@inflexa-ai/harness 0.8.0 → 0.10.0

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 (43) hide show
  1. package/dist/agents/conversation-agent.js +3 -1
  2. package/dist/contracts/message.d.ts +6 -0
  3. package/dist/execution/report-runner.d.ts +6 -1
  4. package/dist/execution/report-runner.js +1 -0
  5. package/dist/execution/run-synthesis.d.ts +7 -0
  6. package/dist/execution/run-synthesis.js +8 -2
  7. package/dist/index.d.ts +1 -1
  8. package/dist/loop/run-agent.d.ts +1 -1
  9. package/dist/loop/run-agent.js +55 -2
  10. package/dist/memory/ai-sdk-message-storage.d.ts +42 -0
  11. package/dist/memory/ai-sdk-message-storage.js +62 -0
  12. package/dist/memory/content-to-cortex.d.ts +8 -1
  13. package/dist/memory/content-to-cortex.js +36 -4
  14. package/dist/memory/thread-history.d.ts +37 -0
  15. package/dist/memory/thread-history.js +74 -7
  16. package/dist/prompts/conversation.d.ts +1 -1
  17. package/dist/prompts/conversation.js +3 -3
  18. package/dist/prompts/report-builder.d.ts +1 -1
  19. package/dist/prompts/report-builder.js +1 -1
  20. package/dist/providers/streaming-chat.d.ts +11 -2
  21. package/dist/providers/streaming-chat.js +31 -6
  22. package/dist/providers/types.d.ts +10 -1
  23. package/dist/schemas/validate-plan.js +10 -5
  24. package/dist/tools/iterate-report.d.ts +0 -1
  25. package/dist/tools/iterate-report.js +125 -11
  26. package/dist/tools/report/mint-preview-url.d.ts +1 -1
  27. package/dist/tools/report/mint-preview-url.js +7 -5
  28. package/dist/tools/report/preview-publisher.d.ts +16 -0
  29. package/dist/tools/report/preview-publisher.js +20 -0
  30. package/dist/tools/report/preview-snapshot.d.ts +9 -1
  31. package/dist/tools/report/preview-snapshot.js +15 -1
  32. package/dist/tools/report/submit-report.d.ts +1 -0
  33. package/dist/tools/report/submit-report.js +32 -11
  34. package/dist/tools/report/version-fs.d.ts +4 -4
  35. package/dist/tools/report/version-fs.js +24 -17
  36. package/dist/tools/research/inspect-run.js +10 -3
  37. package/dist/tools/workspace/read-file.d.ts +5 -0
  38. package/dist/tools/workspace/read-file.js +20 -2
  39. package/dist/workflows/execute-analysis.d.ts +14 -1
  40. package/dist/workflows/execute-analysis.js +105 -8
  41. package/dist/workspace/paths.d.ts +10 -0
  42. package/dist/workspace/paths.js +10 -0
  43. package/package.json +1 -1
@@ -10,6 +10,26 @@
10
10
  * returns not-ok, so `preview_snapshot` short-circuits before touching
11
11
  * Chrome and the report still builds (submit_report is the only gate).
12
12
  */
13
+ /**
14
+ * Renders a failed mint as the line an agent reads back from a preview tool.
15
+ *
16
+ * The failure shape is sparse by design: a realization with no HTTP transport
17
+ * behind it supplies neither a status nor, necessarily, a message. Naming a
18
+ * field the seam left unset would put the literal `status=undefined` in front
19
+ * of the model, so the message carries only what actually arrived. This lives
20
+ * beside the type because its whole job is to honour that type's optionality —
21
+ * every tool holding the seam owes the same message, and one of them owning
22
+ * the wording would make the others depend on a peer for a seam-wide concern.
23
+ */
24
+ export function describeMintFailure(failure) {
25
+ const detail = [];
26
+ if (failure.status !== undefined)
27
+ detail.push(`status=${failure.status}`);
28
+ const message = failure.error.message?.trim();
29
+ if (message)
30
+ detail.push(message);
31
+ return detail.length > 0 ? `preview-access mint failed: ${detail.join(" ")}` : "preview-access mint failed";
32
+ }
13
33
  /**
14
34
  * Local default — no hosted preview surface. Returns not-ok so the preview
15
35
  * tools surface a clear "unavailable" message and short-circuit before any
@@ -10,10 +10,16 @@
10
10
  * Returned screenshot is base64-encoded PNG bytes; the agent can inspect
11
11
  * console errors / failed requests to decide whether to fix the template
12
12
  * or move on to submit_report.
13
+ *
14
+ * A seam that cannot mint is also reported through the injected `Logger`:
15
+ * this tool is the build's only visual verification, so an unavailable seam
16
+ * means that verification silently did not happen — a condition an operator
17
+ * has to be able to see without reading the model transcript.
13
18
  */
14
19
  import { type Tool } from "../define-tool.js";
15
20
  import { type ChromeConfig } from "../../lib/chrome.js";
16
- import type { PreviewPublisher } from "./preview-publisher.js";
21
+ import type { Logger } from "../../lib/logger.js";
22
+ import { type PreviewPublisher } from "./preview-publisher.js";
17
23
  import type { PreviewUrlCell } from "./mint-preview-url.js";
18
24
  export interface PreviewSnapshotToolState {
19
25
  readonly resourceId: string;
@@ -22,5 +28,7 @@ export interface PreviewSnapshotToolState {
22
28
  readonly previews: PreviewPublisher;
23
29
  readonly urlCell: PreviewUrlCell;
24
30
  readonly chrome: ChromeConfig;
31
+ /** Operational logging seam; omitted falls back to no-op. */
32
+ readonly logger?: Logger;
25
33
  }
26
34
  export declare function createPreviewSnapshotTool(state: PreviewSnapshotToolState): Tool;
@@ -10,15 +10,23 @@
10
10
  * Returned screenshot is base64-encoded PNG bytes; the agent can inspect
11
11
  * console errors / failed requests to decide whether to fix the template
12
12
  * or move on to submit_report.
13
+ *
14
+ * A seam that cannot mint is also reported through the injected `Logger`:
15
+ * this tool is the build's only visual verification, so an unavailable seam
16
+ * means that verification silently did not happen — a condition an operator
17
+ * has to be able to see without reading the model transcript.
13
18
  */
14
19
  import { ok } from "neverthrow";
15
20
  import { z } from "zod";
16
21
  import { defineTool } from "../define-tool.js";
17
22
  import { withPage } from "../../lib/chrome.js";
23
+ import { createNoopLogger } from "../../lib/console-logger.js";
24
+ import { describeMintFailure } from "./preview-publisher.js";
18
25
  const NAV_TIMEOUT_MS = 20_000;
19
26
  const READY_TIMEOUT_MS = 8_000;
20
27
  const WAIT_MS_CAP = 30_000;
21
28
  export function createPreviewSnapshotTool(state) {
29
+ const logger = (state.logger ?? createNoopLogger()).named("preview-snapshot").with({ previewId: state.previewId });
22
30
  return defineTool({
23
31
  id: "preview_snapshot",
24
32
  description: "Render the report in a real headless browser and report what you " +
@@ -48,11 +56,17 @@ export function createPreviewSnapshotTool(state) {
48
56
  if (!url) {
49
57
  const result = await state.previews.mintPreviewAccess(state.resourceId, state.previewId);
50
58
  if (!result.ok) {
59
+ const reason = result.error.message?.trim();
60
+ logger.warn("preview access unavailable — visual verification did not run", {
61
+ version: state.currentVersion,
62
+ ...(result.status !== undefined ? { status: result.status } : {}),
63
+ ...(reason ? { reason } : {}),
64
+ });
51
65
  return ok({
52
66
  ok: false,
53
67
  consoleErrors: [],
54
68
  failedRequests: [],
55
- error: `preview-access mint failed: status=${result.status} ${result.error.message ?? ""}`.trim(),
69
+ error: describeMintFailure(result),
56
70
  });
57
71
  }
58
72
  url = `${result.data.baseUrl.replace(/\/?$/, "/")}v${state.currentVersion}/index.html?t=${result.data.token}`;
@@ -3,6 +3,7 @@
3
3
  * `data-report-preview` until this tool writes the success outcome.
4
4
  *
5
5
  * Checks:
6
+ * - report.html.j2 exists at versionDir/ — the report itself
6
7
  * - index.html exists at versionDir/ with size > 0
7
8
  * - no unrendered `{{ … }}` or `{% … %}` left in the output
8
9
  * - all referenced asset paths resolve to files on disk
@@ -3,6 +3,7 @@
3
3
  * `data-report-preview` until this tool writes the success outcome.
4
4
  *
5
5
  * Checks:
6
+ * - report.html.j2 exists at versionDir/ — the report itself
6
7
  * - index.html exists at versionDir/ with size > 0
7
8
  * - no unrendered `{{ … }}` or `{% … %}` left in the output
8
9
  * - all referenced asset paths resolve to files on disk
@@ -23,26 +24,46 @@ const UNRENDERED_JINJA = /\{\{[^{}]*\}\}|\{%-?\s*\w/;
23
24
  export function createSubmitReportTool(state) {
24
25
  return defineTool({
25
26
  id: "submit_report",
26
- description: "Declare the report ready. Validates index.html exists, has no " +
27
- "unrendered Jinja, and that all referenced asset paths resolve. " +
27
+ description: "Declare the report ready. Validates report.html.j2 and index.html " +
28
+ "exist, that the output has no unrendered Jinja, and that all " +
29
+ "referenced asset paths resolve. " +
28
30
  "You MUST call this to finish — the runner ignores any other claim " +
29
31
  "of success. Returns problems[] when checks fail; fix and re-submit. " +
30
- "Use `notes` to surface caveats to the caller (e.g. files skipped due " +
31
- "to the 50MB asset size limit, sections rendered with partial data, " +
32
- "missing inputs that forced a fallback) — these reach the conversation " +
33
- "agent and the user in the iteration result.",
32
+ "Use `notes` to surface caveats to the caller (e.g. sections rendered " +
33
+ "with partial data, missing inputs that forced a fallback) — these " +
34
+ "reach the conversation agent and the user in the iteration result.",
34
35
  inputSchema: z.object({
35
36
  notes: z
36
37
  .array(z.string())
37
38
  .optional()
38
39
  .describe("Caveats / issues to surface to the caller. One short sentence " +
39
- "per item. Examples: 'skipped raw_counts.h5ad (1.2GB > 50MB cap)', " +
40
- "'pathway-enrichment section omitted — no GSEA output found'. " +
40
+ "per item. Examples: 'pathway-enrichment section omitted — no " +
41
+ "GSEA output found', 'cohort table rendered from 8 of 12 " +
42
+ "samples — the rest had no QC row'. " +
41
43
  "Leave empty when the report shipped cleanly."),
42
44
  }),
43
45
  execute: async (input) => {
44
46
  const problems = [];
45
- // 1. index.html exists and is non-empty
47
+ // 1. report.html.j2 exists — the report itself, as opposed to the
48
+ // artifact rendered from it. Nothing downstream re-derives this: a
49
+ // version that is finalized without a template persists as one, and
50
+ // the next iteration copies a template that isn't there, then tells
51
+ // the builder to edit it in place. Checked first because an absent
52
+ // template is the cause the checks below can only report symptoms of.
53
+ const templatePath = join(state.versionDir, "report.html.j2");
54
+ let hasTemplate;
55
+ try {
56
+ hasTemplate = (await stat(templatePath)).isFile();
57
+ }
58
+ catch {
59
+ hasTemplate = false;
60
+ }
61
+ if (!hasTemplate) {
62
+ problems.push("report.html.j2 is missing — a report is authored as report.html.j2 and " +
63
+ "rendered into index.html by build_report; a hand-written index.html is " +
64
+ "not a report. Write the template, then call build_report.");
65
+ }
66
+ // 2. index.html exists and is non-empty
46
67
  const indexPath = join(state.versionDir, "index.html");
47
68
  let html;
48
69
  try {
@@ -57,13 +78,13 @@ export function createSubmitReportTool(state) {
57
78
  problems.push("index.html does not exist — call build_report first");
58
79
  return ok({ ok: false, problems });
59
80
  }
60
- // 2. No unrendered Jinja markers
81
+ // 3. No unrendered Jinja markers
61
82
  if (UNRENDERED_JINJA.test(html)) {
62
83
  const match = html.match(UNRENDERED_JINJA);
63
84
  problems.push(`unrendered Jinja in output (e.g. ${JSON.stringify(match?.[0] ?? "")}); ` +
64
85
  "build_report likely failed silently or report.html.j2 was edited after build");
65
86
  }
66
- // 3. Asset reference resolution
87
+ // 4. Asset reference resolution
67
88
  const refs = extractLocalRefs(html);
68
89
  for (const ref of refs) {
69
90
  const resolved = resolveLocalRef(ref, state.versionDir, state.assetsDir);
@@ -2,10 +2,10 @@
2
2
  * In-process workspace surface for the report-builder agent.
3
3
  *
4
4
  * Tight read/write/mkdir surface scoped to the version directory.
5
- * Agent-supplied paths are resolved against `versionDir`; any path that
6
- * escapes is rejected as `out_of_scope`. Constructed inside the
7
- * iterate-report runner so it always shares the same `versionDir` as the
8
- * rest of the report tools.
5
+ * Agent-supplied paths are relative to `versionDir`; an absolute path, or one
6
+ * that escapes the directory, is rejected as `out_of_scope`. Constructed
7
+ * inside the iterate-report runner so it always shares the same `versionDir`
8
+ * as the rest of the report tools.
9
9
  */
10
10
  import { type Tool } from "../define-tool.js";
11
11
  export interface VersionFsToolsState {
@@ -2,10 +2,10 @@
2
2
  * In-process workspace surface for the report-builder agent.
3
3
  *
4
4
  * Tight read/write/mkdir surface scoped to the version directory.
5
- * Agent-supplied paths are resolved against `versionDir`; any path that
6
- * escapes is rejected as `out_of_scope`. Constructed inside the
7
- * iterate-report runner so it always shares the same `versionDir` as the
8
- * rest of the report tools.
5
+ * Agent-supplied paths are relative to `versionDir`; an absolute path, or one
6
+ * that escapes the directory, is rejected as `out_of_scope`. Constructed
7
+ * inside the iterate-report runner so it always shares the same `versionDir`
8
+ * as the rest of the report tools.
9
9
  */
10
10
  import { mkdir as fsMkdir, readFile as fsReadFile, stat as fsStat, writeFile as fsWriteFile } from "node:fs/promises";
11
11
  import { dirname, resolve as resolvePath, sep } from "node:path";
@@ -19,13 +19,15 @@ function resolveAgentPath(agentPath, versionDir) {
19
19
  if (agentPath.length === 0 || agentPath.includes("\0")) {
20
20
  return { kind: "out_of_scope", reason: "empty or null-byte path" };
21
21
  }
22
- // Strip a leading slash so an agent typing "/index.html" is treated as
23
- // "index.html" relative to versionDir (the version dir is the root).
24
- const stripped = agentPath.replace(/^\/+/, "");
25
- if (!stripped) {
26
- return { kind: "out_of_scope", reason: "path resolves to the version dir itself" };
22
+ // A leading slash is refused rather than trimmed. Trimming it makes the
23
+ // containment check below unreachable for absolute paths: a deep one such
24
+ // as "/previews/abc/v1/report.html.j2" would land a real file several
25
+ // directories below versionDir and report success, and the builder — which
26
+ // has no directory-listing tool — cannot find where its template went.
27
+ if (agentPath.startsWith("/")) {
28
+ return { kind: "out_of_scope", reason: `absolute path rejected, use one relative to the version dir: ${agentPath}` };
27
29
  }
28
- const absolute = resolvePath(versionDir, stripped);
30
+ const absolute = resolvePath(versionDir, agentPath);
29
31
  if (absolute !== versionDir && !absolute.startsWith(versionDir + sep)) {
30
32
  return { kind: "out_of_scope", reason: `path escapes version dir: ${agentPath}` };
31
33
  }
@@ -34,9 +36,10 @@ function resolveAgentPath(agentPath, versionDir) {
34
36
  export function createVersionFsTools(state) {
35
37
  const writeFile = defineTool({
36
38
  id: "write_file",
37
- description: "Write a UTF-8 text file under the report's version directory. Use " +
38
- "relative paths (e.g. `report.html.j2`, `assets/foo.csv`). Returns " +
39
- "`out_of_scope` for any path that escapes the version dir.",
39
+ description: "Write a UTF-8 text file under the report's version directory. Paths " +
40
+ "are relative to it (e.g. `report.html.j2`, `assets/foo.csv`); an " +
41
+ "absolute path, or one that escapes the version dir, returns " +
42
+ "`out_of_scope` and writes nothing.",
40
43
  inputSchema: z.object({
41
44
  path: z.string().min(1),
42
45
  content: z.string(),
@@ -62,8 +65,9 @@ export function createVersionFsTools(state) {
62
65
  id: "edit_file",
63
66
  description: "Surgical edit of an existing UTF-8 file inside the report's version " +
64
67
  "directory: replace the first occurrence of `oldText` with `newText`. " +
65
- "Returns `not_found` if the file doesn't exist, `no_match` if " +
66
- "`oldText` isn't present.",
68
+ "Paths are relative to that directory; an absolute or escaping path " +
69
+ "returns `out_of_scope`. Returns `not_found` if the file doesn't " +
70
+ "exist, `no_match` if `oldText` isn't present.",
67
71
  inputSchema: z.object({
68
72
  path: z.string().min(1),
69
73
  oldText: z.string().min(1),
@@ -102,7 +106,9 @@ export function createVersionFsTools(state) {
102
106
  });
103
107
  const readFile = defineTool({
104
108
  id: "read_file",
105
- description: "Read a UTF-8 file inside the report's version directory. Returns " + "truncated content (capped at 256 KiB) for large files.",
109
+ description: "Read a UTF-8 file inside the report's version directory. Paths are " +
110
+ "relative to it; an absolute or escaping path returns `out_of_scope`. " +
111
+ "Returns truncated content (capped at 256 KiB) for large files.",
106
112
  inputSchema: z.object({
107
113
  path: z.string().min(1),
108
114
  }),
@@ -137,7 +143,8 @@ export function createVersionFsTools(state) {
137
143
  });
138
144
  const mkdir = defineTool({
139
145
  id: "mkdir",
140
- description: "Create a directory (recursive) inside the report's version directory.",
146
+ description: "Create a directory (recursive) inside the report's version directory. " +
147
+ "Paths are relative to it; an absolute or escaping path returns `out_of_scope`.",
141
148
  inputSchema: z.object({
142
149
  path: z.string().min(1),
143
150
  }),
@@ -16,6 +16,7 @@ import { ok } from "neverthrow";
16
16
  import { z } from "zod";
17
17
  import { queryRun, queryRunsByAnalysis, queryStepsByRun } from "../../state/index.js";
18
18
  import { scopeResource } from "../../auth/types.js";
19
+ import { SYNTHESIS_STEP_ID } from "../../workspace/paths.js";
19
20
  import { unwrapOrThrow } from "../../lib/result.js";
20
21
  import { defineTool } from "../define-tool.js";
21
22
  function formatRun(r, verbose) {
@@ -73,9 +74,15 @@ export function createInspectRunTool(pool) {
73
74
  agentId: s.agentId,
74
75
  wave: s.wave,
75
76
  status: s.status,
76
- // Seeded rows exist before a step ever runs; a summary path for one
77
- // would point the agent at a file that cannot exist yet (or ever).
78
- ...(s.status === "pending" || s.status === "skipped" ? {} : { summaryPath: `runs/${input.runId}/${s.stepId}/output/summary.md` }),
77
+ // No per-step `summaryPath` when the file cannot exist: a seeded row
78
+ // (`pending`/`skipped`) never produced output, and the reserved
79
+ // `synthesis` phase is not a sandbox step — it writes the run-level
80
+ // `synthesis.json`, surfaced as the run's `synthesisPath`, not a
81
+ // `{stepId}/output/summary.md`. Emitting the per-step path for it would
82
+ // point the agent at a file that never exists.
83
+ ...(s.status === "pending" || s.status === "skipped" || s.stepId === SYNTHESIS_STEP_ID
84
+ ? {}
85
+ : { summaryPath: `runs/${input.runId}/${s.stepId}/output/summary.md` }),
79
86
  ...(verbose
80
87
  ? {
81
88
  durationMs: s.durationMs,
@@ -30,6 +30,11 @@ type ReadFileOutput = {
30
30
  } | {
31
31
  status: "out_of_scope";
32
32
  path: string;
33
+ } | {
34
+ status: "binary";
35
+ path: string;
36
+ mode: "head" | "tail" | "full";
37
+ totalSize?: number;
33
38
  };
34
39
  export declare function createReadFileTool(fs: WorkspaceFilesystem, workingDir?: string): import("../define-tool.js").Tool<{
35
40
  path: string;
@@ -13,6 +13,18 @@ import { defineTool } from "../define-tool.js";
13
13
  import { unwrapOrThrow } from "../../lib/result.js";
14
14
  /** Default cap — large enough for typical CSV/MD/JSON, small enough to keep tool results out of context-blowing territory. */
15
15
  const DEFAULT_MAX_BYTES = 256 * 1024;
16
+ /**
17
+ * Treat returned content as binary when it contains a NUL byte — `grep`'s
18
+ * `looksBinary` heuristic, applied here to the whole returned window rather than
19
+ * a 1 KiB probe. These exact bytes are what would be UTF-8-decoded into the
20
+ * transcript, and a NUL both renders that decode as lossy garbage (useless to
21
+ * the model) and cannot be persisted into the JSONB message store — Postgres
22
+ * rejects U+0000 with 22P05, failing the whole turn's append. Reporting the
23
+ * read as `binary` keeps a NUL out of both.
24
+ */
25
+ function looksBinary(content) {
26
+ return content.includes(0);
27
+ }
16
28
  const ReadFileInputSchema = z.object({
17
29
  path: z
18
30
  .string()
@@ -45,8 +57,10 @@ export function createReadFileTool(fs, workingDir) {
45
57
  "as UTF-8 text. Output is capped at " +
46
58
  `${DEFAULT_MAX_BYTES} bytes; pass headLines or tailLines to read a ` +
47
59
  "specific window of a large file (typical for bio CSV/TSV/log files). " +
48
- "Oversize reads come back truncated with a marker. Missing paths and " +
49
- "paths outside the analysis tree return a data variant — not an error.",
60
+ "Oversize reads come back truncated with a marker. This tool is for " +
61
+ "text; a binary file (one holding NUL bytes, e.g. a .gz or image) comes " +
62
+ "back as a `binary` data variant rather than decoded garbage. Missing " +
63
+ "paths and paths outside the analysis tree return a data variant — not an error.",
50
64
  inputSchema: ReadFileInputSchema,
51
65
  execute: async ({ path, headLines, tailLines }, ctx) => {
52
66
  if (headLines !== undefined && tailLines !== undefined) {
@@ -67,6 +81,8 @@ export function createReadFileTool(fs, workingDir) {
67
81
  const mode = headLines !== undefined ? "head" : tailLines !== undefined ? "tail" : "full";
68
82
  switch (result.kind) {
69
83
  case "ok":
84
+ if (looksBinary(result.content))
85
+ return ok({ status: "binary", path, mode });
70
86
  return ok({
71
87
  status: "ok",
72
88
  path,
@@ -74,6 +90,8 @@ export function createReadFileTool(fs, workingDir) {
74
90
  content: result.content.toString("utf8"),
75
91
  });
76
92
  case "truncated":
93
+ if (looksBinary(result.content))
94
+ return ok({ status: "binary", path, mode, totalSize: result.totalSize });
77
95
  return ok({
78
96
  status: "truncated",
79
97
  path,
@@ -63,7 +63,7 @@ import type { MachineBudget, ResourceSpec } from "../config/resource-limits.js";
63
63
  import type { RunSession } from "../auth/types.js";
64
64
  import type { RunAuthorizer } from "../execution/run-authorizer.js";
65
65
  import type { Logger } from "../lib/logger.js";
66
- import type { SynthesisStatus } from "../state/index.js";
66
+ import type { SynthesisStatus, UpdateStepExecutionInput } from "../state/index.js";
67
67
  import type { AnalysisStep } from "../schemas/workflow-state.js";
68
68
  import type { ChatProvider, EmbeddingProvider } from "../providers/types.js";
69
69
  import type { BioToolKeys } from "../tools/bio/keys.js";
@@ -288,6 +288,19 @@ export declare function composeStepSeed(args: {
288
288
  runId: string;
289
289
  deps: ExecuteAnalysisDeps;
290
290
  }): Promise<string>;
291
+ /**
292
+ * Project a classified synthesis outcome onto the run-phase `synthesis` ledger
293
+ * row's terminal update (see the run-synthesis-outcome spec). The blocker keeps
294
+ * its reason in `blockedReason` — an honest agent-declared blocker, not an
295
+ * error — and a failure carries the same reason string
296
+ * `persist-synthesis-outcome` writes to `cortex_runs.synthesis_reason`, so the
297
+ * two ledgers cannot disagree about why synthesis died. Exported for the
298
+ * projection tests (the `buildChildInput` pattern).
299
+ */
300
+ export declare function synthesisRowUpdate(outcome: {
301
+ status: SynthesisStatus;
302
+ reason: string | null;
303
+ }, durationMs: number): UpdateStepExecutionInput;
291
304
  /**
292
305
  * Run-level literature-grounded synthesis (no sandbox). Returns the
293
306
  * quick-display findings for the run-completed card; empty when synthesis is
@@ -58,17 +58,17 @@
58
58
  * - WRITES NOTHING to the conversation thread (results are pull-only
59
59
  * via `inspectRun`)
60
60
  */
61
- import { DBOS } from "@dbos-inc/dbos-sdk";
61
+ import { DBOS, Error as DBOSErrors } from "@dbos-inc/dbos-sdk";
62
62
  import { mkdir } from "node:fs/promises";
63
63
  import { join } from "node:path";
64
64
  import { forStep } from "../auth/types.js";
65
65
  import { createNoopLogger } from "../lib/console-logger.js";
66
66
  import { unwrapOrThrow } from "../lib/result.js";
67
- import { RunDedupCollisionError, countArtifactsForRun, loadDataProfileStatus, queryActiveRun, queryStepArtifactPaths, seedStepExecutions, setRunSynthesisOutcome, suspendAnalysis as suspendAnalysisQuery, sweepPendingStepExecutions, updateRunStatus, } from "../state/index.js";
67
+ import { RunDedupCollisionError, countArtifactsForRun, insertStepExecution, loadDataProfileStatus, queryActiveRun, queryStepArtifactPaths, seedStepExecutions, setRunSynthesisOutcome, suspendAnalysis as suspendAnalysisQuery, sweepPendingStepExecutions, updateRunStatus, updateStepExecution, } from "../state/index.js";
68
68
  import { isBudgetExceeded } from "../loop/budget-exceeded.js";
69
- import { loadStepSummariesFromDisk } from "../execution/run-synthesis.js";
69
+ import { SYNTHESIS_AGENT_ID, loadStepSummariesFromDisk } from "../execution/run-synthesis.js";
70
70
  import { MAX_UPSTREAM_ARTIFACTS, composeStepBriefing } from "../prompts/briefing.js";
71
- import { runDir, runStepDir, stepSubdir, stepWritePrefix, toSandboxPath } from "../workspace/paths.js";
71
+ import { SYNTHESIS_STEP_ID, runDir, runStepDir, stepSubdir, stepWritePrefix, toSandboxPath } from "../workspace/paths.js";
72
72
  import { isChatDataPart } from "../sandbox/sandbox-step-translate.js";
73
73
  import { synthesizeRun } from "../app/synthesize-run.js";
74
74
  import { computeTopologicalLevels, scheduleReady, validatePlanDag } from "./execute-analysis-scheduler.js";
@@ -222,6 +222,31 @@ async function collectUpstreamHandoffs(args) {
222
222
  }
223
223
  return handoffs;
224
224
  }
225
+ /**
226
+ * Project a classified synthesis outcome onto the run-phase `synthesis` ledger
227
+ * row's terminal update (see the run-synthesis-outcome spec). The blocker keeps
228
+ * its reason in `blockedReason` — an honest agent-declared blocker, not an
229
+ * error — and a failure carries the same reason string
230
+ * `persist-synthesis-outcome` writes to `cortex_runs.synthesis_reason`, so the
231
+ * two ledgers cannot disagree about why synthesis died. Exported for the
232
+ * projection tests (the `buildChildInput` pattern).
233
+ */
234
+ export function synthesisRowUpdate(outcome, durationMs) {
235
+ switch (outcome.status) {
236
+ case "produced":
237
+ return { status: "completed", durationMs };
238
+ case "skipped_no_summaries":
239
+ return { status: "skipped", durationMs };
240
+ case "skipped_blocker":
241
+ return { status: "blocked", durationMs, blockedReason: outcome.reason };
242
+ case "failed":
243
+ return { status: "failed", durationMs, error: outcome.reason };
244
+ default: {
245
+ const _exhaustive = outcome.status;
246
+ throw new Error(`unhandled synthesis status: ${String(_exhaustive)}`);
247
+ }
248
+ }
249
+ }
225
250
  /**
226
251
  * Run-level literature-grounded synthesis (no sandbox). Returns the
227
252
  * quick-display findings for the run-completed card; empty when synthesis is
@@ -309,14 +334,32 @@ export async function runExecuteAnalysisBody(input, deps) {
309
334
  // The seed's DO NOTHING conflict action makes this replay-safe; the agent
310
335
  // fallback mirrors `buildChildInput` so seed and mark-running agree on the
311
336
  // row a step's child later upserts.
337
+ //
338
+ // The run-phase `synthesis` row is seeded alongside the DAG — gated on
339
+ // enablement ONLY, because the other half of the run gate for actually
340
+ // executing synthesis (`final.completed.size > 0`) is unknowable until the
341
+ // scheduler returns. A seeded row whose gate later fails simply stays
342
+ // `pending` and the terminal sweep finalizes it to `skipped` — honest
343
+ // ("never ran, never will"), like any never-dispatched DAG step. Its wave
344
+ // sits after every DAG level so ledger-ordered readers render it last.
345
+ const synthesisEnabled = deps.synthesisEnabled ?? true;
346
+ const synthesisWave = Math.max(-1, ...levels.values()) + 1;
312
347
  await DBOS.runStep(async () => {
313
- unwrapOrThrow(await seedStepExecutions(deps.pool, input.steps.map((step) => ({
348
+ const dagRows = input.steps.map((step) => ({
314
349
  runId,
315
350
  stepId: step.id,
316
351
  analysisId: input.analysisId,
317
352
  wave: levels.get(step.id) ?? 0,
318
353
  agentId: input.agentByStepId[step.id] ?? "scientific-executor",
319
- }))));
354
+ }));
355
+ const synthesisRow = {
356
+ runId,
357
+ stepId: SYNTHESIS_STEP_ID,
358
+ analysisId: input.analysisId,
359
+ wave: synthesisWave,
360
+ agentId: SYNTHESIS_AGENT_ID,
361
+ };
362
+ unwrapOrThrow(await seedStepExecutions(deps.pool, synthesisEnabled ? [...dagRows, synthesisRow] : dagRows));
320
363
  }, { name: "seed-step-executions" });
321
364
  // One checkpointed clock read stamps the run's start; the terminal read in
322
365
  // `collectAndComplete` subtracts it for the true run span. Checkpointed →
@@ -352,10 +395,37 @@ export async function runExecuteAnalysisBody(input, deps) {
352
395
  let synthesisError = null;
353
396
  let synthesisFindings = [];
354
397
  // Null when synthesis never ran (disabled or no completed steps): the ledger
355
- // columns stay NULL — "unknown", not a recorded skip.
398
+ // columns stay NULL — "unknown", not a recorded skip — and the seeded
399
+ // `synthesis` row (if any) stays `pending` for the terminal sweep.
356
400
  let synthesisOutcome = null;
357
- const synthesisEnabled = deps.synthesisEnabled ?? true;
358
401
  if (synthesisEnabled && final.completed.size > 0) {
402
+ // Bracketing clock reads for the row's duration_ms — checkpointed, so a
403
+ // recovered replay reuses the original instants (same rule as `startedAtMs`).
404
+ const synthStartedAtMs = await DBOS.now();
405
+ // Mark the seeded `synthesis` row running. The same upsert the DAG children
406
+ // use: it stamps started_at, and it CREATES the row outright when the seed
407
+ // never carried it (synthesisEnabled flipped true across a recovery
408
+ // redeploy) — the mark self-heals rather than trusting the seed's snapshot
409
+ // of the config. Log-don't-fail: a progress row must never fail an
410
+ // otherwise-healthy run. Cancellation still re-propagates — it is not a
411
+ // write failure, and swallowing it here would only defer it one operation.
412
+ try {
413
+ await DBOS.runStep(async () => {
414
+ unwrapOrThrow(await insertStepExecution(deps.pool, {
415
+ runId,
416
+ stepId: SYNTHESIS_STEP_ID,
417
+ analysisId: input.analysisId,
418
+ wave: synthesisWave,
419
+ agentId: SYNTHESIS_AGENT_ID,
420
+ childWorkflowId: null,
421
+ }));
422
+ }, { name: "mark-synthesis-running" });
423
+ }
424
+ catch (err) {
425
+ if (err instanceof DBOSErrors.DBOSWorkflowCancelledError)
426
+ throw err;
427
+ logger.error("mark-synthesis-running failed", { runId, ...logger.errorFields(err) });
428
+ }
359
429
  try {
360
430
  const synthOut = await DBOS.runStep(() => synthesizeFindings({
361
431
  analysisId: input.analysisId,
@@ -368,10 +438,37 @@ export async function runExecuteAnalysisBody(input, deps) {
368
438
  synthesisOutcome = { status: synthOut.synthesisStatus, reason: synthOut.synthesisReason };
369
439
  }
370
440
  catch (err) {
441
+ // Cancellation is not a synthesis outcome: re-propagate unchanged (the
442
+ // sandbox-step rule), so a cancelled workflow is never recorded as a
443
+ // synthesis failure — neither on the row nor in cortex_runs. The row is
444
+ // deliberately left `running`: `collectAndComplete` never executes on
445
+ // this path either, so the run row is equally non-terminal and the pair
446
+ // stays consistent — the pre-existing cancelled-mid-flight wedge shape
447
+ // `inflexa run` already detects via its DBOS-status cross-check.
448
+ if (err instanceof DBOSErrors.DBOSWorkflowCancelledError)
449
+ throw err;
371
450
  synthesisError = err;
372
451
  synthesisOutcome = { status: "failed", reason: err instanceof Error ? err.message : String(err) };
373
452
  logger.error("synthesizeFindings failed — failing the run", { runId, ...logger.errorFields(err) });
374
453
  }
454
+ // Terminal stamp for the `synthesis` row — deliberately BEFORE
455
+ // `collectAndComplete` writes the run row's terminal status, so no reader
456
+ // can observe a terminal run beside a still-`running` synthesis row. Both
457
+ // branches above set `synthesisOutcome` (or threw), so it is non-null here
458
+ // and the compiler narrows it — no guard needed. Log-don't-fail, like the
459
+ // sibling finalisation writes.
460
+ const rowOutcome = synthesisOutcome;
461
+ try {
462
+ const synthEndedAtMs = await DBOS.now();
463
+ await DBOS.runStep(async () => {
464
+ unwrapOrThrow(await updateStepExecution(deps.pool, runId, SYNTHESIS_STEP_ID, synthesisRowUpdate(rowOutcome, synthEndedAtMs - synthStartedAtMs)));
465
+ }, { name: "persist-synthesis-step" });
466
+ }
467
+ catch (err) {
468
+ if (err instanceof DBOSErrors.DBOSWorkflowCancelledError)
469
+ throw err;
470
+ logger.error("persist-synthesis-step failed", { runId, ...logger.errorFields(err) });
471
+ }
375
472
  }
376
473
  // (6) collectAndComplete — terminal block. Runs on EVERY path. When synthesis
377
474
  // failed, force a failed terminal status so the charge/run-authorization close correctly
@@ -181,5 +181,15 @@ export declare function stepSubdir(stepBase: string, type: StepSubdirType): stri
181
181
  export declare function reportDir(reportId: string): string;
182
182
  /** All standard subdirectory types for a step. */
183
183
  export declare const STEP_SUBDIRS: readonly StepSubdirType[];
184
+ /**
185
+ * Reserved id of the run-phase synthesis row in `cortex_step_executions` — the
186
+ * ledger row `executeAnalysis` writes for run-level synthesis so progress
187
+ * readers see the phase. Reserved here beside {@link STEP_SUBDIRS} because the
188
+ * reservation is a layout fact as much as a ledger one: a plan step with this
189
+ * id would collide with the row's primary key AND put its step directory
190
+ * `runs/{runId}/synthesis/` beside the run-level `runs/{runId}/synthesis.json`.
191
+ * Plan validation rejects it (see validate-plan).
192
+ */
193
+ export declare const SYNTHESIS_STEP_ID = "synthesis";
184
194
  /** Get all subdirectory paths for a step. */
185
195
  export declare function allStepSubdirs(stepBase: string): string[];
@@ -213,6 +213,16 @@ export function reportDir(reportId) {
213
213
  }
214
214
  /** All standard subdirectory types for a step. */
215
215
  export const STEP_SUBDIRS = ["scripts", "output", "figures", "logs", "notebooks"];
216
+ /**
217
+ * Reserved id of the run-phase synthesis row in `cortex_step_executions` — the
218
+ * ledger row `executeAnalysis` writes for run-level synthesis so progress
219
+ * readers see the phase. Reserved here beside {@link STEP_SUBDIRS} because the
220
+ * reservation is a layout fact as much as a ledger one: a plan step with this
221
+ * id would collide with the row's primary key AND put its step directory
222
+ * `runs/{runId}/synthesis/` beside the run-level `runs/{runId}/synthesis.json`.
223
+ * Plan validation rejects it (see validate-plan).
224
+ */
225
+ export const SYNTHESIS_STEP_ID = "synthesis";
216
226
  /** Get all subdirectory paths for a step. */
217
227
  export function allStepSubdirs(stepBase) {
218
228
  return STEP_SUBDIRS.map((type) => stepSubdir(stepBase, type));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@inflexa-ai/harness",
3
- "version": "0.8.0",
3
+ "version": "0.10.0",
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",