@inflexa-ai/harness 0.31.1 → 0.32.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 (56) hide show
  1. package/dist/agents/conversation-agent.d.ts +7 -0
  2. package/dist/agents/conversation-agent.js +1 -0
  3. package/dist/agents/report-session-agent.d.ts +9 -0
  4. package/dist/agents/report-session-agent.js +10 -2
  5. package/dist/app/chat-turn.d.ts +11 -0
  6. package/dist/app/chat-turn.js +17 -1
  7. package/dist/app/spawn-report-session.d.ts +12 -0
  8. package/dist/app/spawn-report-session.js +21 -1
  9. package/dist/contracts/report-blocks.d.ts +8 -8
  10. package/dist/contracts/schemas/chat-parts.d.ts +6 -6
  11. package/dist/index.d.ts +4 -1
  12. package/dist/index.js +8 -0
  13. package/dist/provenance/seam.d.ts +203 -0
  14. package/dist/provenance/seam.js +71 -0
  15. package/dist/report-model/authoring-grammar.d.ts +2 -2
  16. package/dist/report-render/assets.d.ts +10 -0
  17. package/dist/report-render/assets.js +14 -0
  18. package/dist/report-render/design.js +256 -0
  19. package/dist/report-render/page.d.ts +75 -0
  20. package/dist/report-render/page.js +793 -1
  21. package/dist/report-render/provenance-data.d.ts +42 -0
  22. package/dist/report-render/provenance-data.js +62 -0
  23. package/dist/report-render/render.d.ts +20 -1
  24. package/dist/report-render/render.js +23 -10
  25. package/dist/report-render/types.d.ts +3 -0
  26. package/dist/report-render/views/chart-view.d.ts +5 -1
  27. package/dist/report-render/views/chart-view.js +6 -2
  28. package/dist/report-render/views/lineage.d.ts +93 -0
  29. package/dist/report-render/views/lineage.js +86 -0
  30. package/dist/report-render/views/page-view.d.ts +22 -1
  31. package/dist/report-render/views/page-view.js +12 -3
  32. package/dist/report-render/views/prose.d.ts +7 -1
  33. package/dist/report-render/views/prose.js +10 -6
  34. package/dist/report-render/views/references-view.d.ts +17 -1
  35. package/dist/report-render/views/references-view.js +12 -4
  36. package/dist/report-render/views/values.d.ts +17 -4
  37. package/dist/report-render/views/values.js +21 -8
  38. package/dist/runtime/assemble.d.ts +22 -0
  39. package/dist/runtime/assemble.js +4 -0
  40. package/dist/sandbox/k8s-client.d.ts +0 -5
  41. package/dist/sandbox/k8s-client.js +9 -115
  42. package/dist/schemas/artifact-manifest.d.ts +4 -4
  43. package/dist/tools/display/show-user.d.ts +1 -1
  44. package/dist/tools/report-authoring/authoring-tools.d.ts +16 -1
  45. package/dist/tools/report-authoring/authoring-tools.js +58 -16
  46. package/dist/tools/report-session/derive-table.d.ts +3 -0
  47. package/dist/tools/report-session/derive-table.js +15 -0
  48. package/dist/tools/report-session/preview-report.d.ts +13 -4
  49. package/dist/tools/report-session/preview-report.js +23 -6
  50. package/dist/tools/report-session/record-version.d.ts +3 -0
  51. package/dist/tools/report-session/record-version.js +7 -3
  52. package/dist/tools/start-report-session.d.ts +7 -0
  53. package/dist/tools/start-report-session.js +1 -0
  54. package/dist/workflows/execute-analysis.d.ts +15 -69
  55. package/dist/workflows/execute-analysis.js +5 -4
  56. package/package.json +2 -1
@@ -19,7 +19,9 @@
19
19
  import { err, ok } from "neverthrow";
20
20
  import { basename } from "node:path";
21
21
  import { z } from "zod";
22
+ import { createNoopLogger } from "../../lib/console-logger.js";
22
23
  import { capCodePoints, DETAIL_NEEDLE_MAX_LENGTH } from "../../loop/tool-detail.js";
24
+ import { bindSessionEmit } from "../../provenance/seam.js";
23
25
  import { isSafeId } from "../../workspace/paths.js";
24
26
  import { addBlock, changeBlock, locate, moveBlock, removeBlock, setTitle, } from "../../report-model/draft-operations.js";
25
27
  import { buildOutline, childOutline, readBlock } from "../../report-model/draft-read.js";
@@ -299,6 +301,23 @@ function addedSubject(kind, block) {
299
301
  function holderIdOf(document, blockId) {
300
302
  return locate(document, blockId)?.parent?.id;
301
303
  }
304
+ /**
305
+ * The event of one block act, or `undefined` when the document holds no block of that id.
306
+ *
307
+ * The kind rides beside the id, thus a reader of the record sees what the act touched and it needs no
308
+ * document of its own. The kind comes out of the document that holds the block after the act: the next
309
+ * document for an add, a change, and a move, and the previous one for a remove.
310
+ *
311
+ * A lookup of a landed operation always finds its block. The absent arm keeps the read total, and it drops
312
+ * the event rather than state a kind that no block carries.
313
+ */
314
+ function blockActOf(type, scope, document, blockId) {
315
+ const kind = locate(document, blockId)?.block.kind;
316
+ if (kind === undefined) {
317
+ return undefined;
318
+ }
319
+ return { type, analysisId: scope.analysisId, threadId: scope.threadId, blockId, blockKind: kind };
320
+ }
302
321
  /** Read the child order of one container out of a document. */
303
322
  function containerIn(document, parentId) {
304
323
  if (parentId === undefined) {
@@ -326,8 +345,12 @@ const AUTHORING_EXECUTION_MODE = "inline";
326
345
  * Each tool reads the thread id from the scope of the call, and it loads the state of that thread through
327
346
  * the gateway. Thus one factory serves every thread, and two threads never share one draft. A mutation
328
347
  * persists the new document before it reports `applied: true`, thus a reported landing is never lost.
348
+ *
349
+ * A landed mutation emits one observation. The emit sits after the persist, thus a refused operation and a
350
+ * failed persist each emit nothing.
329
351
  */
330
- export function createReportAuthoringTools(gateway) {
352
+ export function createReportAuthoringTools(gateway, deps = {}) {
353
+ const observe = bindSessionEmit(deps.provenance, (deps.logger ?? createNoopLogger()).named("report-authoring"));
331
354
  /**
332
355
  * Resolve the thread of the call, and load its state.
333
356
  *
@@ -346,8 +369,13 @@ export function createReportAuthoringTools(gateway) {
346
369
  *
347
370
  * The persist is a compare-and-swap against the token that the load read. A concurrent turn that landed
348
371
  * first turns the persist into a conflict, and the tool refuses with `stale-state`.
372
+ *
373
+ * `event` is the observation of the operation, and it emits after the persist landed. Thus one site owns
374
+ * the rule, and a refusal, a conflict, and a persist fault each emit nothing. It reads the two documents
375
+ * for the same reason that `holders` does: a removed block sits in the first alone, and an added block
376
+ * sits in the second alone. A caller that cannot name the block of its own operation gives no event.
349
377
  */
350
- const land = async (threadId, token, previous, result, holders) => {
378
+ const land = async (threadId, token, previous, result, holders, event) => {
351
379
  if (result.isErr()) {
352
380
  return { applied: false, refusal: result.error };
353
381
  }
@@ -362,6 +390,10 @@ export function createReportAuthoringTools(gateway) {
362
390
  if (persisted.outcome === "failed") {
363
391
  return { applied: false, refusal: { reason: "state-unavailable", detail: persisted.detail } };
364
392
  }
393
+ const observed = event(previous, next);
394
+ if (observed !== undefined) {
395
+ observe(observed);
396
+ }
365
397
  // A move inside one container names the same holder two times.
366
398
  const unique = [...new Set(holders(previous, next))];
367
399
  return { applied: true, changed: unique.map((parentId) => containerIn(next, parentId)) };
@@ -390,14 +422,17 @@ export function createReportAuthoringTools(gateway) {
390
422
  if (opened.isErr()) {
391
423
  return ok(refuse(opened.error));
392
424
  }
393
- const { threadId, state, token } = opened.value;
425
+ const { threadId, analysisId, state, token } = opened.value;
394
426
  const destination = toDestination(input);
395
427
  if (destination.isErr()) {
396
428
  return ok(refuse(destination.error));
397
429
  }
398
430
  const block = decodeBlockPayload(input.block);
399
431
  const addedId = readId(block);
400
- return ok(await land(threadId, token, state.document, addBlock(state.document, { block, destination: destination.value }, state.snapshot), (_previous, next) => [addedId === undefined ? undefined : holderIdOf(next, addedId)]));
432
+ return ok(await land(threadId, token, state.document, addBlock(state.document, { block, destination: destination.value }, state.snapshot), (_previous, next) => [addedId === undefined ? undefined : holderIdOf(next, addedId)],
433
+ // The grammar requires an id on the payload, thus a landed add always names one. A
434
+ // payload with no id reaches the core, which refuses it, and no event is due.
435
+ (_previous, next) => (addedId === undefined ? undefined : blockActOf("add-block", { analysisId, threadId }, next, addedId))));
401
436
  },
402
437
  });
403
438
  const change_block = defineTool({
@@ -413,14 +448,15 @@ export function createReportAuthoringTools(gateway) {
413
448
  if (opened.isErr()) {
414
449
  return ok(refuse(opened.error));
415
450
  }
416
- const { threadId, state, token } = opened.value;
451
+ const { threadId, analysisId, state, token } = opened.value;
417
452
  const operation = toChangeOperation(input);
418
453
  if (operation.isErr()) {
419
454
  return ok(refuse(operation.error));
420
455
  }
421
- return ok(await land(threadId, token, state.document, changeBlock(state.document, operation.value, state.snapshot), (_previous, next) => [
422
- holderIdOf(next, input.targetId),
423
- ]));
456
+ return ok(await land(threadId, token, state.document, changeBlock(state.document, operation.value, state.snapshot), (_previous, next) => [holderIdOf(next, input.targetId)],
457
+ // A change is permitted to change the kind, thus the event states the kind that the
458
+ // document holds after the act.
459
+ (_previous, next) => blockActOf("change-block", { analysisId, threadId }, next, input.targetId)));
424
460
  },
425
461
  });
426
462
  const remove_block = defineTool({
@@ -434,10 +470,11 @@ export function createReportAuthoringTools(gateway) {
434
470
  if (opened.isErr()) {
435
471
  return ok(refuse(opened.error));
436
472
  }
437
- const { threadId, state, token } = opened.value;
438
- return ok(await land(threadId, token, state.document, removeBlock(state.document, { targetId: input.targetId }, state.snapshot), (previous) => [
439
- holderIdOf(previous, input.targetId),
440
- ]));
473
+ const { threadId, analysisId, state, token } = opened.value;
474
+ return ok(await land(threadId, token, state.document, removeBlock(state.document, { targetId: input.targetId }, state.snapshot), (previous) => [holderIdOf(previous, input.targetId)],
475
+ // The next document holds no removed block, thus the event states the kind that the
476
+ // previous one carried.
477
+ (previous) => blockActOf("remove-block", { analysisId, threadId }, previous, input.targetId)));
441
478
  },
442
479
  });
443
480
  const move_block = defineTool({
@@ -452,12 +489,12 @@ export function createReportAuthoringTools(gateway) {
452
489
  if (opened.isErr()) {
453
490
  return ok(refuse(opened.error));
454
491
  }
455
- const { threadId, state, token } = opened.value;
492
+ const { threadId, analysisId, state, token } = opened.value;
456
493
  const destination = toDestination(input);
457
494
  if (destination.isErr()) {
458
495
  return ok(refuse(destination.error));
459
496
  }
460
- return ok(await land(threadId, token, state.document, moveBlock(state.document, { targetId: input.targetId, destination: destination.value }, state.snapshot), (previous, next) => [holderIdOf(previous, input.targetId), holderIdOf(next, input.targetId)]));
497
+ return ok(await land(threadId, token, state.document, moveBlock(state.document, { targetId: input.targetId, destination: destination.value }, state.snapshot), (previous, next) => [holderIdOf(previous, input.targetId), holderIdOf(next, input.targetId)], (_previous, next) => blockActOf("move-block", { analysisId, threadId }, next, input.targetId)));
461
498
  },
462
499
  });
463
500
  const set_title = defineTool({
@@ -473,8 +510,13 @@ export function createReportAuthoringTools(gateway) {
473
510
  if (opened.isErr()) {
474
511
  return ok(refuse(opened.error));
475
512
  }
476
- const { threadId, state, token } = opened.value;
477
- return ok(await land(threadId, token, state.document, ok(setTitle(state.document, input.title)), () => []));
513
+ const { threadId, analysisId, state, token } = opened.value;
514
+ return ok(await land(threadId, token, state.document, ok(setTitle(state.document, input.title)), () => [], () => ({
515
+ type: "set-title",
516
+ analysisId,
517
+ threadId,
518
+ title: input.title,
519
+ })));
478
520
  },
479
521
  });
480
522
  const read_outline = defineTool({
@@ -42,6 +42,7 @@ import type { DerivationSource, ReportSessionStateStore } from "../../state/repo
42
42
  import { type ResolveWorkspaceRoot } from "../../workspace/paths.js";
43
43
  import { type Tool } from "../define-tool.js";
44
44
  import { type ReportSessionStateGateway, type SessionRefusal } from "../report-authoring/authoring-tools.js";
45
+ import { type ProvenanceSeam } from "../../provenance/seam.js";
45
46
  /** The input of one derivation: the script, the declared pinned inputs, and the name of the output file. */
46
47
  declare const deriveTableInput: z.ZodObject<{
47
48
  script: z.ZodString;
@@ -114,6 +115,8 @@ export interface DeriveTableToolDeps {
114
115
  readonly derivations: Pick<ReportSessionStateStore, "appendDerivation">;
115
116
  readonly runDerivation?: DeriveTableRunner;
116
117
  readonly runAuthorizer?: RunAuthorizer;
118
+ /** The provenance seam; an unbound session emit emits nothing and the derivation runs the same. */
119
+ readonly provenance?: ProvenanceSeam;
117
120
  readonly logger?: Logger;
118
121
  }
119
122
  /** The synthetic run id and step id of one derivation. Both are constants, and neither names a run row. */
@@ -45,6 +45,7 @@ import { generateExecutionId } from "../../sandbox/execution-id.js";
45
45
  import { isSafeId, reportSessionDerivedDir, toSandboxPath } from "../../workspace/paths.js";
46
46
  import { defineTool } from "../define-tool.js";
47
47
  import { openReportThread } from "../report-authoring/authoring-tools.js";
48
+ import { bindSessionEmit } from "../../provenance/seam.js";
48
49
  import { readHeaderColumns } from "./list-artifacts.js";
49
50
  /** The input of one derivation: the script, the declared pinned inputs, and the name of the output file. */
50
51
  const deriveTableInput = z.object({
@@ -128,6 +129,7 @@ export function describeExecFailure(result) {
128
129
  */
129
130
  export function createDeriveTableTool(deps) {
130
131
  const logger = (deps.logger ?? createNoopLogger()).named("derive-table");
132
+ const observe = bindSessionEmit(deps.provenance, logger);
131
133
  const { runDerivation, runAuthorizer } = deps;
132
134
  return defineTool({
133
135
  id: "derive_table",
@@ -252,6 +254,19 @@ export function createDeriveTableTool(deps) {
252
254
  outputName: input.output,
253
255
  logger,
254
256
  });
257
+ // The record of the derivation lands before this point, thus the event states a table that
258
+ // the session already holds as evidence. Each other arm derived nothing.
259
+ if (result.outcome === "derived") {
260
+ observe({
261
+ type: "run-derivation",
262
+ analysisId,
263
+ threadId,
264
+ outputPath: result.path,
265
+ outputHash: result.hash,
266
+ scriptHash: result.scriptHash,
267
+ sources: result.sources,
268
+ });
269
+ }
255
270
  return ok(result);
256
271
  }
257
272
  finally {
@@ -7,10 +7,12 @@
7
7
  * and renders the page.
8
8
  *
9
9
  * The renderer writes no file. It gives the page and the data asset of each table, and this tool stages
10
- * them beside the figures. It also writes the script of each derivation that the document references, from
11
- * the text of the durable record. The shipped libraries and fonts stage under `assets/deps/`, and each
12
- * report-side file at the root of `assets/`. The stage is authoritative over that root: what this preview
13
- * wrote stays, and each other file goes.
10
+ * them beside the figures. The tool asks the optional provenance source for the frozen document of the
11
+ * analysis first, and the renderer turns the opaque text into data assets of the same kind. It also writes
12
+ * the script of each derivation that the document references, from the text of the durable record. The
13
+ * shipped libraries and fonts stage under `assets/deps/`, and each report-side file at the root of
14
+ * `assets/`. The stage is authoritative over that root: what this preview wrote stays, and each other file
15
+ * goes.
14
16
  *
15
17
  * The page and its staged assets land in `report-sessions/{threadId}/` under the workspace root. That
16
18
  * namespace belongs to this path alone, thus the tool never writes under the old `previews/` or `reports/`
@@ -45,6 +47,7 @@ import { type BridgeMismatch } from "../../report-render/value-bridge.js";
45
47
  import type { RenderProblem } from "../../report-render/types.js";
46
48
  import { type Tool } from "../define-tool.js";
47
49
  import { type ReportSessionStateGateway, type SessionRefusal } from "../report-authoring/authoring-tools.js";
50
+ import { type ProvenanceSeam } from "../../provenance/seam.js";
48
51
  import { type MakeSessionPagePublisher } from "./session-page-publisher.js";
49
52
  /** The empty input. The tool renders the current draft of the thread, thus it needs no field. */
50
53
  declare const previewReportInput: z.ZodObject<{}, z.core.$strip>;
@@ -135,6 +138,12 @@ export interface PreviewReportToolDeps {
135
138
  readonly resolveWorkspaceRoot: ResolveWorkspaceRoot;
136
139
  readonly resolvePageAsset?: ResolvePageAsset;
137
140
  readonly makeSessionPages?: MakeSessionPagePublisher;
141
+ /**
142
+ * The provenance seam. An unbound session emit emits nothing and the render runs the same. An unbound
143
+ * document read, and a read that holds no document for the analysis, both give absence, and the page
144
+ * then carries no provenance asset.
145
+ */
146
+ readonly provenance?: ProvenanceSeam;
138
147
  readonly logger?: Logger;
139
148
  }
140
149
  /**
@@ -7,10 +7,12 @@
7
7
  * and renders the page.
8
8
  *
9
9
  * The renderer writes no file. It gives the page and the data asset of each table, and this tool stages
10
- * them beside the figures. It also writes the script of each derivation that the document references, from
11
- * the text of the durable record. The shipped libraries and fonts stage under `assets/deps/`, and each
12
- * report-side file at the root of `assets/`. The stage is authoritative over that root: what this preview
13
- * wrote stays, and each other file goes.
10
+ * them beside the figures. The tool asks the optional provenance source for the frozen document of the
11
+ * analysis first, and the renderer turns the opaque text into data assets of the same kind. It also writes
12
+ * the script of each derivation that the document references, from the text of the durable record. The
13
+ * shipped libraries and fonts stage under `assets/deps/`, and each report-side file at the root of
14
+ * `assets/`. The stage is authoritative over that root: what this preview wrote stays, and each other file
15
+ * goes.
14
16
  *
15
17
  * The page and its staged assets land in `report-sessions/{threadId}/` under the workspace root. That
16
18
  * namespace belongs to this path alone, thus the tool never writes under the old `previews/` or `reports/`
@@ -55,6 +57,7 @@ import { ASSETS_DIR, DEPS_DIR, derivationScriptName, PAGE_ASSETS, stagedSource,
55
57
  import { renderReportPage } from "../../report-render/render.js";
56
58
  import { defineTool } from "../define-tool.js";
57
59
  import { openReportThread } from "../report-authoring/authoring-tools.js";
60
+ import { bindReadExport, bindSessionEmit } from "../../provenance/seam.js";
58
61
  import { describeSessionPageMintFailure } from "./session-page-publisher.js";
59
62
  /** The empty input. The tool renders the current draft of the thread, thus it needs no field. */
60
63
  const previewReportInput = z.object({});
@@ -383,6 +386,8 @@ async function mintAccess(makeSessionPages, analysisId, threadId, auth, logger)
383
386
  */
384
387
  export function createPreviewReportTool(deps) {
385
388
  const logger = (deps.logger ?? createNoopLogger()).named("preview-report");
389
+ const observe = bindSessionEmit(deps.provenance, logger);
390
+ const readProvenance = bindReadExport(deps.provenance, logger);
386
391
  const resolvePageAsset = deps.resolvePageAsset ?? resolvePageAssetFromInstallation;
387
392
  return defineTool({
388
393
  id: "preview_report",
@@ -438,7 +443,15 @@ export function createPreviewReportTool(deps) {
438
443
  // relative links of its entry, thus the renderer states them and it stages nothing.
439
444
  const used = referencedDerivations(document, derivations);
440
445
  const sessionDir = reportSessionDir(threadId);
441
- const rendered = renderReportPage(document, bridged.value, snapshot.citationRecords, used.map((record) => chainOf(record, sessionDir)));
446
+ // The provenance is the frozen document of the analysis. It rides the render as opaque text, thus
447
+ // the renderer moves it into a data asset and no part of this path reads a field of it. Absence
448
+ // gives a page with no provenance asset, and each other part of the render is unchanged.
449
+ const provenance = await readProvenance(analysisId);
450
+ const rendered = renderReportPage(document, bridged.value, {
451
+ records: snapshot.citationRecords,
452
+ derivations: used.map((record) => chainOf(record, sessionDir)),
453
+ provenance,
454
+ });
442
455
  if (rendered.isErr()) {
443
456
  return ok({ outcome: "render-problems", problems: rendered.error });
444
457
  }
@@ -471,7 +484,8 @@ export function createPreviewReportTool(deps) {
471
484
  // The page shows the draft that the finish read. The stamp records that draft, thus the eyes
472
485
  // copy this hash and the record compares against it. A failed stamp is a transient store fault,
473
486
  // and the page stays on disk. The agent runs the preview again to stamp the marker.
474
- const stamped = await deps.gateway.stampRendered(threadId, computeDraftHash(draft));
487
+ const renderedHash = computeDraftHash(draft);
488
+ const stamped = await deps.gateway.stampRendered(threadId, renderedHash);
475
489
  if (stamped.outcome !== "stamped") {
476
490
  const detail = stamped.outcome === "failed" ? stamped.detail : "the session state row is absent";
477
491
  logger.warn("the rendered hash did not stamp", { threadId, analysisId, detail });
@@ -483,6 +497,9 @@ export function createPreviewReportTool(deps) {
483
497
  type: "data-report-rendered",
484
498
  data: { id: randomUUID(), renderedAt: new Date().toISOString(), title: document.title },
485
499
  });
500
+ // The event rides the rendered arm alone, the same as the part above. The hash is the hash of
501
+ // the draft that the page shows, thus a consumer ties the page to the document that made it.
502
+ observe({ type: "preview", analysisId, threadId, pagePath: written.value, documentHash: renderedHash });
486
503
  const access = await mintAccess(deps.makeSessionPages, analysisId, threadId, ctx.session.auth, logger);
487
504
  return ok({ outcome: "rendered", pagePath: written.value, ...(access !== undefined ? { access } : {}) });
488
505
  },
@@ -39,6 +39,7 @@ import type { ReportVersionStore } from "../../state/report-versions.js";
39
39
  import { type ResolveWorkspaceRoot } from "../../workspace/paths.js";
40
40
  import { type Tool } from "../define-tool.js";
41
41
  import { type ReportSessionStateGateway, type SessionRefusal } from "../report-authoring/authoring-tools.js";
42
+ import { type ProvenanceSeam } from "../../provenance/seam.js";
42
43
  /** The empty input. The tool records the current draft of the thread, thus it needs no field. */
43
44
  declare const recordVersionInput: z.ZodObject<{}, z.core.$strip>;
44
45
  export type RecordVersionInput = z.infer<typeof recordVersionInput>;
@@ -101,6 +102,8 @@ export interface RecordVersionToolDeps {
101
102
  analysisId: string;
102
103
  auth: AuthContext;
103
104
  }) => ReferenceResolver;
105
+ /** The provenance seam; an unbound session emit emits nothing and the record runs the same. */
106
+ readonly provenance?: ProvenanceSeam;
104
107
  readonly logger?: Logger;
105
108
  }
106
109
  /**
@@ -42,6 +42,7 @@ import { validateReport } from "../../report-model/validate.js";
42
42
  import { reportSessionDerivedDir, resolveWorkspacePath } from "../../workspace/paths.js";
43
43
  import { defineTool } from "../define-tool.js";
44
44
  import { openReportThread } from "../report-authoring/authoring-tools.js";
45
+ import { bindSessionEmit } from "../../provenance/seam.js";
45
46
  /** The empty input. The tool records the current draft of the thread, thus it needs no field. */
46
47
  const recordVersionInput = z.object({});
47
48
  /** A short account of a record refusal, for the detail of a transient failure. */
@@ -122,6 +123,7 @@ async function pruneUnusedDerivations(args) {
122
123
  */
123
124
  export function createRecordVersionTool(deps) {
124
125
  const logger = (deps.logger ?? createNoopLogger()).named("record-report-version");
126
+ const observe = bindSessionEmit(deps.provenance, logger);
125
127
  return defineTool({
126
128
  id: "record_report_version",
127
129
  description: "Record the current draft as one report version. The tool runs the whole gate first: it finishes the draft, " +
@@ -203,9 +205,11 @@ export function createRecordVersionTool(deps) {
203
205
  parentSeq: thread.value.parentSeq,
204
206
  });
205
207
  if (recorded.isOk()) {
206
- // The version stands from here. The prune reclaims the bytes of each derivation that the
207
- // recorded document ignores, and it decides nothing about the outcome. It runs on each
208
- // record, thus an output that an amend unbound goes at the next record.
208
+ // The version stands from here, thus the event states a version that a reader can open.
209
+ observe({ type: "record-version", analysisId, threadId, versionId: recorded.value.versionId, replaced: recorded.value.outcome === "replaced" });
210
+ // The prune reclaims the bytes of each derivation that the recorded document ignores, and it
211
+ // decides nothing about the outcome. It runs on each record, thus an output that an amend
212
+ // unbound goes at the next record.
209
213
  await pruneUnusedDerivations({
210
214
  resolveWorkspaceRoot: deps.resolveWorkspaceRoot,
211
215
  analysisId,
@@ -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.32.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",