@inflexa-ai/harness 0.45.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.
- package/dist/agents/conversation-agent.d.ts +10 -0
- package/dist/agents/conversation-agent.js +19 -1
- package/dist/agents/sandbox/shared.d.ts +23 -2
- package/dist/agents/sandbox/shared.js +51 -18
- package/dist/agents/sandbox/translational-safety-agent.js +26 -0
- package/dist/agents/sandbox/types.d.ts +24 -1
- package/dist/agents/sandbox-catalog.d.ts +14 -0
- package/dist/agents/sandbox-catalog.js +19 -0
- package/dist/app/approve-plan.d.ts +5 -2
- package/dist/app/approve-plan.js +1 -1
- package/dist/app/chat-turn.js +6 -3
- package/dist/config/environment-stores.d.ts +4 -2
- package/dist/contracts/chat-events.d.ts +12 -2
- package/dist/contracts/chat-events.js +1 -1
- package/dist/contracts/chat-frame.js +6 -2
- package/dist/contracts/index.d.ts +1 -1
- package/dist/contracts/schemas/chat-events.d.ts +25 -0
- package/dist/contracts/schemas/chat-events.js +7 -0
- package/dist/contracts/schemas/chat-parts.d.ts +39 -0
- package/dist/contracts/schemas/chat-parts.js +17 -0
- package/dist/contracts/schemas/index.d.ts +1 -1
- package/dist/contracts/schemas/index.js +1 -1
- package/dist/execution/dbos-workflow-purger.d.ts +2 -0
- package/dist/execution/dbos-workflow-purger.js +21 -14
- package/dist/execution/run-canceler.d.ts +33 -11
- package/dist/execution/run-canceler.js +129 -27
- package/dist/execution/run-end-record.d.ts +30 -0
- package/dist/execution/run-end-record.js +27 -0
- package/dist/index.d.ts +9 -5
- package/dist/index.js +11 -1
- package/dist/loop/call-guard.d.ts +50 -0
- package/dist/loop/call-guard.js +90 -0
- package/dist/loop/compaction.d.ts +5 -0
- package/dist/loop/compaction.js +17 -0
- package/dist/loop/run-agent.d.ts +9 -0
- package/dist/loop/run-agent.js +15 -1
- package/dist/loop/run-to-terminal.js +4 -1
- package/dist/loop/types.d.ts +6 -0
- package/dist/memory/conversation-display-recorder.d.ts +0 -2
- package/dist/memory/conversation-display-recorder.js +28 -3
- package/dist/memory/thread-history.d.ts +34 -7
- package/dist/memory/thread-history.js +151 -42
- package/dist/prompts/briefing.d.ts +57 -3
- package/dist/prompts/briefing.js +129 -2
- package/dist/prompts/planner.js +13 -2
- package/dist/provenance/seam.d.ts +23 -15
- package/dist/provenance/seam.js +7 -5
- package/dist/providers/ai-sdk.js +87 -7
- package/dist/providers/types.d.ts +6 -0
- package/dist/reference-data/catalog.js +141 -2
- package/dist/report-model/authoring-grammar.d.ts +26 -26
- package/dist/sandbox/image-packages.d.ts +26 -4
- package/dist/sandbox/image-packages.js +18 -4
- package/dist/schemas/data-profile-schemas.d.ts +12 -12
- package/dist/schemas/plan-schemas.d.ts +57 -3
- package/dist/schemas/plan-schemas.js +1 -1
- package/dist/schemas/workflow-state.d.ts +113 -0
- package/dist/schemas/workflow-state.js +54 -0
- package/dist/state/artifacts.d.ts +17 -1
- package/dist/state/artifacts.js +21 -3
- package/dist/state/index.d.ts +2 -2
- package/dist/state/index.js +1 -1
- package/dist/state/runs.d.ts +43 -9
- package/dist/state/runs.js +85 -14
- package/dist/state/step-executions.d.ts +9 -0
- package/dist/state/step-executions.js +22 -0
- package/dist/tools/ad-hoc-router.d.ts +2 -0
- package/dist/tools/ad-hoc-router.js +18 -2
- package/dist/tools/capability-data.d.ts +36 -0
- package/dist/tools/capability-data.js +73 -0
- package/dist/tools/execute-analysis.d.ts +37 -9
- package/dist/tools/execute-analysis.js +131 -21
- package/dist/tools/knowledge/check.d.ts +60 -0
- package/dist/tools/knowledge/check.js +92 -0
- package/dist/tools/knowledge/client.d.ts +382 -0
- package/dist/tools/knowledge/client.js +302 -0
- package/dist/tools/knowledge/environment.d.ts +78 -0
- package/dist/tools/knowledge/environment.js +105 -0
- package/dist/tools/knowledge/index.d.ts +49 -0
- package/dist/tools/knowledge/index.js +70 -0
- package/dist/tools/knowledge/local-slots.d.ts +62 -0
- package/dist/tools/knowledge/local-slots.js +177 -0
- package/dist/tools/knowledge/pin.d.ts +14 -0
- package/dist/tools/knowledge/pin.js +17 -0
- package/dist/tools/knowledge/recommend.d.ts +92 -0
- package/dist/tools/knowledge/recommend.js +98 -0
- package/dist/tools/knowledge/situation.d.ts +143 -0
- package/dist/tools/knowledge/situation.js +164 -0
- package/dist/tools/knowledge/skeleton.d.ts +69 -0
- package/dist/tools/knowledge/skeleton.js +202 -0
- package/dist/tools/knowledge/template.d.ts +137 -0
- package/dist/tools/knowledge/template.js +267 -0
- package/dist/tools/research/generate-plan.d.ts +23 -0
- package/dist/tools/research/generate-plan.js +203 -12
- package/dist/tools/research/guarded-address.d.ts +20 -0
- package/dist/tools/research/guarded-address.js +148 -0
- package/dist/tools/research/searxng-engine.d.ts +40 -0
- package/dist/tools/research/searxng-engine.js +120 -0
- package/dist/tools/research/web-fetch.d.ts +124 -0
- package/dist/tools/research/web-fetch.js +627 -0
- package/dist/tools/research/web-search.d.ts +75 -0
- package/dist/tools/research/web-search.js +112 -0
- package/dist/tools/sandbox/list-available-packages.d.ts +7 -4
- package/dist/tools/sandbox/list-available-packages.js +12 -4
- package/dist/tools/sandbox/list-available-refs.d.ts +21 -0
- package/dist/tools/sandbox/list-available-refs.js +60 -3
- package/dist/tools/sandbox/report-blocker.js +5 -1
- package/dist/tools/terminal-text.d.ts +11 -0
- package/dist/tools/terminal-text.js +19 -0
- package/dist/tools/workspace/decision-record.d.ts +81 -0
- package/dist/tools/workspace/decision-record.js +117 -0
- package/dist/tools/workspace/edit-file.d.ts +3 -1
- package/dist/tools/workspace/edit-file.js +20 -2
- package/dist/tools/workspace/execute-command.d.ts +17 -0
- package/dist/tools/workspace/execute-command.js +28 -1
- package/dist/tools/workspace/index.d.ts +1 -0
- package/dist/tools/workspace/index.js +1 -0
- package/dist/tools/workspace/mutator.d.ts +1 -1
- package/dist/workflows/execute-analysis.d.ts +58 -18
- package/dist/workflows/execute-analysis.js +273 -58
- package/dist/workflows/sandbox-step.d.ts +12 -0
- package/package.json +5 -1
|
@@ -59,12 +59,16 @@
|
|
|
59
59
|
* 5. `synthesizeFindings` (single sequential block, no sandbox)
|
|
60
60
|
* 6. `collectAndComplete` (terminal — runs on ALL paths)
|
|
61
61
|
* - determine final status from completed/cancelled/failed counts
|
|
62
|
-
* - update `cortex_runs.status` + `error
|
|
63
|
-
* the
|
|
64
|
-
*
|
|
65
|
-
* died before
|
|
66
|
-
*
|
|
67
|
-
*
|
|
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
|
|
68
72
|
* - emit terminal stream part
|
|
69
73
|
* - WRITES NOTHING to the conversation thread (results are pull-only
|
|
70
74
|
* via `inspectRun`)
|
|
@@ -74,17 +78,18 @@ import { err, ok } from "neverthrow";
|
|
|
74
78
|
import { mkdir } from "node:fs/promises";
|
|
75
79
|
import { join } from "node:path";
|
|
76
80
|
import { forStep } from "../auth/types.js";
|
|
81
|
+
import { RUN_END_EVENT_KEY } from "../execution/run-end-record.js";
|
|
77
82
|
import { createNoopLogger } from "../lib/console-logger.js";
|
|
78
83
|
import { deliverNotice, passGate } from "../lib/hooks.js";
|
|
79
84
|
import { ATTR_INFLEXA_STEP_ID, stableSpan } from "../lib/otel-spans.js";
|
|
80
85
|
import { recordRunCompleted, recordStepCompleted, stepOutcomeOf } from "../lib/metrics.js";
|
|
81
|
-
import { describeDbError } from "../lib/db-result.js";
|
|
86
|
+
import { describeDbError, withTransaction } from "../lib/db-result.js";
|
|
82
87
|
import { unwrapOrThrow } from "../lib/result.js";
|
|
83
88
|
import { countArtifactsByRuns, insertStepExecution, loadDataProfileStatus, queryActiveRun, queryStepArtifactPaths, queryStepsByRun, seedStepExecutions, setRunSynthesisOutcome, suspendAnalysis as suspendAnalysisQuery, sweepPendingStepExecutions, updateRunStatus, updateStepExecution, } from "../state/index.js";
|
|
84
89
|
import { addChatUsage, hasReportedUsage } from "../loop/metrics.js";
|
|
85
90
|
import { flattenDataPart } from "../contracts/chat-frame.js";
|
|
86
91
|
import { SYNTHESIS_AGENT_ID, loadStepSummariesFromDisk } from "../execution/run-synthesis.js";
|
|
87
|
-
import { MAX_UPSTREAM_ARTIFACTS, composeStepBriefing, emptyStepMemory } from "../prompts/briefing.js";
|
|
92
|
+
import { MAX_UPSTREAM_ARTIFACTS, composeStepBriefing, emptyStepMemory, } from "../prompts/briefing.js";
|
|
88
93
|
import { SYNTHESIS_STEP_ID, runDir, runStepDir, stepSubdir, stepWritePrefix, toSandboxPath } from "../workspace/paths.js";
|
|
89
94
|
import { isChatDataPart, synthesisEmission } from "../sandbox/sandbox-step-translate.js";
|
|
90
95
|
import { synthesizeRun } from "../app/synthesize-run.js";
|
|
@@ -183,7 +188,7 @@ function observeRunGuarded(deps, observation) {
|
|
|
183
188
|
* projection/timeout-propagation tests.
|
|
184
189
|
*/
|
|
185
190
|
export function buildChildInput(args) {
|
|
186
|
-
const { input, stepId, level, runId, workflowId, prompt } = args;
|
|
191
|
+
const { input, stepId, level, runId, workflowId, prompt, templateBinding } = args;
|
|
187
192
|
const resources = input.resourcesByStepId[stepId];
|
|
188
193
|
if (!resources) {
|
|
189
194
|
throw new Error(`executeAnalysis: step "${stepId}" missing from resourcesByStepId — every step must declare resources`);
|
|
@@ -205,6 +210,9 @@ export function buildChildInput(args) {
|
|
|
205
210
|
// genuinely declared nothing, and it silently deletes every same-run edge
|
|
206
211
|
// the step was entitled to.
|
|
207
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 } : {}),
|
|
208
216
|
level,
|
|
209
217
|
prompt,
|
|
210
218
|
parentWorkflowId: workflowId,
|
|
@@ -222,13 +230,18 @@ export function buildChildInput(args) {
|
|
|
222
230
|
* rendered any earlier (at plan submission, say) cannot mention them, and the
|
|
223
231
|
* agent is left to rediscover its own upstream.
|
|
224
232
|
*
|
|
225
|
-
*
|
|
226
|
-
*
|
|
227
|
-
*
|
|
228
|
-
*
|
|
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.
|
|
229
241
|
*/
|
|
230
242
|
export async function composeStepSeed(args) {
|
|
231
|
-
|
|
243
|
+
const { briefing, templateBinding } = await gatherStepPackage(args);
|
|
244
|
+
return { prompt: composeStepBriefing(briefing), ...(templateBinding ? { templateBinding } : {}) };
|
|
232
245
|
}
|
|
233
246
|
/**
|
|
234
247
|
* Do every read of one step's package and give the data its seed is rendered
|
|
@@ -242,6 +255,9 @@ export async function composeStepSeed(args) {
|
|
|
242
255
|
* so an edit during a run reaches each later step, and two steps of one run
|
|
243
256
|
* can carry different copies.
|
|
244
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
|
+
*
|
|
245
261
|
* A read failure throws (house rules): a swallowed error would be frozen into
|
|
246
262
|
* the checkpointed seed and served to every replay of this step.
|
|
247
263
|
*/
|
|
@@ -282,12 +298,151 @@ export async function gatherStepPackage(args) {
|
|
|
282
298
|
agentByStepId: input.agentByStepId,
|
|
283
299
|
pool: deps.pool,
|
|
284
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
|
+
}
|
|
285
415
|
return {
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
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
|
+
: {}),
|
|
291
446
|
};
|
|
292
447
|
}
|
|
293
448
|
/**
|
|
@@ -861,17 +1016,21 @@ async function runSchedulerLoop(args) {
|
|
|
861
1016
|
// can be named. Checkpointed so a replay re-dispatches the child
|
|
862
1017
|
// with a byte-identical prompt instead of re-reading the DB/disk.
|
|
863
1018
|
const seedStepName = `compose-step-seed:${stepId}`;
|
|
864
|
-
const
|
|
1019
|
+
const checkpointed = await DBOS.runStep(() => {
|
|
865
1020
|
stableSpan(seedStepName, "compose-step-seed", { [ATTR_INFLEXA_STEP_ID]: stepId });
|
|
866
1021
|
return composeStepSeed({ input, stepId, runId, deps });
|
|
867
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;
|
|
868
1026
|
const baseChildInput = buildChildInput({
|
|
869
1027
|
input,
|
|
870
1028
|
stepId,
|
|
871
1029
|
level: levels.get(stepId) ?? 0,
|
|
872
1030
|
runId,
|
|
873
1031
|
workflowId,
|
|
874
|
-
prompt,
|
|
1032
|
+
prompt: seed.prompt,
|
|
1033
|
+
...(seed.templateBinding ? { templateBinding: seed.templateBinding } : {}),
|
|
875
1034
|
});
|
|
876
1035
|
const childInput = {
|
|
877
1036
|
...baseChildInput,
|
|
@@ -1052,6 +1211,9 @@ async function cancelInFlight(inFlight, cause, canceledByParent) {
|
|
|
1052
1211
|
recordCancelledChild({ cause });
|
|
1053
1212
|
}
|
|
1054
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 };
|
|
1055
1217
|
async function collectAndComplete(args) {
|
|
1056
1218
|
const { input, runId, workflowId, startedAtMs, completed, failed, canceled, suspension, failureReason, forceFailed, deps } = args;
|
|
1057
1219
|
// A forced failure is terminal and never a resumable suspension.
|
|
@@ -1080,18 +1242,9 @@ async function collectAndComplete(args) {
|
|
|
1080
1242
|
: suspended !== null
|
|
1081
1243
|
? "workflow-suspended"
|
|
1082
1244
|
: "workflow-canceled";
|
|
1083
|
-
|
|
1084
|
-
// status write below — and thus before any of the terminal cleanup steps. The CLI
|
|
1085
|
-
// watches the run by POLLING that row (`run.ts` `waitForRunTerminal`) and shuts down
|
|
1086
|
-
// the instant it leaves `running`, flushing provenance and then `process.exit()`ing;
|
|
1087
|
-
// emitting the record only after the status write races that shutdown and can drop
|
|
1088
|
-
// the run's terminal provenance entirely. Emitting first makes the record dirty
|
|
1089
|
-
// (synchronously, via the bus) before the row can be observed terminal, so the CLI's
|
|
1090
|
-
// flush always captures it. Not step-wrapped — a DBOS recovery replay must re-fire it;
|
|
1091
|
-
// `DBOS.now()` is checkpointed, so the span stays replay-stable (see `RunProvenanceEvent`).
|
|
1092
|
-
// The duration measures from the `run_started` read threaded in as `startedAtMs`.
|
|
1245
|
+
const finalError = failureReason ?? (status === "canceled" ? "external_cancel" : null);
|
|
1093
1246
|
const terminalAtMs = await DBOS.now();
|
|
1094
|
-
emitProvenanceGuarded(deps, {
|
|
1247
|
+
const emitRunEnd = () => emitProvenanceGuarded(deps, {
|
|
1095
1248
|
type: "run_completed",
|
|
1096
1249
|
analysisId: input.analysisId,
|
|
1097
1250
|
runId,
|
|
@@ -1099,12 +1252,58 @@ async function collectAndComplete(args) {
|
|
|
1099
1252
|
atMs: terminalAtMs,
|
|
1100
1253
|
durationMs: terminalAtMs - startedAtMs,
|
|
1101
1254
|
}, input.runSession);
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
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
|
|
1108
1307
|
// row already carried its metric; the residue counted here is every settled step
|
|
1109
1308
|
// without one — a child the cancel cut before `mark-canceled` (the cancel check runs
|
|
1110
1309
|
// before a step body), or one that died before `failStep`. The ledger read is kept
|
|
@@ -1121,8 +1320,41 @@ async function collectAndComplete(args) {
|
|
|
1121
1320
|
recordStepCompleted({ agentId: row.agentId, status: "failed" });
|
|
1122
1321
|
}
|
|
1123
1322
|
recordRunCompleted({ workflow: "analysis", status, durationMs: terminalAtMs - startedAtMs });
|
|
1124
|
-
|
|
1125
|
-
|
|
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
|
+
}
|
|
1126
1358
|
// Record the run's synthesis outcome AFTER the status write — a reader that
|
|
1127
1359
|
// sees a terminal status then also sees whether (and why) synthesis ran. A
|
|
1128
1360
|
// null outcome means synthesis never ran; leave the columns NULL ("unknown").
|
|
@@ -1142,24 +1374,7 @@ async function collectAndComplete(args) {
|
|
|
1142
1374
|
if (suspended !== null) {
|
|
1143
1375
|
await ledgerStep(logger, "suspend-analysis", () => suspendAnalysisQuery(deps.pool, input.analysisId));
|
|
1144
1376
|
}
|
|
1145
|
-
|
|
1146
|
-
analysisId: input.analysisId,
|
|
1147
|
-
runId,
|
|
1148
|
-
outcome: chargeOutcome,
|
|
1149
|
-
session: input.runSession,
|
|
1150
|
-
})), { name: "close-running-charge" });
|
|
1151
|
-
// Revoke the run authorization for the terminal run state. Ownership
|
|
1152
|
-
// defaults to true for inputs persisted before the field existed (a
|
|
1153
|
-
// workflow recovered across this deploy) — those were always Cortex-owned.
|
|
1154
|
-
const authorization = {
|
|
1155
|
-
runSession: input.runSession,
|
|
1156
|
-
ownsMandate: input.ownsMandate ?? true, // oss-core-managed-ok
|
|
1157
|
-
};
|
|
1158
|
-
await DBOS.runStep(() => deliverNotice(logger.with({ revokeReason }), "RunAuthorizer.revoke", deps.runAuthorizer.revoke(authorization, revokeReason)), {
|
|
1159
|
-
name: "revoke-run-auth",
|
|
1160
|
-
});
|
|
1161
|
-
// The `run_completed` provenance was already emitted above (before the status write, to
|
|
1162
|
-
// 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
|
|
1163
1378
|
// completed/failed shapes genuinely differ — the provenance record does not.
|
|
1164
1379
|
if (status === "completed" || status === "partial") {
|
|
1165
1380
|
const artifactCount = await DBOS.runStep(async () => await countArtifactsByRuns(deps.pool, input.analysisId, [runId])
|
|
@@ -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. */
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@inflexa-ai/harness",
|
|
3
|
-
"version": "0.45.
|
|
3
|
+
"version": "0.45.1",
|
|
4
4
|
"description": "Host-agnostic bioinformatics agent harness — hand-rolled agent loop, DBOS-durable workflows, sandboxed compute.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"type": "module",
|
|
@@ -61,6 +61,7 @@
|
|
|
61
61
|
"@fontsource/ibm-plex-mono": "5.1.1",
|
|
62
62
|
"@inflexa-ai/tsprov": "0.6.0",
|
|
63
63
|
"@kubernetes/client-node": "^1.4.0",
|
|
64
|
+
"@mozilla/readability": "^0.6.0",
|
|
64
65
|
"@opentelemetry/api": "^1.9.1",
|
|
65
66
|
"@opentelemetry/core": "^2.9.0",
|
|
66
67
|
"@opentelemetry/exporter-metrics-otlp-http": "^0.219.0",
|
|
@@ -72,6 +73,7 @@
|
|
|
72
73
|
"@opentelemetry/semantic-conventions": "^1.43.0",
|
|
73
74
|
"@types/dockerode": "^3.3.47",
|
|
74
75
|
"@types/pg": "^8.20.0",
|
|
76
|
+
"@types/turndown": "^5.0.6",
|
|
75
77
|
"ag-grid-community": "36.1.0",
|
|
76
78
|
"ai": "^7.0.113",
|
|
77
79
|
"cheerio": "^1.2.0",
|
|
@@ -82,6 +84,7 @@
|
|
|
82
84
|
"hyparquet": "^1.28.1",
|
|
83
85
|
"js-tiktoken": "^1.0.21",
|
|
84
86
|
"kysely": "^0.29.6",
|
|
87
|
+
"linkedom": "^0.18.14",
|
|
85
88
|
"mime": "^4.1.0",
|
|
86
89
|
"nanoid": "^5.1.16",
|
|
87
90
|
"neverthrow": "^8.2.0",
|
|
@@ -90,6 +93,7 @@
|
|
|
90
93
|
"pdf-parse": "^2.4.5",
|
|
91
94
|
"pg": "^8.22.0",
|
|
92
95
|
"puppeteer-core": "^23.11.1",
|
|
96
|
+
"turndown": "^7.2.4",
|
|
93
97
|
"zod": "^4.4.3"
|
|
94
98
|
},
|
|
95
99
|
"overrides": {
|