@inflexa-ai/harness 0.4.2 → 0.4.4

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.
@@ -61,6 +61,10 @@ CREATE TABLE IF NOT EXISTS cortex_runs (
61
61
  started_at TEXT NOT NULL,
62
62
  completed_at TEXT,
63
63
  error TEXT,
64
+ -- Run-level synthesis outcome. NULL means "unknown": a legacy row written
65
+ -- before these columns existed, or a run whose synthesis never recorded a result.
66
+ synthesis_status TEXT,
67
+ synthesis_reason TEXT,
64
68
  parts JSONB,
65
69
  mandate_jti TEXT, -- oss-core-managed-ok run-mandate ledger, nullable, OSS leaves null
66
70
  mandate_expires_at TEXT -- oss-core-managed-ok run-mandate ledger, nullable, OSS leaves null
@@ -318,6 +322,8 @@ export async function initCortexState(pool, injected) {
318
322
  "ALTER TABLE cortex_runs ADD COLUMN IF NOT EXISTS parts JSONB",
319
323
  "ALTER TABLE cortex_runs ADD COLUMN IF NOT EXISTS mandate_jti TEXT", // oss-core-managed-ok: run-mandate ledger (nullable; OSS leaves null)
320
324
  "ALTER TABLE cortex_runs ADD COLUMN IF NOT EXISTS mandate_expires_at TEXT", // oss-core-managed-ok: run-mandate ledger (nullable; OSS leaves null)
325
+ "ALTER TABLE cortex_runs ADD COLUMN IF NOT EXISTS synthesis_status TEXT",
326
+ "ALTER TABLE cortex_runs ADD COLUMN IF NOT EXISTS synthesis_reason TEXT",
321
327
  "ALTER TABLE cortex_analysis_state ADD COLUMN IF NOT EXISTS data_profile_result JSONB",
322
328
  "ALTER TABLE cortex_analysis_state ADD COLUMN IF NOT EXISTS billing_context JSONB",
323
329
  // Sandbox reliability telemetry (from sandbox-reliability change on main).
@@ -12,7 +12,7 @@
12
12
  import type { ResultAsync } from "neverthrow";
13
13
  import type { DbError } from "../lib/db-result.js";
14
14
  import type { Querier } from "./db.js";
15
- import type { CortexRunRow } from "./schema.js";
15
+ import type { CortexRunRow, SynthesisStatus } from "./schema.js";
16
16
  export interface InsertRunInput {
17
17
  runId: string;
18
18
  analysisId: string;
@@ -67,3 +67,9 @@ export declare function queryRunsByThread(pool: Querier, analysisId: string, thr
67
67
  * The JWT itself is NOT persisted — it rides in DBOS workflow input.
68
68
  */
69
69
  export declare function setRunMandate(pool: Querier, runId: string, jti: string, expiresAt: string): ResultAsync<void, DbError>;
70
+ /**
71
+ * Record the run-level synthesis outcome on the run ledger. `synthesisReason`
72
+ * carries operator-facing detail for a skipped/failed outcome; it stays null
73
+ * for the "produced" case.
74
+ */
75
+ export declare function setRunSynthesisOutcome(pool: Querier, runId: string, synthesisStatus: SynthesisStatus, synthesisReason?: string | null): ResultAsync<void, DbError>;
@@ -80,7 +80,7 @@ export function queryActiveRun(pool, analysisId, planId) {
80
80
  return tryQuery("runs.queryActiveRun", async () => {
81
81
  const result = await pool.query({
82
82
  text: `SELECT run_id, analysis_id, thread_id, workflow_name,
83
- status, started_at, completed_at, error, parts,
83
+ status, started_at, completed_at, error, synthesis_status, synthesis_reason, parts,
84
84
  mandate_jti, mandate_expires_at, plan_id -- oss-core-managed-ok: run-mandate ledger (nullable; OSS leaves null)
85
85
  FROM cortex_runs
86
86
  WHERE analysis_id = $1
@@ -118,7 +118,7 @@ export function queryRun(pool, runId) {
118
118
  return tryQuery("runs.queryRun", async () => {
119
119
  const result = await pool.query({
120
120
  text: `SELECT run_id, analysis_id, thread_id, workflow_name,
121
- status, started_at, completed_at, error, parts,
121
+ status, started_at, completed_at, error, synthesis_status, synthesis_reason, parts,
122
122
  mandate_jti, mandate_expires_at, plan_id -- oss-core-managed-ok: run-mandate ledger (nullable; OSS leaves null)
123
123
  FROM cortex_runs
124
124
  WHERE run_id = $1`,
@@ -132,7 +132,7 @@ export function queryRunsByAnalysis(pool, analysisId, { limit = 50, offset = 0 }
132
132
  return tryQuery("runs.queryRunsByAnalysis", async () => {
133
133
  const result = await pool.query({
134
134
  text: `SELECT run_id, analysis_id, thread_id, workflow_name,
135
- status, started_at, completed_at, error, parts,
135
+ status, started_at, completed_at, error, synthesis_status, synthesis_reason, parts,
136
136
  mandate_jti, mandate_expires_at, plan_id -- oss-core-managed-ok: run-mandate ledger (nullable; OSS leaves null)
137
137
  FROM cortex_runs
138
138
  WHERE analysis_id = $1
@@ -146,7 +146,7 @@ export function queryRunsByThread(pool, analysisId, threadId, { limit = 50, offs
146
146
  return tryQuery("runs.queryRunsByThread", async () => {
147
147
  const result = await pool.query({
148
148
  text: `SELECT run_id, analysis_id, thread_id, workflow_name,
149
- status, started_at, completed_at, error, parts,
149
+ status, started_at, completed_at, error, synthesis_status, synthesis_reason, parts,
150
150
  mandate_jti, mandate_expires_at, plan_id -- oss-core-managed-ok: run-mandate ledger (nullable; OSS leaves null)
151
151
  FROM cortex_runs
152
152
  WHERE analysis_id = $1 AND thread_id = $2
@@ -174,6 +174,21 @@ export function setRunMandate(pool, runId, jti, expiresAt) {
174
174
  });
175
175
  });
176
176
  }
177
+ /**
178
+ * Record the run-level synthesis outcome on the run ledger. `synthesisReason`
179
+ * carries operator-facing detail for a skipped/failed outcome; it stays null
180
+ * for the "produced" case.
181
+ */
182
+ export function setRunSynthesisOutcome(pool, runId, synthesisStatus, synthesisReason) {
183
+ return tryMutation("runs.setRunSynthesisOutcome", async () => {
184
+ await pool.query({
185
+ text: `UPDATE cortex_runs
186
+ SET synthesis_status = $1, synthesis_reason = $2
187
+ WHERE run_id = $3`,
188
+ values: [synthesisStatus, synthesisReason ?? null, runId],
189
+ });
190
+ });
191
+ }
177
192
  function mapRunRow(row) {
178
193
  // JSONB is parsed by `pg` into native arrays/objects. Treat legacy TEXT
179
194
  // rows (if anything slipped through) as strings and parse them.
@@ -198,6 +213,8 @@ function mapRunRow(row) {
198
213
  startedAt: row.started_at,
199
214
  completedAt: row.completed_at ?? null,
200
215
  error: row.error ?? null,
216
+ synthesisStatus: row.synthesis_status ?? null,
217
+ synthesisReason: row.synthesis_reason ?? null,
201
218
  parts,
202
219
  mandateJti: row.mandate_jti ?? null, // oss-core-managed-ok: run-mandate ledger (nullable; OSS leaves null)
203
220
  mandateExpiresAt: row.mandate_expires_at ?? null, // oss-core-managed-ok: run-mandate ledger (nullable; OSS leaves null)
@@ -64,6 +64,13 @@ export declare const StepExecutionStatus: z.ZodEnum<{
64
64
  blocked: "blocked";
65
65
  }>;
66
66
  export type StepExecutionStatus = z.infer<typeof StepExecutionStatus>;
67
+ export declare const SynthesisStatus: z.ZodEnum<{
68
+ failed: "failed";
69
+ produced: "produced";
70
+ skipped_no_summaries: "skipped_no_summaries";
71
+ skipped_blocker: "skipped_blocker";
72
+ }>;
73
+ export type SynthesisStatus = z.infer<typeof SynthesisStatus>;
67
74
  export declare const CortexRunRowSchema: z.ZodObject<{
68
75
  runId: z.ZodString;
69
76
  analysisId: z.ZodString;
@@ -80,6 +87,13 @@ export declare const CortexRunRowSchema: z.ZodObject<{
80
87
  startedAt: z.ZodString;
81
88
  completedAt: z.ZodNullable<z.ZodString>;
82
89
  error: z.ZodNullable<z.ZodString>;
90
+ synthesisStatus: z.ZodNullable<z.ZodEnum<{
91
+ failed: "failed";
92
+ produced: "produced";
93
+ skipped_no_summaries: "skipped_no_summaries";
94
+ skipped_blocker: "skipped_blocker";
95
+ }>>;
96
+ synthesisReason: z.ZodNullable<z.ZodString>;
83
97
  parts: z.ZodNullable<z.ZodArray<z.ZodAny>>;
84
98
  mandateJti: z.ZodNullable<z.ZodString>;
85
99
  mandateExpiresAt: z.ZodNullable<z.ZodString>;
@@ -35,6 +35,11 @@ export const ArtifactRowSchema = z.object({
35
35
  // ── Run & Step Execution Schemas ─────────────────────────────────────
36
36
  export const RunStatus = z.enum(["running", "completed", "partial", "failed", "canceled", "suspended_insufficient_funds"]);
37
37
  export const StepExecutionStatus = z.enum(["pending", "running", "completed", "failed", "skipped", "canceled", "blocked"]);
38
+ // Run-level synthesis outcome, distinct from RunStatus: a run can reach
39
+ // status="completed" while synthesis produced nothing. "produced" is the only
40
+ // outcome that wrote a synthesis result; the two "skipped_*" variants and
41
+ // "failed" record why none exists.
42
+ export const SynthesisStatus = z.enum(["produced", "skipped_no_summaries", "skipped_blocker", "failed"]);
38
43
  export const CortexRunRowSchema = z.object({
39
44
  runId: z.string(),
40
45
  analysisId: z.string(),
@@ -44,6 +49,10 @@ export const CortexRunRowSchema = z.object({
44
49
  startedAt: z.string(),
45
50
  completedAt: z.string().nullable(),
46
51
  error: z.string().nullable(),
52
+ // NULL synthesisStatus means "unknown" — a legacy row, or a run whose
53
+ // synthesis never recorded an outcome.
54
+ synthesisStatus: SynthesisStatus.nullable(),
55
+ synthesisReason: z.string().nullable(),
47
56
  parts: z.array(z.any()).nullable(),
48
57
  mandateJti: z.string().nullable(), // oss-core-managed-ok: run-mandate ledger (nullable; OSS leaves null)
49
58
  mandateExpiresAt: z.string().nullable(), // oss-core-managed-ok: run-mandate ledger (nullable; OSS leaves null)
@@ -50,6 +50,7 @@ import { previewDir } from "../workspace/paths.js";
50
50
  import { runReportIteration } from "../execution/report-runner.js";
51
51
  import { formatBytes, stageReportAssets } from "./lib/report-preflight.js";
52
52
  import { createNoopLogger } from "../lib/console-logger.js";
53
+ import { hintForZodIssue } from "../lib/zod-issues.js";
53
54
  const REPORT_TOOL_ACCESS_TTL_SECONDS = 3600;
54
55
  const PREVIEW_META_FILE = "preview-meta.json";
55
56
  async function readPreviewMeta(metaPath) {
@@ -435,6 +436,7 @@ export function createReportSubmitTool(deps) {
435
436
  issues: parsed.error.issues.map((i) => ({
436
437
  path: i.path.join(".") || "(root)",
437
438
  message: i.message,
439
+ hint: hintForZodIssue(i, input.report),
438
440
  })),
439
441
  hint: "The `report` brief did not match the schema. Call `plan_report` for the full schema, fix the fields named in `issues`, and resubmit.",
440
442
  });
@@ -41,6 +41,7 @@ import { hydratePlanSteps, PlannerPlanSchema } from "../../schemas/plan-schemas.
41
41
  import { validatePlan } from "../../schemas/validate-plan.js";
42
42
  import { AnalysisPlanSchema } from "../../schemas/workflow-state.js";
43
43
  import { unwrapOrThrow } from "../../lib/result.js";
44
+ import { hintForZodIssue } from "../../lib/zod-issues.js";
44
45
  import { insertPlan, loadDataProfileStatus, loadPlan } from "../../state/index.js";
45
46
  // ── Tool-level config ──────────────────────────────────────────────
46
47
  /** Sub-agent identity for the planner — provenance only. */
@@ -201,11 +202,12 @@ async function persistPlan(plan, ctx, pool) {
201
202
  }
202
203
  }
203
204
  // ── Validation ──────────────────────────────────────────────────────
204
- function zodIssuesToValidationIssues(error, rootPath = "plan") {
205
+ function zodIssuesToValidationIssues(error, input, rootPath = "plan") {
205
206
  return error.issues.map((i) => ({
206
207
  path: [rootPath, ...i.path.map((p) => String(p))].join("."),
207
208
  code: "schema",
208
209
  message: i.message,
210
+ hint: hintForZodIssue(i, input),
209
211
  }));
210
212
  }
211
213
  /**
@@ -215,7 +217,7 @@ function zodIssuesToValidationIssues(error, rootPath = "plan") {
215
217
  function fullyValidate(candidate, resourcePolicy) {
216
218
  const parsed = PlannerPlanSchema.safeParse(candidate);
217
219
  if (!parsed.success) {
218
- return { valid: false, issues: zodIssuesToValidationIssues(parsed.error) };
220
+ return { valid: false, issues: zodIssuesToValidationIssues(parsed.error, candidate) };
219
221
  }
220
222
  // Semantic checks operate on the AnalysisPlan shape — PlannerPlan omits
221
223
  // maxSteps, so inject the execution-time default to satisfy the validator.
@@ -9,8 +9,8 @@
9
9
  * conversation thread on completion. The retired `run-result-writer` push
10
10
  * payload (run status, step summaries, run synthesis, artifact paths) is
11
11
  * surfaced here on demand: the run row + step `summaryPath` references +
12
- * the `synthesisPath` for completed runs let the conversation agent
13
- * `read_file` the artefacts it needs.
12
+ * the `synthesisPath` for runs whose synthesis was produced let the
13
+ * conversation agent `read_file` the artefacts it needs.
14
14
  */
15
15
  import type { Pool } from "pg";
16
16
  import type { CortexRunRow } from "../../state/schema.js";
@@ -39,6 +39,7 @@ type InspectRunOutput = {
39
39
  };
40
40
  declare function formatRun(r: CortexRunRow, verbose: boolean): {
41
41
  error?: string | null | undefined;
42
+ synthesisReason?: string | undefined;
42
43
  runId: string;
43
44
  workflowName: string;
44
45
  status: "running" | "completed" | "failed" | "partial" | "canceled" | "suspended_insufficient_funds";
@@ -46,6 +47,7 @@ declare function formatRun(r: CortexRunRow, verbose: boolean): {
46
47
  completedAt: string | null;
47
48
  planId: string | null;
48
49
  synthesisPath: string | null;
50
+ synthesisStatus: "failed" | "produced" | "skipped_no_summaries" | "skipped_blocker" | null;
49
51
  };
50
52
  export declare function createInspectRunTool(pool: Pool): import("../define-tool.js").Tool<{
51
53
  runId?: string | undefined;
@@ -9,8 +9,8 @@
9
9
  * conversation thread on completion. The retired `run-result-writer` push
10
10
  * payload (run status, step summaries, run synthesis, artifact paths) is
11
11
  * surfaced here on demand: the run row + step `summaryPath` references +
12
- * the `synthesisPath` for completed runs let the conversation agent
13
- * `read_file` the artefacts it needs.
12
+ * the `synthesisPath` for runs whose synthesis was produced let the
13
+ * conversation agent `read_file` the artefacts it needs.
14
14
  */
15
15
  import { ok } from "neverthrow";
16
16
  import { z } from "zod";
@@ -26,7 +26,15 @@ function formatRun(r, verbose) {
26
26
  startedAt: r.startedAt,
27
27
  completedAt: r.completedAt,
28
28
  planId: r.planId,
29
- synthesisPath: r.status === "completed" ? `runs/${r.runId}/synthesis.json` : null,
29
+ // synthesis.json is written only when synthesis actually produced a
30
+ // result; a skipped/failed synthesis leaves no file, so pointing at a
31
+ // path would send the agent to a stale or absent artifact. The recorded
32
+ // outcome — surfaced as synthesisStatus (and synthesisReason when set) —
33
+ // lets a consumer tell "produced" from "skipped/failed/unknown" without
34
+ // reading a file.
35
+ synthesisPath: r.synthesisStatus === "produced" ? `runs/${r.runId}/synthesis.json` : null,
36
+ synthesisStatus: r.synthesisStatus,
37
+ ...(r.synthesisReason !== null ? { synthesisReason: r.synthesisReason } : {}),
30
38
  ...(verbose ? { error: r.error } : {}),
31
39
  };
32
40
  }
@@ -35,7 +43,7 @@ export function createInspectRunTool(pool) {
35
43
  id: "inspect_run",
36
44
  description: "Inspect workflow runs for this analysis. " +
37
45
  "Without a runId: lists all runs. " +
38
- "With a runId: returns step-level details with paths to summary.md (and synthesis.json on completed runs). " +
46
+ "With a runId: returns step-level details with paths to summary.md, plus each run's synthesisStatus and a synthesisPath to synthesis.json only when synthesis was produced. " +
39
47
  "Default response is lightweight — pass includeDiagnostics:true to add error/duration/retry telemetry. " +
40
48
  "Read summary.md or synthesis.json for findings, or use workspace search with type:summary or type:synthesis.",
41
49
  inputSchema: z.object({
@@ -58,6 +58,7 @@ import type { MachineBudget, ResourceSpec } from "../config/resource-limits.js";
58
58
  import type { RunSession } from "../auth/types.js";
59
59
  import type { RunAuthorizer } from "../execution/run-authorizer.js";
60
60
  import type { Logger } from "../lib/logger.js";
61
+ import type { SynthesisStatus } from "../state/index.js";
61
62
  import type { AnalysisStep } from "../schemas/workflow-state.js";
62
63
  import type { ChatProvider, EmbeddingProvider } from "../providers/types.js";
63
64
  import type { BioToolKeys } from "../tools/bio/keys.js";
@@ -297,6 +298,8 @@ export declare function synthesizeFindings(args: {
297
298
  deps: ExecuteAnalysisDeps;
298
299
  }): Promise<{
299
300
  findings: readonly RunFinding[];
301
+ synthesisStatus: Exclude<SynthesisStatus, "failed">;
302
+ synthesisReason: string | null;
300
303
  }>;
301
304
  /**
302
305
  * Register the executeAnalysis parent workflow with DBOS. Caller is
@@ -59,7 +59,7 @@ import { join } from "node:path";
59
59
  import { forStep } from "../auth/types.js";
60
60
  import { createNoopLogger } from "../lib/console-logger.js";
61
61
  import { unwrapOrThrow } from "../lib/result.js";
62
- import { RunDedupCollisionError, countArtifactsForRun, loadDataProfileStatus, queryActiveRun, queryStepArtifactPaths, seedStepExecutions, suspendAnalysis as suspendAnalysisQuery, sweepPendingStepExecutions, updateRunStatus, } from "../state/index.js";
62
+ import { RunDedupCollisionError, countArtifactsForRun, loadDataProfileStatus, queryActiveRun, queryStepArtifactPaths, seedStepExecutions, setRunSynthesisOutcome, suspendAnalysis as suspendAnalysisQuery, sweepPendingStepExecutions, updateRunStatus, } from "../state/index.js";
63
63
  import { isBudgetExceeded } from "../loop/budget-exceeded.js";
64
64
  import { loadStepSummariesFromDisk } from "../execution/run-synthesis.js";
65
65
  import { MAX_UPSTREAM_ARTIFACTS, composeStepBriefing } from "../prompts/briefing.js";
@@ -224,6 +224,7 @@ export function synthesizeFindings(args) {
224
224
  return DBOS.writeStream("events", part);
225
225
  };
226
226
  return synthesizeRun({
227
+ logger: deps.logger,
227
228
  pool: deps.pool,
228
229
  provider: deps.provider,
229
230
  embedding: deps.embedding,
@@ -333,6 +334,9 @@ export async function runExecuteAnalysisBody(input, deps) {
333
334
  // after so the workflow record goes to ERROR.
334
335
  let synthesisError = null;
335
336
  let synthesisFindings = [];
337
+ // Null when synthesis never ran (disabled or no completed steps): the ledger
338
+ // columns stay NULL — "unknown", not a recorded skip.
339
+ let synthesisOutcome = null;
336
340
  const synthesisEnabled = deps.synthesisEnabled ?? true;
337
341
  if (synthesisEnabled && final.completed.size > 0) {
338
342
  try {
@@ -344,9 +348,11 @@ export async function runExecuteAnalysisBody(input, deps) {
344
348
  deps,
345
349
  }), { name: "synthesize-findings" });
346
350
  synthesisFindings = synthOut.findings;
351
+ synthesisOutcome = { status: synthOut.synthesisStatus, reason: synthOut.synthesisReason };
347
352
  }
348
353
  catch (err) {
349
354
  synthesisError = err;
355
+ synthesisOutcome = { status: "failed", reason: err instanceof Error ? err.message : String(err) };
350
356
  logger.error("synthesizeFindings failed — failing the run", { runId, ...logger.errorFields(err) });
351
357
  }
352
358
  }
@@ -369,6 +375,7 @@ export async function runExecuteAnalysisBody(input, deps) {
369
375
  : final.failureReason,
370
376
  forceFailed: synthesisError !== null,
371
377
  findings: synthesisFindings,
378
+ synthesisOutcome,
372
379
  deps,
373
380
  });
374
381
  // Synthesis failure takes priority over the budget-exceeded cascade: a run
@@ -741,6 +748,22 @@ async function collectAndComplete(args) {
741
748
  catch (err) {
742
749
  logger.error("persist-final-status failed", { status, ...logger.errorFields(err) });
743
750
  }
751
+ // Record the run's synthesis outcome AFTER the status write — a reader that
752
+ // sees a terminal status then also sees whether (and why) synthesis ran. A
753
+ // null outcome means synthesis never ran; leave the columns NULL ("unknown").
754
+ // Log-don't-rollback, like the sibling terminal steps: a failed ledger note
755
+ // must not undo an otherwise-complete run.
756
+ const outcome = args.synthesisOutcome;
757
+ if (outcome !== null) {
758
+ try {
759
+ await DBOS.runStep(async () => {
760
+ unwrapOrThrow(await setRunSynthesisOutcome(deps.pool, runId, outcome.status, outcome.reason));
761
+ }, { name: "persist-synthesis-outcome" });
762
+ }
763
+ catch (err) {
764
+ logger.error("persist-synthesis-outcome failed", { synthesisStatus: outcome.status, ...logger.errorFields(err) });
765
+ }
766
+ }
744
767
  // Sweep never-started steps to `skipped` — but ONLY on genuinely-terminal
745
768
  // paths. The gate is the BRANCH, not the written run status: the resumable
746
769
  // 402 pause below also writes "canceled", yet its pending rows must survive
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@inflexa-ai/harness",
3
- "version": "0.4.2",
3
+ "version": "0.4.4",
4
4
  "description": "Host-agnostic bioinformatics agent harness \u2014 hand-rolled agent loop, DBOS-durable workflows, sandboxed compute.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",