@inflexa-ai/harness 0.31.1 → 0.33.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 (96) hide show
  1. package/dist/agents/conversation-agent.d.ts +15 -0
  2. package/dist/agents/conversation-agent.js +16 -6
  3. package/dist/agents/report-session-agent.d.ts +9 -0
  4. package/dist/agents/report-session-agent.js +10 -2
  5. package/dist/agents/sandbox/shared.d.ts +13 -1
  6. package/dist/agents/sandbox/shared.js +22 -5
  7. package/dist/app/chat-turn.d.ts +11 -0
  8. package/dist/app/chat-turn.js +17 -1
  9. package/dist/app/spawn-report-session.d.ts +12 -0
  10. package/dist/app/spawn-report-session.js +21 -1
  11. package/dist/config/environment-stores.d.ts +53 -5
  12. package/dist/contracts/report-blocks.d.ts +8 -8
  13. package/dist/contracts/schemas/chat-parts.d.ts +6 -6
  14. package/dist/index.d.ts +8 -1
  15. package/dist/index.js +11 -0
  16. package/dist/prompts/briefing.d.ts +1 -1
  17. package/dist/prompts/briefing.js +4 -0
  18. package/dist/prompts/planner.js +13 -0
  19. package/dist/prompts/sandbox/multi-omics-integration-agent.d.ts +1 -1
  20. package/dist/prompts/sandbox/multi-omics-integration-agent.js +5 -5
  21. package/dist/prompts/sandbox/network-agent.d.ts +1 -1
  22. package/dist/prompts/sandbox/network-agent.js +4 -3
  23. package/dist/prompts/sandbox-standards.d.ts +20 -0
  24. package/dist/prompts/sandbox-standards.js +65 -0
  25. package/dist/provenance/seam.d.ts +203 -0
  26. package/dist/provenance/seam.js +71 -0
  27. package/dist/reference-data/catalog.js +11 -9
  28. package/dist/report-model/authoring-grammar.d.ts +2 -2
  29. package/dist/report-render/assets.d.ts +10 -0
  30. package/dist/report-render/assets.js +14 -0
  31. package/dist/report-render/design.js +256 -0
  32. package/dist/report-render/page.d.ts +75 -0
  33. package/dist/report-render/page.js +793 -1
  34. package/dist/report-render/provenance-data.d.ts +42 -0
  35. package/dist/report-render/provenance-data.js +62 -0
  36. package/dist/report-render/render.d.ts +20 -1
  37. package/dist/report-render/render.js +23 -10
  38. package/dist/report-render/types.d.ts +3 -0
  39. package/dist/report-render/views/chart-view.d.ts +5 -1
  40. package/dist/report-render/views/chart-view.js +6 -2
  41. package/dist/report-render/views/lineage.d.ts +93 -0
  42. package/dist/report-render/views/lineage.js +86 -0
  43. package/dist/report-render/views/page-view.d.ts +22 -1
  44. package/dist/report-render/views/page-view.js +12 -3
  45. package/dist/report-render/views/prose.d.ts +7 -1
  46. package/dist/report-render/views/prose.js +10 -6
  47. package/dist/report-render/views/references-view.d.ts +17 -1
  48. package/dist/report-render/views/references-view.js +12 -4
  49. package/dist/report-render/views/values.d.ts +17 -4
  50. package/dist/report-render/views/values.js +21 -8
  51. package/dist/runtime/assemble.d.ts +22 -0
  52. package/dist/runtime/assemble.js +4 -0
  53. package/dist/sandbox/create-sandbox.d.ts +20 -4
  54. package/dist/sandbox/create-sandbox.js +4 -0
  55. package/dist/sandbox/docker-client.d.ts +16 -6
  56. package/dist/sandbox/docker-client.js +47 -31
  57. package/dist/sandbox/farm.d.ts +95 -0
  58. package/dist/sandbox/farm.js +127 -0
  59. package/dist/sandbox/k8s-client.d.ts +24 -10
  60. package/dist/sandbox/k8s-client.js +85 -122
  61. package/dist/sandbox/mount-plan.d.ts +23 -1
  62. package/dist/sandbox/mount-plan.js +44 -7
  63. package/dist/sandbox/sandbox-error.d.ts +12 -0
  64. package/dist/sandbox/sandbox-error.js +2 -0
  65. package/dist/sandbox/types.d.ts +118 -0
  66. package/dist/schemas/artifact-manifest.d.ts +4 -4
  67. package/dist/schemas/plan-schemas.d.ts +3 -0
  68. package/dist/schemas/plan-schemas.js +1 -1
  69. package/dist/schemas/validate-plan.js +20 -1
  70. package/dist/schemas/workflow-state.d.ts +4 -0
  71. package/dist/schemas/workflow-state.js +10 -0
  72. package/dist/tasks/data-profile.d.ts +15 -0
  73. package/dist/tasks/data-profile.js +36 -26
  74. package/dist/tools/display/show-user.d.ts +1 -1
  75. package/dist/tools/execute-analysis.d.ts +18 -0
  76. package/dist/tools/execute-analysis.js +87 -0
  77. package/dist/tools/report-authoring/authoring-tools.d.ts +16 -1
  78. package/dist/tools/report-authoring/authoring-tools.js +58 -16
  79. package/dist/tools/report-session/derive-table.d.ts +3 -0
  80. package/dist/tools/report-session/derive-table.js +15 -0
  81. package/dist/tools/report-session/preview-report.d.ts +13 -4
  82. package/dist/tools/report-session/preview-report.js +23 -6
  83. package/dist/tools/report-session/record-version.d.ts +3 -0
  84. package/dist/tools/report-session/record-version.js +7 -3
  85. package/dist/tools/research/generate-plan.js +12 -2
  86. package/dist/tools/sandbox/index.d.ts +1 -0
  87. package/dist/tools/sandbox/index.js +1 -0
  88. package/dist/tools/sandbox/link-packages.d.ts +27 -0
  89. package/dist/tools/sandbox/link-packages.js +52 -0
  90. package/dist/tools/sandbox/list-available-packages.d.ts +50 -30
  91. package/dist/tools/sandbox/list-available-packages.js +131 -40
  92. package/dist/tools/start-report-session.d.ts +7 -0
  93. package/dist/tools/start-report-session.js +1 -0
  94. package/dist/workflows/execute-analysis.d.ts +15 -69
  95. package/dist/workflows/execute-analysis.js +5 -4
  96. package/package.json +2 -1
@@ -1,26 +1,25 @@
1
1
  /**
2
- * listAvailablePackages — query the R/Python/CLI/Node packages available in the
3
- * sandbox.
2
+ * listAvailablePackages — query the R/Python/CLI/Node packages of the
3
+ * environment, from the inventory source the embedder binds.
4
4
  *
5
- * The data source is the store's `packages.txt`, assembled by the library-store
6
- * build (see the lib-store-build spec) and read from wherever the HOST can see
7
- * it — the container path when the host mounts the same store, an injected path
8
- * when it does not. Its shape is fixed by the
9
- * producers (`scripts/lib-store-common.sh`, `images/sandbox-python/inflexa-libs-refresh`):
10
- * two `#` advisory lines, then one `## <Section>` heading per language track
11
- * followed by that track's packages as a single comma-separated line.
5
+ * Two sources exist, one per vantage. A SANDBOX agent reads the
6
+ * `inflexa.lock` of the mounted farm — what a step can import right now —
7
+ * from wherever the HOST can see it: the farm container path when the host
8
+ * mounts the same farm, an injected path when it does not. A CONVERSATION or
9
+ * planning surface reads the pool-scope inventory (`readPoolInventory`) —
10
+ * everything the store holds, whether or not a farm links it yet — because
11
+ * the ask flow marks the packages the POOL does not hold, and the farm of a
12
+ * new analysis is empty. The baked image inventory fragment
13
+ * (`image-packages.txt`, at `/opt/inflexa` in the image) merges into either
14
+ * report when the host can read one. The fragment keeps the section format:
12
15
  *
13
- * # Available packages in the sandbox environment.
14
- * # Do NOT attempt to install packages — ...
16
+ * ## System tools (CLI)
17
+ * samtools, bcftools, ...
15
18
  *
16
- * ## R (CRAN)
17
- * Seurat, dplyr, ggplot2, ...
18
- *
19
- * ## Python (pip)
20
- * anndata, scanpy, ...
21
- *
22
- * The file carries **names only — there are no version strings in it**, so this
23
- * tool reports presence and language track and cannot report a version.
19
+ * Each entry renders as `name==version` where the source pins a version, and
20
+ * a targeted `names` lookup also carries the store directory and the full
21
+ * content hash where the source records them. A full listing carries no
22
+ * hashes — a thousand rows of sha256 would bury the signal.
24
23
  */
25
24
  import { readFile } from "node:fs/promises";
26
25
  import { ok } from "neverthrow";
@@ -28,12 +27,45 @@ import { z } from "zod";
28
27
  import { defineTool } from "../define-tool.js";
29
28
  import { capCodePoints, DETAIL_NEEDLE_MAX_LENGTH } from "../../loop/tool-detail.js";
30
29
  import { LIBS_CONTAINER_PATH } from "../../sandbox/mount-plan.js";
30
+ import { readFarmLockFile } from "../../sandbox/farm.js";
31
31
  /**
32
- * Where the list lives when the host mounts the library store at the same path
33
- * the sandbox does. Hosts that do not — the store is baked into the image and
34
- * never bind-mounted — inject their own path instead.
32
+ * Where the lock lives when the host mounts the farm at the same path the
33
+ * sandbox does. Both farm container paths are tried, because the path keys on
34
+ * the declared toolchain (`/mnt/libs/farm` under `"image"`, `/mnt/libs/current`
35
+ * under `"store"`), and the tool does not carry that declaration. A host that
36
+ * reads the farm somewhere else injects its own path instead.
35
37
  */
36
- const DEFAULT_PACKAGES_FILE = `${LIBS_CONTAINER_PATH}/current/packages.txt`;
38
+ const DEFAULT_FARM_LOCK_FILES = [`${LIBS_CONTAINER_PATH}/farm/inflexa.lock`, `${LIBS_CONTAINER_PATH}/current/inflexa.lock`];
39
+ /** Where the baked image inventory fragment lives inside the image. */
40
+ const DEFAULT_IMAGE_PACKAGES_FILE = "/opt/inflexa/image-packages.txt";
41
+ /** The section title of each known lock track. An unknown track titles itself. */
42
+ const TRACK_TITLES = {
43
+ python: "Python (pip)",
44
+ cran: "R (CRAN)",
45
+ bioconductor: "R (Bioconductor)",
46
+ github: "R (GitHub)",
47
+ node: "Node (npm)",
48
+ };
49
+ /**
50
+ * Group the lock packages into sections, one per track, in a stable order:
51
+ * the known tracks first, then an unknown track at its first occurrence.
52
+ * Each entry keeps the lock's version, store directory, and hash, so the
53
+ * targeted `names` path can report the exact store identity.
54
+ */
55
+ export function lockSections(lock) {
56
+ const byTrack = new Map();
57
+ for (const track of Object.keys(TRACK_TITLES))
58
+ byTrack.set(track, []);
59
+ for (const pkg of lock.packages) {
60
+ const entry = { name: pkg.name, version: pkg.version, storeDir: pkg.store_dir, hash: pkg.hash };
61
+ const open = byTrack.get(pkg.track);
62
+ if (open)
63
+ open.push(entry);
64
+ else
65
+ byTrack.set(pkg.track, [entry]);
66
+ }
67
+ return [...byTrack.entries()].filter(([, packages]) => packages.length > 0).map(([track, packages]) => ({ title: TRACK_TITLES[track] ?? track, packages }));
68
+ }
37
69
  /**
38
70
  * Default cap on a listing — high enough that the real store is never truncated.
39
71
  * The shipped catalog is ~270 packages (~3 KB, well under a thousand tokens), so
@@ -49,11 +81,18 @@ const DEFAULT_LIMIT = 2_000;
49
81
  * check, and an agent told to "assume numpy is available" will import it and
50
82
  * fail at runtime instead of probing first.
51
83
  */
52
- const UNAVAILABLE_NOTE = "Package list not available — the library store's inventory could not be read, so what is installed is UNKNOWN. " +
84
+ const UNAVAILABLE_NOTE = "Package list not available — the inventory of the farm could not be read, so what is installed is UNKNOWN. " +
53
85
  "Do not assume any package is present, and do not infer one from the analysis you were asked to run. " +
54
86
  "Probe each package you intend to use before relying on it (`python3 -c 'import <pkg>'`, " +
55
87
  'R `requireNamespace("<pkg>", quietly = TRUE)`) and degrade gracefully when it is absent. ' +
56
88
  "Nothing can be installed at runtime.";
89
+ /**
90
+ * The pool-scope twin of {@link UNAVAILABLE_NOTE}. It names no runtime probe,
91
+ * because a conversation surface has no runtime to probe, and an unreadable
92
+ * pool must read as UNKNOWN rather than as empty.
93
+ */
94
+ const POOL_UNAVAILABLE_NOTE = "Package list not available — the package pool could not be read, so what the store holds is UNKNOWN. " +
95
+ "Do not assume a package is present, and do not report a package as absent.";
57
96
  /** The `language` filter values, mapped onto the concrete section headings. */
58
97
  const LANGUAGE_MATCHERS = {
59
98
  // The R triple: `R (CRAN)`, `R (Bioconductor)`, `R (GitHub)`.
@@ -87,11 +126,15 @@ export function parsePackagesFile(content) {
87
126
  for (const name of trimmed.split(",")) {
88
127
  const pkg = name.trim();
89
128
  if (pkg)
90
- open.packages.push(pkg);
129
+ open.packages.push({ name: pkg });
91
130
  }
92
131
  }
93
132
  return sections;
94
133
  }
134
+ /** One entry as the listing renders it: `name==version` where the source pins a version, the bare name otherwise. */
135
+ function renderPackage(entry) {
136
+ return entry.version === undefined ? entry.name : `${entry.name}==${entry.version}`;
137
+ }
95
138
  /** Render sections to the agent-facing listing, bounded at `limit` packages. */
96
139
  function renderListing(sections, limit) {
97
140
  const lines = [];
@@ -106,7 +149,7 @@ function renderListing(sections, limit) {
106
149
  }
107
150
  const shown = section.packages.slice(0, room);
108
151
  returned += shown.length;
109
- lines.push(`\n## ${section.title}`, ` ${shown.join(", ")}`);
152
+ lines.push(`\n## ${section.title}`, ` ${shown.map(renderPackage).join(", ")}`);
110
153
  const hidden = section.packages.length - shown.length;
111
154
  if (hidden > 0)
112
155
  lines.push(` … and ${hidden} more in this section (raise \`limit\` or narrow with \`query\`).`);
@@ -126,15 +169,26 @@ export function queryPackages(sections, { names, query, language, limit }) {
126
169
  for (const pkg of section.packages) {
127
170
  // First writer wins: `packages.txt` sections are ordered, so a name
128
171
  // colliding across tracks resolves to the earliest one.
129
- if (!index.has(pkg.toLowerCase()))
130
- index.set(pkg.toLowerCase(), { name: pkg, section: section.title });
172
+ if (!index.has(pkg.name.toLowerCase()))
173
+ index.set(pkg.name.toLowerCase(), { entry: pkg, section: section.title });
131
174
  }
132
175
  }
133
176
  const checked = names.map((requested) => {
134
177
  const hit = index.get(requested.trim().toLowerCase());
178
+ if (!hit)
179
+ return { requested, present: false };
135
180
  // `name` echoes the catalog's canonical spelling — R package names are
136
181
  // case-sensitive at `library()`, so the exact one is what the caller needs.
137
- return hit ? { requested, present: true, name: hit.name, section: hit.section } : { requested, present: false };
182
+ // The store identity rides only where the source records it.
183
+ return {
184
+ requested,
185
+ present: true,
186
+ name: hit.entry.name,
187
+ section: hit.section,
188
+ ...(hit.entry.version === undefined ? {} : { version: hit.entry.version }),
189
+ ...(hit.entry.storeDir === undefined ? {} : { storeDir: hit.entry.storeDir }),
190
+ ...(hit.entry.hash === undefined ? {} : { hash: hit.entry.hash }),
191
+ };
138
192
  });
139
193
  return { available: true, checked };
140
194
  }
@@ -144,7 +198,7 @@ export function queryPackages(sections, { names, query, language, limit }) {
144
198
  .filter((s) => matchesLanguage(s.title))
145
199
  .map((s) => ({
146
200
  title: s.title,
147
- packages: needle ? s.packages.filter((p) => p.toLowerCase().includes(needle)) : s.packages,
201
+ packages: needle ? s.packages.filter((p) => p.name.toLowerCase().includes(needle)) : s.packages,
148
202
  }))
149
203
  .filter((s) => s.packages.length > 0);
150
204
  const total = filtered.reduce((n, s) => n + s.packages.length, 0);
@@ -161,18 +215,36 @@ export function queryPackages(sections, { names, query, language, limit }) {
161
215
  const { content, returned } = renderListing(filtered, limit ?? DEFAULT_LIMIT);
162
216
  return { available: true, total, returned, hasMore: returned < total, content };
163
217
  }
164
- /** Create the package inventory over a host-readable `packages.txt`. */
218
+ /**
219
+ * Create the package inventory over the bound source: the pool-scope reader
220
+ * when the embedder binds one (a conversation or planning surface), the
221
+ * host-readable farm `inflexa.lock` otherwise (a sandbox agent). The image
222
+ * fragment merges into either report.
223
+ */
165
224
  export function createListAvailablePackagesTool(deps = {}) {
166
- const packagesFile = deps.packagesFile ?? DEFAULT_PACKAGES_FILE;
225
+ const lockCandidates = deps.farmLockFile ? [deps.farmLockFile] : DEFAULT_FARM_LOCK_FILES;
226
+ const imagePackagesFile = deps.imagePackagesFile ?? DEFAULT_IMAGE_PACKAGES_FILE;
227
+ const readPoolInventory = deps.readPoolInventory;
228
+ // The scope decides the framing sentence: a sandbox agent reads what it can
229
+ // import NOW, and a conversation surface reads what the store HOLDS — a
230
+ // package absent from the pool needs an acquisition, not a shrug. A sandbox
231
+ // composition with the link seam gets a third framing. There the farm is
232
+ // not the boundary of an import, and a closed-world sentence would send the
233
+ // agent to report a package that one link call supplies.
234
+ const scopeSentence = readPoolInventory
235
+ ? "Query the R, Python, CLI, and Node packages the package pool holds — the set a run can link into an analysis. A package reported present needs no acquisition; a package absent here is not in the store yet. "
236
+ : deps.linkToolPresent
237
+ ? "Query the R, Python, CLI, and Node packages importable in the sandbox right now. Nothing installs at runtime; a package absent here can still be linkable from the host's staged pool — call `link_packages` before treating it as missing. "
238
+ : "Query the R, Python, CLI, and Node packages installed in the sandbox. No packages can be installed at runtime — only what this tool reports is importable. ";
167
239
  return defineTool({
168
240
  id: "list_available_packages",
169
- description: "Query the R, Python, CLI, and Node packages installed in the sandbox. No packages can be installed at runtime — only what this tool reports is importable. " +
241
+ description: scopeSentence +
170
242
  "A full listing is small (a few hundred packages) and is returned whole by default, so a listing you get back is the complete set unless `hasMore` says otherwise. " +
171
243
  '`names`: check specific packages (e.g. ["Seurat", "scanpy"]) — returns present/absent plus the language track for each, case-insensitively; this is the cheapest call and the right one for \'is X available?\'. ' +
172
244
  "`query`: case-insensitive substring filter over package names. " +
173
245
  "`language`: restrict to one track (r | python | cli | node). " +
174
246
  "`limit`: cap the packages listed; the response always carries the true `total` and a `hasMore` flag, so truncation is never silent. " +
175
- 'The package list carries NO version numbers — this tool cannot report a package\'s version. Check a version at runtime instead (e.g. `python -c "import scanpy; print(scanpy.__version__)"`).',
247
+ "Each entry renders as `name==version` where the source pins a version, and a `names` check also reports the store directory and the content hash where the source records them.",
176
248
  inputSchema: z.object({
177
249
  names: z
178
250
  .array(z.string())
@@ -212,16 +284,35 @@ export function createListAvailablePackagesTool(deps = {}) {
212
284
  return "full package list";
213
285
  },
214
286
  execute: async (input) => {
287
+ // The baked image fragment merges in when the host can read one; a
288
+ // missing fragment merges nothing, and that is the normal state for
289
+ // a host that runs outside the image.
290
+ const fragmentSections = await readFile(imagePackagesFile, "utf-8")
291
+ .then(parsePackagesFile)
292
+ .catch(() => []);
215
293
  // An unreadable inventory is an expected environment state — model it as an
216
- // `available: false` data variant telling the caller the set is UNKNOWN.
217
- let raw;
218
- try {
219
- raw = await readFile(packagesFile, "utf-8");
294
+ // `available: false` data variant telling the caller the set is UNKNOWN,
295
+ // WITH the reason: without it, a structural fault (a damaged dependency
296
+ // graph) reads as a transient flake, and the caller retries for ever.
297
+ if (readPoolInventory) {
298
+ const pool = await readPoolInventory().catch((cause) => ({
299
+ kind: "unavailable",
300
+ reason: cause instanceof Error ? cause.message : String(cause),
301
+ }));
302
+ if (pool.kind === "unavailable")
303
+ return ok({ available: false, content: `${POOL_UNAVAILABLE_NOTE} The reason: ${pool.reason}.` });
304
+ return ok(queryPackages([...pool.sections, ...fragmentSections], input));
305
+ }
306
+ let lock = null;
307
+ for (const candidate of lockCandidates) {
308
+ lock = readFarmLockFile(candidate).unwrapOr(null);
309
+ if (lock !== null)
310
+ break;
220
311
  }
221
- catch {
312
+ if (lock === null) {
222
313
  return ok({ available: false, content: UNAVAILABLE_NOTE });
223
314
  }
224
- return ok(queryPackages(parsePackagesFile(raw), input));
315
+ return ok(queryPackages([...lockSections(lock), ...fragmentSections], input));
225
316
  },
226
317
  });
227
318
  }
@@ -24,6 +24,7 @@ import type { AcquireEyes } from "../lib/eyes.js";
24
24
  import type { Logger } from "../lib/logger.js";
25
25
  import type { ThreadType } from "../memory/thread-store.js";
26
26
  import { type Tool } from "./define-tool.js";
27
+ import type { ProvenanceSeam } from "../provenance/seam.js";
27
28
  /**
28
29
  * The brief of the ask, plus the one override of the advice.
29
30
  *
@@ -101,6 +102,12 @@ export interface StartReportSessionToolDeps {
101
102
  * carries no stale value of its own.
102
103
  */
103
104
  readonly anchorSession?: (threadId: string) => Promise<EnsureSessionStateResult>;
105
+ /**
106
+ * The provenance seam of the composition. The tool hands it to the spawn,
107
+ * which emits the creation of the session at the moment that the child lands.
108
+ * The tool emits nothing of its own.
109
+ */
110
+ readonly provenance?: ProvenanceSeam;
104
111
  readonly logger?: Logger;
105
112
  }
106
113
  /**
@@ -111,6 +111,7 @@ export function createStartReportSessionTool(deps) {
111
111
  chrome: deps.chrome,
112
112
  ...(deps.eyes ? { eyes: deps.eyes } : {}),
113
113
  ...(deps.anchorSession ? { anchorSession: deps.anchorSession } : {}),
114
+ ...(deps.provenance ? { provenance: deps.provenance } : {}),
114
115
  ...(deps.logger ? { logger: deps.logger } : {}),
115
116
  };
116
117
  const spawn = createReportSessionSpawn(spawnDeps);
@@ -69,6 +69,7 @@ import type { SynthesisStatus, UpdateStepExecutionInput } from "../state/index.j
69
69
  import type { AnalysisStep } from "../schemas/workflow-state.js";
70
70
  import type { ChatProvider, EmbeddingProvider } from "../providers/types.js";
71
71
  import type { BioToolKeys } from "../tools/bio/keys.js";
72
+ import type { ProvenanceSeam } from "../provenance/seam.js";
72
73
  import type { RunCharge } from "../billing/run-charge.js";
73
74
  import { type ResolveWorkspaceRoot } from "../workspace/paths.js";
74
75
  import { type PlanStep } from "./execute-analysis-scheduler.js";
@@ -154,63 +155,6 @@ export interface ExecuteAnalysisResult {
154
155
  readonly failedSteps: readonly string[];
155
156
  readonly canceledSteps: readonly string[];
156
157
  }
157
- /**
158
- * A run-lifecycle provenance observation handed to an optional host observer.
159
- * Harness-owned plain union — the harness stays tsprov-free and bus-free; the
160
- * host maps these execution facts onto its own ledger vocabulary.
161
- *
162
- * Every `atMs` is epoch milliseconds read via `await DBOS.now()`, a checkpointed
163
- * step: a body re-executed by DBOS recovery reads the recorded value, so a
164
- * re-emitted event carries the identical timestamp and merges on the host's
165
- * ledger without a value conflict. Never source these from a wall clock
166
- * (`Date.now()`) — that would diverge across replays and defeat the merge.
167
- *
168
- * `run_completed.durationMs` is the terminal `atMs` minus the `run_started`
169
- * `atMs` (both `DBOS.now()` reads) — the true workflow-observed run span.
170
- *
171
- * `step_completed` fires once at EVERY scheduler-loop settlement — the only site
172
- * that observes every executed step (registration sees only artifact-producing
173
- * steps, and a child cannot observe its own parent-driven cancel). Steps that
174
- * were never dispatched (dependents of a failed sibling) emit nothing by design;
175
- * the run's terminal status carries that outcome. `status` maps the settlement
176
- * outcome: `complete` → `"completed"`, `canceled` → `"canceled"`,
177
- * `failed`/`blocked`/child-error → `"failed"`.
178
- *
179
- * `run_completed` fires at BOTH terminal boundaries (success and failure); the
180
- * `status` field distinguishes them.
181
- */
182
- export type RunProvenanceEvent = {
183
- type: "run_started";
184
- analysisId: string;
185
- runId: string;
186
- planSummary: string;
187
- stepCount: number;
188
- atMs: number;
189
- } | {
190
- type: "step_completed";
191
- analysisId: string;
192
- runId: string;
193
- stepId: string;
194
- /** Settlement outcome mapped to a terminal step status. */
195
- status: "completed" | "failed" | "canceled";
196
- /** The child's durable execution duration; absent when the child settled by throwing. */
197
- durationMs?: number;
198
- atMs: number;
199
- } | {
200
- type: "run_completed";
201
- analysisId: string;
202
- runId: string;
203
- /**
204
- * The body's terminal status. Both boundary sites resolve it through `deriveFinalStatus`,
205
- * which records a budget pause as `"canceled"` — so `"suspended_insufficient_funds"` (a
206
- * `RunStatus` member) is never emitted here and is deliberately absent from this narrower
207
- * `ExecuteAnalysisFinalStatus`-minus-`"running"` set.
208
- */
209
- status: Exclude<ExecuteAnalysisFinalStatus, "running">;
210
- atMs: number;
211
- /** `atMs − run_started.atMs`: the workflow-observed run span in ms. */
212
- durationMs: number;
213
- };
214
158
  /**
215
159
  * A step's in-run display state. Wider than the ledger's `StepExecutionStatus`: `queued` is a
216
160
  * scheduler-only condition (dependency-satisfied but held for budget capacity) that never reaches a
@@ -291,9 +235,11 @@ export interface ExecuteAnalysisDeps {
291
235
  */
292
236
  readonly usageRecorder?: UsageRecorder;
293
237
  /**
294
- * Optional, fire-and-forget provenance observation of the run lifecycle.
295
- * Deliberately invoked directly in the workflow body (NOT wrapped in
296
- * `DBOS.runStep`) so that body re-execution on DBOS recovery re-fires it —
238
+ * The provenance seam of the composition. The body reads its run emit member,
239
+ * and it reads no other member.
240
+ *
241
+ * The emit is deliberately invoked directly in the workflow body (NOT wrapped
242
+ * in `DBOS.runStep`) so that body re-execution on DBOS recovery re-fires it —
297
243
  * a cached step would suppress the recovery re-emission, defeating the
298
244
  * point. Idempotency is the consumer's job, achieved via the deterministic
299
245
  * identifiers the events carry, not via step caching. Every call site is
@@ -303,19 +249,19 @@ export interface ExecuteAnalysisDeps {
303
249
  * persisting realization needs its credential, scope, and identity.
304
250
  * Implementations may ignore the parameter.
305
251
  */
306
- readonly emitProvenance?: (event: RunProvenanceEvent, session: RunSession) => void;
252
+ readonly provenance?: ProvenanceSeam;
307
253
  /**
308
254
  * Optional, fire-and-forget observation of the run's live shape, for a host driving a UI.
309
255
  *
310
- * Independent of {@link emitProvenance} in every direction: neither is implemented in terms of
311
- * the other, they share no payload, and supplying one does not imply the other. They exist
312
- * separately because provenance closes a signed, hash-chained record under a single-writer
313
- * lock, while this is a lossy-tolerant display channel — coupling them would put a UI repaint
314
- * behind the chain's write discipline and force provenance to carry step names and agent ids it
315
- * has no reason to record.
256
+ * Independent of the run emit of {@link provenance} in every direction: neither is implemented
257
+ * in terms of the other, they share no payload, and supplying one does not imply the other.
258
+ * They exist separately because provenance closes a signed, hash-chained record under a
259
+ * single-writer lock, while this is a lossy-tolerant display channel — coupling them would put
260
+ * a UI repaint behind the chain's write discipline and force provenance to carry step names and
261
+ * agent ids it has no reason to record.
316
262
  *
317
- * Invoked directly in the workflow body (NOT wrapped in `DBOS.runStep`) for the same reason
318
- * `emitProvenance` is: body re-execution on recovery must re-fire it, and a cached step would
263
+ * Invoked directly in the workflow body (NOT wrapped in `DBOS.runStep`) for the same reason the
264
+ * run emit is: body re-execution on recovery must re-fire it, and a cached step would
319
265
  * suppress that. Idempotency comes free for rendering because the payload is a whole-run
320
266
  * snapshot; a host taking a DURABLE side effect on one must key it by run id and status itself.
321
267
  *
@@ -88,14 +88,15 @@ function emitStreamPart(part) {
88
88
  * host's own ledger, not here.
89
89
  */
90
90
  function emitProvenanceGuarded(deps, event, session) {
91
- const logger = (deps.logger ?? createNoopLogger()).named("executeAnalysis");
92
- if (!deps.emitProvenance)
91
+ const emit = deps.provenance?.emitRunEvent;
92
+ if (!emit)
93
93
  return;
94
+ const logger = (deps.logger ?? createNoopLogger()).named("executeAnalysis");
94
95
  try {
95
- deps.emitProvenance(event, session);
96
+ emit(event, session);
96
97
  }
97
98
  catch (err) {
98
- logger.error("emitProvenance threw", { runId: event.runId, event: event.type, ...logger.errorFields(err) });
99
+ logger.error("the run emit of the provenance seam threw", { runId: event.runId, event: event.type, ...logger.errorFields(err) });
99
100
  }
100
101
  }
101
102
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@inflexa-ai/harness",
3
- "version": "0.31.1",
3
+ "version": "0.33.0",
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",
@@ -56,6 +56,7 @@
56
56
  "@dbos-inc/dbos-sdk": "^4.23.6",
57
57
  "@fontsource-variable/space-grotesk": "5.1.1",
58
58
  "@fontsource/ibm-plex-mono": "5.1.1",
59
+ "@inflexa-ai/tsprov": "0.6.0",
59
60
  "@kubernetes/client-node": "^1.4.0",
60
61
  "@opentelemetry/api": "^1.9.1",
61
62
  "@opentelemetry/core": "^2.9.0",