@inflexa-ai/harness 0.44.0 → 0.45.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (187) hide show
  1. package/dist/agents/conversation-agent.d.ts +10 -0
  2. package/dist/agents/conversation-agent.js +19 -1
  3. package/dist/agents/sandbox/shared.d.ts +23 -2
  4. package/dist/agents/sandbox/shared.js +51 -18
  5. package/dist/agents/sandbox/translational-safety-agent.js +26 -0
  6. package/dist/agents/sandbox/types.d.ts +24 -1
  7. package/dist/agents/sandbox-catalog.d.ts +14 -0
  8. package/dist/agents/sandbox-catalog.js +19 -0
  9. package/dist/app/approve-plan.d.ts +51 -0
  10. package/dist/app/approve-plan.js +84 -0
  11. package/dist/app/chat-turn.js +6 -3
  12. package/dist/app/data-profile-view.d.ts +1 -1
  13. package/dist/app/run-activity.js +4 -6
  14. package/dist/config/environment-stores.d.ts +4 -2
  15. package/dist/contracts/chat-events.d.ts +14 -2
  16. package/dist/contracts/chat-events.js +1 -1
  17. package/dist/contracts/chat-frame.d.ts +7 -0
  18. package/dist/contracts/chat-frame.js +24 -6
  19. package/dist/contracts/chat-parts.d.ts +73 -2
  20. package/dist/contracts/chat-parts.js +3 -1
  21. package/dist/contracts/index.d.ts +4 -2
  22. package/dist/contracts/index.js +1 -0
  23. package/dist/contracts/part-registry.js +3 -0
  24. package/dist/contracts/schemas/chat-events.d.ts +38 -0
  25. package/dist/contracts/schemas/chat-events.js +8 -0
  26. package/dist/contracts/schemas/chat-parts.d.ts +239 -15
  27. package/dist/contracts/schemas/chat-parts.js +80 -12
  28. package/dist/contracts/schemas/index.d.ts +2 -2
  29. package/dist/contracts/schemas/index.js +2 -2
  30. package/dist/contracts/subagent-activity.d.ts +12 -0
  31. package/dist/contracts/subagent-activity.js +76 -0
  32. package/dist/execution/artifact-metadata.js +1 -3
  33. package/dist/execution/dbos-workflow-purger.d.ts +2 -0
  34. package/dist/execution/dbos-workflow-purger.js +21 -14
  35. package/dist/execution/run-canceler.d.ts +33 -11
  36. package/dist/execution/run-canceler.js +130 -28
  37. package/dist/execution/run-end-record.d.ts +30 -0
  38. package/dist/execution/run-end-record.js +27 -0
  39. package/dist/execution/run-event-parts.d.ts +6 -5
  40. package/dist/execution/run-event-parts.js +6 -5
  41. package/dist/execution/run-event-stream.d.ts +18 -6
  42. package/dist/execution/run-event-stream.js +67 -17
  43. package/dist/execution/run-synthesis.d.ts +21 -0
  44. package/dist/execution/run-synthesis.js +77 -3
  45. package/dist/index.d.ts +28 -10
  46. package/dist/index.js +26 -2
  47. package/dist/lib/hooks.d.ts +1 -1
  48. package/dist/lib/metrics.d.ts +2 -2
  49. package/dist/loop/call-guard.d.ts +50 -0
  50. package/dist/loop/call-guard.js +90 -0
  51. package/dist/loop/compaction.d.ts +5 -0
  52. package/dist/loop/compaction.js +17 -0
  53. package/dist/loop/run-agent.d.ts +9 -0
  54. package/dist/loop/run-agent.js +16 -1
  55. package/dist/loop/run-to-terminal.js +4 -1
  56. package/dist/loop/types.d.ts +8 -0
  57. package/dist/memory/card-builders.d.ts +11 -0
  58. package/dist/memory/card-builders.js +35 -21
  59. package/dist/memory/conversation-display-recorder.d.ts +2 -2
  60. package/dist/memory/conversation-display-recorder.js +38 -4
  61. package/dist/memory/conversation-display-storage.d.ts +9 -2
  62. package/dist/memory/conversation-display-storage.js +18 -3
  63. package/dist/memory/thread-history.d.ts +42 -8
  64. package/dist/memory/thread-history.js +164 -45
  65. package/dist/memory/thread-store.d.ts +13 -0
  66. package/dist/memory/thread-store.js +15 -1
  67. package/dist/prompts/briefing.d.ts +57 -3
  68. package/dist/prompts/briefing.js +129 -2
  69. package/dist/prompts/conversation.d.ts +1 -1
  70. package/dist/prompts/conversation.js +3 -1
  71. package/dist/prompts/planner.js +13 -2
  72. package/dist/provenance/seam.d.ts +39 -20
  73. package/dist/provenance/seam.js +7 -5
  74. package/dist/providers/ai-sdk.js +93 -11
  75. package/dist/providers/types.d.ts +6 -0
  76. package/dist/reference-data/catalog.js +141 -2
  77. package/dist/report-model/authoring-grammar.d.ts +26 -26
  78. package/dist/report-model/pin-snapshot.d.ts +1 -1
  79. package/dist/report-model/pin-snapshot.js +2 -50
  80. package/dist/runtime/assemble.d.ts +8 -1
  81. package/dist/runtime/assemble.js +11 -0
  82. package/dist/runtime/boot.d.ts +3 -0
  83. package/dist/runtime/boot.js +10 -1
  84. package/dist/sandbox/image-packages.d.ts +26 -4
  85. package/dist/sandbox/image-packages.js +18 -4
  86. package/dist/sandbox/sandbox-step-translate.d.ts +23 -0
  87. package/dist/sandbox/sandbox-step-translate.js +24 -0
  88. package/dist/schemas/artifact-manifest.d.ts +1 -1
  89. package/dist/schemas/artifact-manifest.js +11 -9
  90. package/dist/schemas/data-profile-schemas.d.ts +12 -12
  91. package/dist/schemas/plan-schemas.d.ts +75 -21
  92. package/dist/schemas/plan-schemas.js +1 -1
  93. package/dist/schemas/run-synthesis.d.ts +2 -2
  94. package/dist/schemas/workflow-state.d.ts +113 -0
  95. package/dist/schemas/workflow-state.js +54 -0
  96. package/dist/state/analyses-metrics.d.ts +79 -7
  97. package/dist/state/analyses-metrics.js +141 -6
  98. package/dist/state/artifacts.d.ts +66 -3
  99. package/dist/state/artifacts.js +85 -7
  100. package/dist/state/data-profile.d.ts +4 -2
  101. package/dist/state/data-profile.js +7 -5
  102. package/dist/state/index.d.ts +9 -8
  103. package/dist/state/index.js +5 -5
  104. package/dist/state/migrations/20261012120000_index_the_runs_by_start.d.ts +8 -0
  105. package/dist/state/migrations/20261012120000_index_the_runs_by_start.js +12 -0
  106. package/dist/state/migrations/20261013120000_index_the_ended_runs_and_the_recent_threads.d.ts +8 -0
  107. package/dist/state/migrations/20261013120000_index_the_ended_runs_and_the_recent_threads.js +13 -0
  108. package/dist/state/migrations/20261013180000_narrow_the_active_run_index.d.ts +8 -0
  109. package/dist/state/migrations/20261013180000_narrow_the_active_run_index.js +13 -0
  110. package/dist/state/migrations/20261014120000_index_the_plans_by_parent_and_the_runs_by_plan.d.ts +8 -0
  111. package/dist/state/migrations/20261014120000_index_the_plans_by_parent_and_the_runs_by_plan.js +13 -0
  112. package/dist/state/migrations/index.js +8 -0
  113. package/dist/state/plans.d.ts +21 -0
  114. package/dist/state/plans.js +25 -0
  115. package/dist/state/runs.d.ts +79 -14
  116. package/dist/state/runs.js +150 -33
  117. package/dist/state/schema.d.ts +2 -0
  118. package/dist/state/step-executions.d.ts +9 -0
  119. package/dist/state/step-executions.js +22 -0
  120. package/dist/tasks/data-profile-activity.d.ts +7 -1
  121. package/dist/tasks/data-profile-activity.js +18 -9
  122. package/dist/tasks/data-profile.d.ts +15 -1
  123. package/dist/tasks/data-profile.js +16 -11
  124. package/dist/tools/ad-hoc-router.d.ts +2 -0
  125. package/dist/tools/ad-hoc-router.js +18 -2
  126. package/dist/tools/approval/gateway.js +28 -5
  127. package/dist/tools/capability-data.d.ts +36 -0
  128. package/dist/tools/capability-data.js +73 -0
  129. package/dist/tools/display/show-user.d.ts +1 -1
  130. package/dist/tools/execute-analysis.d.ts +80 -1
  131. package/dist/tools/execute-analysis.js +352 -110
  132. package/dist/tools/knowledge/check.d.ts +60 -0
  133. package/dist/tools/knowledge/check.js +92 -0
  134. package/dist/tools/knowledge/client.d.ts +382 -0
  135. package/dist/tools/knowledge/client.js +302 -0
  136. package/dist/tools/knowledge/environment.d.ts +78 -0
  137. package/dist/tools/knowledge/environment.js +105 -0
  138. package/dist/tools/knowledge/index.d.ts +49 -0
  139. package/dist/tools/knowledge/index.js +70 -0
  140. package/dist/tools/knowledge/local-slots.d.ts +62 -0
  141. package/dist/tools/knowledge/local-slots.js +177 -0
  142. package/dist/tools/knowledge/pin.d.ts +14 -0
  143. package/dist/tools/knowledge/pin.js +17 -0
  144. package/dist/tools/knowledge/recommend.d.ts +92 -0
  145. package/dist/tools/knowledge/recommend.js +98 -0
  146. package/dist/tools/knowledge/situation.d.ts +143 -0
  147. package/dist/tools/knowledge/situation.js +164 -0
  148. package/dist/tools/knowledge/skeleton.d.ts +69 -0
  149. package/dist/tools/knowledge/skeleton.js +202 -0
  150. package/dist/tools/knowledge/template.d.ts +137 -0
  151. package/dist/tools/knowledge/template.js +267 -0
  152. package/dist/tools/research/generate-plan.d.ts +23 -0
  153. package/dist/tools/research/generate-plan.js +203 -12
  154. package/dist/tools/research/guarded-address.d.ts +20 -0
  155. package/dist/tools/research/guarded-address.js +148 -0
  156. package/dist/tools/research/inspect-data-profile.d.ts +1 -1
  157. package/dist/tools/research/inspect-run.d.ts +1 -1
  158. package/dist/tools/research/inspect-run.js +7 -7
  159. package/dist/tools/research/searxng-engine.d.ts +40 -0
  160. package/dist/tools/research/searxng-engine.js +120 -0
  161. package/dist/tools/research/web-fetch.d.ts +124 -0
  162. package/dist/tools/research/web-fetch.js +627 -0
  163. package/dist/tools/research/web-search.d.ts +75 -0
  164. package/dist/tools/research/web-search.js +112 -0
  165. package/dist/tools/sandbox/list-available-packages.d.ts +7 -4
  166. package/dist/tools/sandbox/list-available-packages.js +12 -4
  167. package/dist/tools/sandbox/list-available-refs.d.ts +21 -0
  168. package/dist/tools/sandbox/list-available-refs.js +60 -3
  169. package/dist/tools/sandbox/report-blocker.js +5 -1
  170. package/dist/tools/terminal-text.d.ts +11 -0
  171. package/dist/tools/terminal-text.js +19 -0
  172. package/dist/tools/workspace/decision-record.d.ts +81 -0
  173. package/dist/tools/workspace/decision-record.js +117 -0
  174. package/dist/tools/workspace/edit-file.d.ts +3 -1
  175. package/dist/tools/workspace/edit-file.js +20 -2
  176. package/dist/tools/workspace/execute-command.d.ts +17 -0
  177. package/dist/tools/workspace/execute-command.js +28 -1
  178. package/dist/tools/workspace/index.d.ts +1 -0
  179. package/dist/tools/workspace/index.js +1 -0
  180. package/dist/tools/workspace/mutator.d.ts +1 -1
  181. package/dist/workflows/execute-analysis.d.ts +62 -21
  182. package/dist/workflows/execute-analysis.js +307 -79
  183. package/dist/workflows/plan/graph.d.ts +16 -0
  184. package/dist/workflows/plan/graph.js +36 -0
  185. package/dist/workflows/sandbox-step.d.ts +12 -0
  186. package/dist/workflows/sandbox-step.js +14 -12
  187. package/package.json +5 -1
@@ -50,20 +50,25 @@
50
50
  * the run before any step starts
51
51
  * - a `suspend` error of a synthesis model request suspends the run in
52
52
  * place of the synthesis failure
53
- * - the analysis flips to `suspended_insufficient_funds` in
54
- * `collectAndComplete`. The paused parent lands in `CANCELLED`, a
53
+ * - the run row and the analysis flip to `suspended_insufficient_funds` in
54
+ * `collectAndComplete`. The run status is terminal and does not block a
55
+ * new run of the plan. The paused parent lands in `CANCELLED`, a
55
56
  * durably reschedulable state; re-driving it is a future enhancement,
56
57
  * not yet wired here. The reason of the host rides to the run row, the
57
58
  * charge close, the metric, and the terminal part, unread.
58
59
  * 5. `synthesizeFindings` (single sequential block, no sandbox)
59
60
  * 6. `collectAndComplete` (terminal — runs on ALL paths)
60
61
  * - determine final status from completed/cancelled/failed counts
61
- * - update `cortex_runs.status` + `error`, and inside that same step record
62
- * the run outcome metric plus the outcome of every settled step whose
63
- * child never wrote its own terminal row (a cancelled child, or one that
64
- * died before `failStep`)
65
- * - close running charge with matching reason
66
- * - revoke run authorization via the injected `runAuthorizer` seam
62
+ * - update `cortex_runs.status` + `error`. When the write changes the row,
63
+ * the same step emits `run_completed` before the commit, records the run
64
+ * outcome metric plus the outcome of every settled step whose child never
65
+ * wrote its own terminal row (a cancelled child, or one that died before
66
+ * `failStep`), closes the running charge with the matching outcome, and
67
+ * revokes the run authorization through the injected `runAuthorizer`
68
+ * - the write retries with backoff on a failed transaction. When the write is
69
+ * refused, or fails on each attempt, close and revoke in their own steps, then
70
+ * emit `run_completed`. After a failed write the row stays `running`, so the
71
+ * body records its run end as a workflow event for the run canceler
67
72
  * - emit terminal stream part
68
73
  * - WRITES NOTHING to the conversation thread (results are pull-only
69
74
  * via `inspectRun`)
@@ -73,18 +78,20 @@ import { err, ok } from "neverthrow";
73
78
  import { mkdir } from "node:fs/promises";
74
79
  import { join } from "node:path";
75
80
  import { forStep } from "../auth/types.js";
81
+ import { RUN_END_EVENT_KEY } from "../execution/run-end-record.js";
76
82
  import { createNoopLogger } from "../lib/console-logger.js";
77
83
  import { deliverNotice, passGate } from "../lib/hooks.js";
78
84
  import { ATTR_INFLEXA_STEP_ID, stableSpan } from "../lib/otel-spans.js";
79
85
  import { recordRunCompleted, recordStepCompleted, stepOutcomeOf } from "../lib/metrics.js";
80
- import { describeDbError } from "../lib/db-result.js";
86
+ import { describeDbError, withTransaction } from "../lib/db-result.js";
81
87
  import { unwrapOrThrow } from "../lib/result.js";
82
- import { countArtifactsForRun, insertStepExecution, loadDataProfileStatus, queryActiveRun, queryStepArtifactPaths, queryStepsByRun, seedStepExecutions, setRunSynthesisOutcome, suspendAnalysis as suspendAnalysisQuery, sweepPendingStepExecutions, updateRunStatus, updateStepExecution, } from "../state/index.js";
88
+ import { countArtifactsByRuns, insertStepExecution, loadDataProfileStatus, queryActiveRun, queryStepArtifactPaths, queryStepsByRun, seedStepExecutions, setRunSynthesisOutcome, suspendAnalysis as suspendAnalysisQuery, sweepPendingStepExecutions, updateRunStatus, updateStepExecution, } from "../state/index.js";
83
89
  import { addChatUsage, hasReportedUsage } from "../loop/metrics.js";
90
+ import { flattenDataPart } from "../contracts/chat-frame.js";
84
91
  import { SYNTHESIS_AGENT_ID, loadStepSummariesFromDisk } from "../execution/run-synthesis.js";
85
- import { MAX_UPSTREAM_ARTIFACTS, composeStepBriefing, emptyStepMemory } from "../prompts/briefing.js";
92
+ import { MAX_UPSTREAM_ARTIFACTS, composeStepBriefing, emptyStepMemory, } from "../prompts/briefing.js";
86
93
  import { SYNTHESIS_STEP_ID, runDir, runStepDir, stepSubdir, stepWritePrefix, toSandboxPath } from "../workspace/paths.js";
87
- import { isChatDataPart } from "../sandbox/sandbox-step-translate.js";
94
+ import { isChatDataPart, synthesisEmission } from "../sandbox/sandbox-step-translate.js";
88
95
  import { synthesizeRun } from "../app/synthesize-run.js";
89
96
  import { computeTopologicalLevels, scheduleReady, validatePlanDag } from "./execute-analysis-scheduler.js";
90
97
  import { recordCancelledChild } from "./metrics.js";
@@ -181,7 +188,7 @@ function observeRunGuarded(deps, observation) {
181
188
  * projection/timeout-propagation tests.
182
189
  */
183
190
  export function buildChildInput(args) {
184
- const { input, stepId, level, runId, workflowId, prompt } = args;
191
+ const { input, stepId, level, runId, workflowId, prompt, templateBinding } = args;
185
192
  const resources = input.resourcesByStepId[stepId];
186
193
  if (!resources) {
187
194
  throw new Error(`executeAnalysis: step "${stepId}" missing from resourcesByStepId — every step must declare resources`);
@@ -203,6 +210,9 @@ export function buildChildInput(args) {
203
210
  // genuinely declared nothing, and it silently deletes every same-run edge
204
211
  // the step was entitled to.
205
212
  dependsOn: planStep.depends_on,
213
+ // Composed beside the seed, thus the same checkpoint carries both and a
214
+ // replay hands the child the binding its briefing describes.
215
+ ...(templateBinding ? { templateBinding } : {}),
206
216
  level,
207
217
  prompt,
208
218
  parentWorkflowId: workflowId,
@@ -220,13 +230,18 @@ export function buildChildInput(args) {
220
230
  * rendered any earlier (at plan submission, say) cannot mention them, and the
221
231
  * agent is left to rediscover its own upstream.
222
232
  *
223
- * REPLAY: the caller wraps this in a `DBOS.runStep`, so the composed string is
224
- * checkpointed and a replay returns it verbatim — the DB and disk are not read
225
- * again and cannot drift the seed under a recovered run. Never call it
226
- * unwrapped from the workflow body.
233
+ * The template contract of a grounded step is read with the package, and the
234
+ * plan settings are bound to it: the seed describes the binding, and the child
235
+ * input carries it, from one checkpoint. The child fetches nothing.
236
+ *
237
+ * REPLAY: the caller wraps this in a `DBOS.runStep`, so the composed seed is
238
+ * checkpointed and a replay returns it verbatim — the DB, the disk, and the
239
+ * knowledge service are not read again and cannot drift the seed under a
240
+ * recovered run. Never call it unwrapped from the workflow body.
227
241
  */
228
242
  export async function composeStepSeed(args) {
229
- return composeStepBriefing(await gatherStepPackage(args));
243
+ const { briefing, templateBinding } = await gatherStepPackage(args);
244
+ return { prompt: composeStepBriefing(briefing), ...(templateBinding ? { templateBinding } : {}) };
230
245
  }
231
246
  /**
232
247
  * Do every read of one step's package and give the data its seed is rendered
@@ -240,6 +255,9 @@ export async function composeStepSeed(args) {
240
255
  * so an edit during a run reaches each later step, and two steps of one run
241
256
  * can carry different copies.
242
257
  *
258
+ * The contract of the template a grounded step renders is read here too, from
259
+ * the knowledge client, and the plan settings are bound to it.
260
+ *
243
261
  * A read failure throws (house rules): a swallowed error would be frozen into
244
262
  * the checkpointed seed and served to every replay of this step.
245
263
  */
@@ -280,12 +298,151 @@ export async function gatherStepPackage(args) {
280
298
  agentByStepId: input.agentByStepId,
281
299
  pool: deps.pool,
282
300
  });
301
+ const contract = await resolveTemplateContract(step, deps.knowledge);
302
+ return {
303
+ briefing: {
304
+ step,
305
+ workspace,
306
+ profile: profile?.result ?? null,
307
+ upstream,
308
+ memory: { goal: memory.goal, constraints: memory.constraints },
309
+ ...contract.briefing,
310
+ },
311
+ ...(contract.binding ? { templateBinding: contract.binding } : {}),
312
+ };
313
+ }
314
+ /** A served contract carries no `match`; the two refusals do. */
315
+ function isTemplateContract(answer) {
316
+ return !("match" in answer);
317
+ }
318
+ /**
319
+ * Fetch the contract of the template a step grounds on, and bind the plan
320
+ * settings to it. A step without a template has no template part. A step
321
+ * with one and no way to read it (no client, no answer, or an unknown
322
+ * template) gets the reason, thus the seed reports the absence rather than
323
+ * hiding it, and no binding.
324
+ */
325
+ async function resolveTemplateContract(step, client) {
326
+ const ref = step.grounding?.template;
327
+ if (!ref)
328
+ return { briefing: {} };
329
+ if (!client)
330
+ return { briefing: { templateNotRetrieved: "no knowledge client is bound" } };
331
+ const answer = await client.contract(ref);
332
+ if (!isTemplateContract(answer)) {
333
+ const reason = answer.match === "unavailable"
334
+ ? `the knowledge service did not answer: ${answer.reason}`
335
+ : `the knowledge service refused the lookup: ${answer.message}`;
336
+ return { briefing: { templateNotRetrieved: reason } };
337
+ }
338
+ return bindTemplateContract(ref, answer, step.grounding?.settings ?? [], {
339
+ stepId: step.id,
340
+ claims: step.grounding?.claims ?? [],
341
+ ...(step.grounding?.snapshot && step.grounding.snapshot !== "none" ? { snapshot: step.grounding.snapshot } : {}),
342
+ });
343
+ }
344
+ /** The version of a template reference (`tpl-x@1.0.0` gives `1.0.0`), or `undefined` when the reference has none. */
345
+ function versionOfRef(ref) {
346
+ const at = ref.indexOf("@");
347
+ return at < 0 ? undefined : ref.slice(at + 1);
348
+ }
349
+ /** Two setting values agree when their JSON agrees: the values are scalars or lists of strings. */
350
+ function sameSettingValue(a, b) {
351
+ return JSON.stringify(a) === JSON.stringify(b);
352
+ }
353
+ /**
354
+ * Whether a plan value is a value of the slot, and not a policy word. A
355
+ * procedure parameter and a template slot share a name, but not always a
356
+ * value domain: `min_samples = smallest_group_size` names a rule the agent
357
+ * applies, and the slot takes the resulting integer. Only a member of the
358
+ * enum of the slot, a number for a number slot, or a boolean for a boolean
359
+ * slot binds. A free-text slot never binds, because the host cannot tell a
360
+ * policy word from a value there.
361
+ */
362
+ function fitsSlot(slot, value) {
363
+ if (slot.enum)
364
+ return typeof value === "string" && slot.enum.includes(value);
365
+ if (slot.type === "integer" || slot.type === "number")
366
+ return typeof value === "number";
367
+ if (slot.type === "boolean")
368
+ return typeof value === "boolean";
369
+ return false;
370
+ }
371
+ function bindTemplateContract(ref, contract, settings, grounding) {
372
+ const refVersion = versionOfRef(ref);
373
+ const versionMatches = refVersion === undefined || refVersion === contract.version;
374
+ const adaptable = contract.parameters.filter((slot) => slot.adaptable);
375
+ const adaptableNames = new Set(adaptable.map((slot) => slot.name));
376
+ const pinnedByName = new Map(contract.parameters.filter((slot) => !slot.adaptable).map((slot) => [slot.name, slot]));
377
+ const bound = [];
378
+ const unbound = [];
379
+ const slots = {};
380
+ const sources = {};
381
+ for (const setting of settings) {
382
+ const shown = `\`${setting.name}\` = ${JSON.stringify(setting.value)} (${setting.step})`;
383
+ if (!versionMatches) {
384
+ unbound.push(`${shown}: not bound, because the served version differs from the plan`);
385
+ continue;
386
+ }
387
+ if (adaptableNames.has(setting.name)) {
388
+ const slot = adaptable.find((candidate) => candidate.name === setting.name);
389
+ if (slot && !fitsSlot(slot, setting.value)) {
390
+ unbound.push(`${shown}: a policy of the plan, not a value of the \`${setting.name}\` slot (${slot.type}); state the value it gives`);
391
+ continue;
392
+ }
393
+ const prior = slots[setting.name];
394
+ if (prior !== undefined) {
395
+ if (!sameSettingValue(prior, setting.value))
396
+ unbound.push(`${shown}: differs from the bound value ${JSON.stringify(prior)} of the same slot`);
397
+ continue;
398
+ }
399
+ slots[setting.name] = setting.value;
400
+ if (setting.source)
401
+ sources[setting.name] = setting.source;
402
+ bound.push({ name: setting.name, value: setting.value, source: setting.source ?? "plan" });
403
+ continue;
404
+ }
405
+ const pinned = pinnedByName.get(setting.name);
406
+ if (pinned) {
407
+ const pinnedValue = pinned.default === undefined ? "a fixed value" : JSON.stringify(pinned.default);
408
+ unbound.push(sameSettingValue(pinned.default, setting.value)
409
+ ? `${shown}: pinned by the template at the same value`
410
+ : `${shown}: the template pins \`${setting.name}\` at ${pinnedValue}, and the plan value cannot be sent`);
411
+ continue;
412
+ }
413
+ unbound.push(`${shown}: no slot of the template carries it`);
414
+ }
283
415
  return {
284
- step,
285
- workspace,
286
- profile: profile?.result ?? null,
287
- upstream,
288
- memory: { goal: memory.goal, constraints: memory.constraints },
416
+ briefing: {
417
+ template: {
418
+ ref,
419
+ version_served: contract.version,
420
+ slots: adaptable,
421
+ inputs: (contract.inputs ?? []).map((input) => ({
422
+ name: input.name,
423
+ path: input.path,
424
+ ...(input.description ? { description: input.description } : {}),
425
+ })),
426
+ bound,
427
+ unbound_settings: unbound,
428
+ },
429
+ },
430
+ ...(versionMatches
431
+ ? {
432
+ binding: {
433
+ template: ref,
434
+ slots,
435
+ sources,
436
+ step: grounding.stepId,
437
+ claims: grounding.claims,
438
+ ...(grounding.snapshot ? { snapshot: grounding.snapshot } : {}),
439
+ // The local slots of the contract, thus the tool binds a path or a column name on the machine
440
+ // without a second read of the contract.
441
+ local: contract.parameters.filter((slot) => slot.local === true),
442
+ adaptable: adaptable.map((slot) => slot.name),
443
+ },
444
+ }
445
+ : {}),
289
446
  };
290
447
  }
291
448
  /**
@@ -359,9 +516,20 @@ export function synthesisRowUpdate(outcome, durationMs) {
359
516
  */
360
517
  export function synthesizeFindings(args) {
361
518
  const { analysisId, runId, completedSteps, session, deps } = args;
519
+ const logger = (deps.logger ?? createNoopLogger()).named("executeAnalysis.synthesis");
520
+ // Observation is a diagnostic channel: a failed progress write is logged and never fails the synthesis.
521
+ let progressSeq = 0;
522
+ const writeProgressAlone = async (part, phase) => {
523
+ try {
524
+ await DBOS.writeStream("events", part);
525
+ }
526
+ catch (err) {
527
+ logger.warn("synthesis progress write failed (non-fatal)", { phase, ...logger.errorFields(err) });
528
+ }
529
+ };
362
530
  const emit = (event) => {
363
531
  const part = isChatDataPart(event)
364
- ? event
532
+ ? flattenDataPart(event)
365
533
  : {
366
534
  type: "data-loop-event",
367
535
  data: { stepId: "synthesis", event },
@@ -395,14 +563,15 @@ export function synthesizeFindings(args) {
395
563
  // call path.
396
564
  session: forStep(session, SYNTHESIS_STEP_ID),
397
565
  emit,
398
- onProgress: (phase, activity, extra = {}) => DBOS.writeStream("events", {
399
- type: "data-synthesis-progress",
400
- id: `synthesis-progress-${runId}`,
401
- runId,
402
- phase,
403
- activity,
404
- ...extra,
405
- }),
566
+ onProgress: async (phase, activity, extra = {}) => {
567
+ // Synthesis runs inside one `DBOS.runStep`, thus `DBOS.now()` reads the wall clock here, and a rerun of
568
+ // the step after a crash writes its entries again under the same ids. A reader dedups an entry by id.
569
+ const seq = progressSeq++;
570
+ const at = new Date(await DBOS.now()).toISOString();
571
+ const parts = synthesisEmission(runId, SYNTHESIS_STEP_ID, seq, phase, activity, at, extra);
572
+ await writeProgressAlone(parts.progress, phase);
573
+ await writeProgressAlone(parts.entry, phase);
574
+ },
406
575
  });
407
576
  }
408
577
  // ── Workflow registration ────────────────────────────────────────────
@@ -847,17 +1016,21 @@ async function runSchedulerLoop(args) {
847
1016
  // can be named. Checkpointed so a replay re-dispatches the child
848
1017
  // with a byte-identical prompt instead of re-reading the DB/disk.
849
1018
  const seedStepName = `compose-step-seed:${stepId}`;
850
- const prompt = await DBOS.runStep(() => {
1019
+ const checkpointed = await DBOS.runStep(() => {
851
1020
  stableSpan(seedStepName, "compose-step-seed", { [ATTR_INFLEXA_STEP_ID]: stepId });
852
1021
  return composeStepSeed({ input, stepId, runId, deps });
853
1022
  }, { name: seedStepName });
1023
+ // A run recovered from a checkpoint written when this step returned the
1024
+ // bare seed string replays that string: it carries no binding.
1025
+ const seed = typeof checkpointed === "string" ? { prompt: checkpointed } : checkpointed;
854
1026
  const baseChildInput = buildChildInput({
855
1027
  input,
856
1028
  stepId,
857
1029
  level: levels.get(stepId) ?? 0,
858
1030
  runId,
859
1031
  workflowId,
860
- prompt,
1032
+ prompt: seed.prompt,
1033
+ ...(seed.templateBinding ? { templateBinding: seed.templateBinding } : {}),
861
1034
  });
862
1035
  const childInput = {
863
1036
  ...baseChildInput,
@@ -1038,6 +1211,9 @@ async function cancelInFlight(inFlight, cause, canceledByParent) {
1038
1211
  recordCancelledChild({ cause });
1039
1212
  }
1040
1213
  }
1214
+ // ── (6) collectAndComplete ────────────────────────────────────────────
1215
+ /** The waits between the attempts are 1, 2, 4, and 8 seconds. */
1216
+ const FINAL_STATUS_WRITE_RETRY = { retriesAllowed: true, maxAttempts: 5, intervalSeconds: 1, backoffRate: 2 };
1041
1217
  async function collectAndComplete(args) {
1042
1218
  const { input, runId, workflowId, startedAtMs, completed, failed, canceled, suspension, failureReason, forceFailed, deps } = args;
1043
1219
  // A forced failure is terminal and never a resumable suspension.
@@ -1066,18 +1242,9 @@ async function collectAndComplete(args) {
1066
1242
  : suspended !== null
1067
1243
  ? "workflow-suspended"
1068
1244
  : "workflow-canceled";
1069
- // Terminal clock read + run-completion provenance, emitted BEFORE the `cortex_runs`
1070
- // status write below — and thus before any of the terminal cleanup steps. The CLI
1071
- // watches the run by POLLING that row (`run.ts` `waitForRunTerminal`) and shuts down
1072
- // the instant it leaves `running`, flushing provenance and then `process.exit()`ing;
1073
- // emitting the record only after the status write races that shutdown and can drop
1074
- // the run's terminal provenance entirely. Emitting first makes the record dirty
1075
- // (synchronously, via the bus) before the row can be observed terminal, so the CLI's
1076
- // flush always captures it. Not step-wrapped — a DBOS recovery replay must re-fire it;
1077
- // `DBOS.now()` is checkpointed, so the span stays replay-stable (see `RunProvenanceEvent`).
1078
- // The duration measures from the `run_started` read threaded in as `startedAtMs`.
1245
+ const finalError = failureReason ?? (status === "canceled" ? "external_cancel" : null);
1079
1246
  const terminalAtMs = await DBOS.now();
1080
- emitProvenanceGuarded(deps, {
1247
+ const emitRunEnd = () => emitProvenanceGuarded(deps, {
1081
1248
  type: "run_completed",
1082
1249
  analysisId: input.analysisId,
1083
1250
  runId,
@@ -1085,12 +1252,58 @@ async function collectAndComplete(args) {
1085
1252
  atMs: terminalAtMs,
1086
1253
  durationMs: terminalAtMs - startedAtMs,
1087
1254
  }, input.runSession);
1088
- await ledgerStep(logger.with({ status }), "persist-final-status", async () => {
1089
- const written = await updateRunStatus(deps.pool, runId, status, failureReason ?? (status === "canceled" && suspended === null ? "external_cancel" : null));
1090
- if (written.isErr())
1091
- return written;
1092
- // The outcome metrics ride inside the step that persists the terminal status: DBOS
1093
- // caches the step, so a recovered body records nothing again. A step's own terminal
1255
+ // Ownership defaults to true for inputs persisted before the field existed (a
1256
+ // workflow recovered across this deploy) — those were always Cortex-owned.
1257
+ const authorization = {
1258
+ runSession: input.runSession,
1259
+ ownsMandate: input.ownsMandate ?? true, // oss-core-managed-ok
1260
+ };
1261
+ const closeCharge = () => deliverNotice(logger.with({ chargeOutcome: chargeOutcome.kind }), "RunCharge.close", deps.runCharge.close({
1262
+ analysisId: input.analysisId,
1263
+ runId,
1264
+ outcome: chargeOutcome,
1265
+ session: input.runSession,
1266
+ }));
1267
+ const revokeAuthorization = () => deliverNotice(logger.with({ revokeReason }), "RunAuthorizer.revoke", deps.runAuthorizer.revoke(authorization, revokeReason));
1268
+ // The writer that wins the row owns the run end: the `run_completed` emit, the charge close,
1269
+ // and the revoke. The step does all three when its write changes the row. An engine cancel
1270
+ // cannot stop a step that started, so a won write is never cut before these acts.
1271
+ //
1272
+ // The emit rides inside the transaction of the write. The update holds the row lock until the
1273
+ // commit, so the run canceler waits and then loses, and no reader sees the terminal row before
1274
+ // the emit. The cli polls the row and flushes the provenance when it leaves `running`; an emit
1275
+ // after the commit can miss that flush. `emitRunEvent` returns `void`, so the emit completes before the commit.
1276
+ //
1277
+ // DBOS retries only a thrown error, so an attempt before the last throws on a failed transaction.
1278
+ // The last attempt returns "failed" instead: the body takes the failed path with no `try`, and a
1279
+ // failure of DBOS to record a won step cannot be mistaken for a failed write. A commit can fail
1280
+ // after the emit, so `emittedInStep` keeps the later attempts from emitting again.
1281
+ let emittedInStep = false;
1282
+ const finalWrite = await DBOS.runStep(async () => {
1283
+ const stepLog = logger.with({ status }).named("persist-final-status");
1284
+ const written = await withTransaction(deps.pool, "runs.persistFinalStatus", (client) => updateRunStatus(client, runId, status, finalError).map((changed) => {
1285
+ if (changed && !emittedInStep) {
1286
+ emitRunEnd();
1287
+ emittedInStep = true;
1288
+ }
1289
+ return changed;
1290
+ }));
1291
+ if (written.isErr()) {
1292
+ const attempt = DBOS.stepStatus;
1293
+ const isLastAttempt = attempt?.currentAttempt === undefined || attempt.maxAttempts === undefined || attempt.currentAttempt >= attempt.maxAttempts;
1294
+ const failure = { error: describeDbError(written.error), ...stepLog.errorFields(written.error.cause) };
1295
+ if (isLastAttempt) {
1296
+ stepLog.error("ledger write failed", failure);
1297
+ return "failed";
1298
+ }
1299
+ stepLog.warn("ledger write failed, the step retries", { ...failure, attempt: attempt.currentAttempt });
1300
+ throw new Error(`ledger write failed: ${describeDbError(written.error)}`, { cause: written.error.cause });
1301
+ }
1302
+ // A refused write means an earlier write already set a terminal status: the run
1303
+ // canceler's, or an earlier run of this step before a crash. That writer owns the run end.
1304
+ if (!written.value)
1305
+ return "refused";
1306
+ // DBOS caches the step, so a recovered body records nothing again. A step's own terminal
1094
1307
  // row already carried its metric; the residue counted here is every settled step
1095
1308
  // without one — a child the cancel cut before `mark-canceled` (the cancel check runs
1096
1309
  // before a step body), or one that died before `failStep`. The ledger read is kept
@@ -1107,8 +1320,41 @@ async function collectAndComplete(args) {
1107
1320
  recordStepCompleted({ agentId: row.agentId, status: "failed" });
1108
1321
  }
1109
1322
  recordRunCompleted({ workflow: "analysis", status, durationMs: terminalAtMs - startedAtMs });
1110
- return written;
1111
- });
1323
+ await closeCharge();
1324
+ await revokeAuthorization();
1325
+ return "won";
1326
+ }, { name: "persist-final-status", ...FINAL_STATUS_WRITE_RETRY });
1327
+ // A refused write leaves two owners. When the canceler won, its engine cancel came before its
1328
+ // write, so `close-running-charge` throws the cancel and the body does no act of the run end.
1329
+ // When a recovery runs the status step again after its own write landed, no cancel occurred and
1330
+ // the body completes the run end here. A failed write leaves the row `running` with no owner,
1331
+ // and the body still closes and revokes.
1332
+ let closed = false;
1333
+ let revoked = false;
1334
+ if (finalWrite !== "won") {
1335
+ closed = await DBOS.runStep(closeCharge, { name: "close-running-charge" });
1336
+ revoked = await DBOS.runStep(revokeAuthorization, { name: "revoke-run-auth" });
1337
+ }
1338
+ // The step emits only in the execution that ran it. A won step that DBOS gives from the cache
1339
+ // did not run, and a refused or failed write did not emit; the body fires the run end here
1340
+ // with the same checkpointed values. It is not step-wrapped, so a recovery replay fires it again.
1341
+ if (!emittedInStep)
1342
+ emitRunEnd();
1343
+ // After a failed write the row is not terminal, so the body records its run end for the run
1344
+ // canceler. The record comes after the acts: a cancel before it stops the body and the canceler
1345
+ // then owns the run end. A cancel is the only error that goes back to DBOS; a failed record must
1346
+ // not undo an otherwise-complete run, like the sibling terminal steps.
1347
+ if (finalWrite === "failed") {
1348
+ const recorded = { status, error: finalError, closed, revoked };
1349
+ try {
1350
+ await DBOS.setEvent(RUN_END_EVENT_KEY, recorded);
1351
+ }
1352
+ catch (error) {
1353
+ if (error instanceof DBOSErrors.DBOSWorkflowCancelledError)
1354
+ throw error;
1355
+ logger.error("run end record failed, the run canceler acts as for a run with no record", { status, ...logger.errorFields(error) });
1356
+ }
1357
+ }
1112
1358
  // Record the run's synthesis outcome AFTER the status write — a reader that
1113
1359
  // sees a terminal status then also sees whether (and why) synthesis ran. A
1114
1360
  // null outcome means synthesis never ran; leave the columns NULL ("unknown").
@@ -1119,39 +1365,21 @@ async function collectAndComplete(args) {
1119
1365
  await ledgerStep(logger.with({ synthesisStatus: outcome.status }), "persist-synthesis-outcome", () => setRunSynthesisOutcome(deps.pool, runId, outcome.status, outcome.reason));
1120
1366
  }
1121
1367
  // Sweep never-started steps to `skipped` — but ONLY on genuinely-terminal
1122
- // paths. The gate is the BRANCH, not the written run status: the resumable
1123
- // suspension below also writes "canceled", yet its pending rows must survive
1124
- // for the resumed workflow to execute. Log-don't-rollback, like every other
1125
- // finalisation step here.
1368
+ // paths. The gate is the BRANCH, not the written run status: the pending rows
1369
+ // of a suspended run must survive for a later resume. Log-don't-rollback, like
1370
+ // every other finalisation step here.
1126
1371
  if (suspended === null) {
1127
1372
  await ledgerStep(logger, "sweep-pending-steps", () => sweepPendingStepExecutions(deps.pool, runId));
1128
1373
  }
1129
1374
  if (suspended !== null) {
1130
1375
  await ledgerStep(logger, "suspend-analysis", () => suspendAnalysisQuery(deps.pool, input.analysisId));
1131
1376
  }
1132
- await DBOS.runStep(() => deliverNotice(logger.with({ chargeOutcome: chargeOutcome.kind }), "RunCharge.close", deps.runCharge.close({
1133
- analysisId: input.analysisId,
1134
- runId,
1135
- outcome: chargeOutcome,
1136
- session: input.runSession,
1137
- })), { name: "close-running-charge" });
1138
- // Revoke the run authorization for the terminal run state. Ownership
1139
- // defaults to true for inputs persisted before the field existed (a
1140
- // workflow recovered across this deploy) — those were always Cortex-owned.
1141
- const authorization = {
1142
- runSession: input.runSession,
1143
- ownsMandate: input.ownsMandate ?? true, // oss-core-managed-ok
1144
- };
1145
- await DBOS.runStep(() => deliverNotice(logger.with({ revokeReason }), "RunAuthorizer.revoke", deps.runAuthorizer.revoke(authorization, revokeReason)), {
1146
- name: "revoke-run-auth",
1147
- });
1148
- // The `run_completed` provenance was already emitted above (before the status write, to
1149
- // beat the CLI's poll-and-shutdown). These branches only fan out the UI stream part, whose
1377
+ // The run end was emitted above. These branches only fan out the UI stream part, whose
1150
1378
  // completed/failed shapes genuinely differ — the provenance record does not.
1151
1379
  if (status === "completed" || status === "partial") {
1152
- const artifactCount = await DBOS.runStep(async () => await countArtifactsForRun(deps.pool, input.analysisId, runId).unwrapOr(0), {
1153
- name: "count-run-artifacts",
1154
- });
1380
+ const artifactCount = await DBOS.runStep(async () => await countArtifactsByRuns(deps.pool, input.analysisId, [runId])
1381
+ .map((counts) => counts[0]?.count ?? 0)
1382
+ .unwrapOr(0), { name: "count-run-artifacts" });
1155
1383
  await emitStreamPart({
1156
1384
  type: "data-run-completed",
1157
1385
  runId,
@@ -1197,7 +1425,7 @@ async function collectAndComplete(args) {
1197
1425
  function deriveFinalStatus(args) {
1198
1426
  const { totalSteps, completed, failed, canceled, suspended } = args;
1199
1427
  if (suspended)
1200
- return "canceled";
1428
+ return "suspended_insufficient_funds";
1201
1429
  if (failed.size > 0) {
1202
1430
  return completed.size > 0 ? "partial" : "failed";
1203
1431
  }
@@ -0,0 +1,16 @@
1
+ /**
2
+ * The graph of a stored plan: each step with its agent, its dependencies, and its
3
+ * level. The levels and the agent fallback are those of `data-dag-state`, so a
4
+ * host draws the same graph before, during, and after a run.
5
+ */
6
+ import type { PlanGraphStep } from "../../contracts/chat-parts.js";
7
+ /**
8
+ * Give the graph of a stored plan document, in the order of the plan. An empty
9
+ * step list gives an empty graph. A document that fails the stored plan schema, or
10
+ * whose steps have a duplicate id, a missing dependency, or a cycle, gives `null`
11
+ * (a plan that no run can execute has no levels).
12
+ *
13
+ * The throw of `validatePlanDag` is caught here, at the boundary: the function is a
14
+ * read for a caller that has no use for the typed error.
15
+ */
16
+ export declare function planGraphOf(plan: unknown): PlanGraphStep[] | null;
@@ -0,0 +1,36 @@
1
+ /**
2
+ * The graph of a stored plan: each step with its agent, its dependencies, and its
3
+ * level. The levels and the agent fallback are those of `data-dag-state`, so a
4
+ * host draws the same graph before, during, and after a run.
5
+ */
6
+ import { AnalysisPlanSchema } from "../../schemas/workflow-state.js";
7
+ import { computeTopologicalLevels, validatePlanDag } from "../execute-analysis-scheduler.js";
8
+ /**
9
+ * Give the graph of a stored plan document, in the order of the plan. An empty
10
+ * step list gives an empty graph. A document that fails the stored plan schema, or
11
+ * whose steps have a duplicate id, a missing dependency, or a cycle, gives `null`
12
+ * (a plan that no run can execute has no levels).
13
+ *
14
+ * The throw of `validatePlanDag` is caught here, at the boundary: the function is a
15
+ * read for a caller that has no use for the typed error.
16
+ */
17
+ export function planGraphOf(plan) {
18
+ const parsed = AnalysisPlanSchema.safeParse(plan);
19
+ if (!parsed.success)
20
+ return null;
21
+ const steps = parsed.data.steps;
22
+ try {
23
+ validatePlanDag(steps);
24
+ }
25
+ catch {
26
+ return null;
27
+ }
28
+ const levels = computeTopologicalLevels(steps);
29
+ return steps.map((step) => ({
30
+ id: step.id,
31
+ name: step.name,
32
+ agent: step.agent ?? "unknown",
33
+ dependsOn: [...step.depends_on],
34
+ level: levels.get(step.id) ?? 0,
35
+ }));
36
+ }
@@ -49,6 +49,7 @@ import type { SandboxRef } from "../sandbox/types.js";
49
49
  import { ProvenanceCollector } from "../provenance/collector.js";
50
50
  import { type BlockerHolder } from "../tools/sandbox/report-blocker.js";
51
51
  import { type FileMetadataCell } from "../tools/sandbox/submit-file-metadata.js";
52
+ import type { TemplateBinding } from "../tools/knowledge/template.js";
52
53
  /**
53
54
  * Input the parent hands the child. Must be JSON-serialisable — DBOS
54
55
  * persists this as the workflow's input row.
@@ -70,6 +71,17 @@ export interface SandboxStepInput {
70
71
  * rather than admitting unstable ones.
71
72
  */
72
73
  readonly dependsOn?: readonly string[];
74
+ /**
75
+ * The plan settings bound to the template of this step, composed by the
76
+ * parent at dispatch beside the seed. The agent factory hands it to
77
+ * `knowledge_template`, which merges the bound slots under the model
78
+ * values and refuses a silent change of one.
79
+ *
80
+ * Optional for the same reason as `dependsOn`: a durable input that
81
+ * predates the field recovers without it, and a step without a grounded
82
+ * template has nothing to bind. Absence means the model values ride alone.
83
+ */
84
+ readonly templateBinding?: TemplateBinding;
73
85
  /** Topological level — persisted on `cortex_step_executions.wave`. */
74
86
  readonly level: number;
75
87
  /** User-content prompt the agent receives as its initial message. */