@inflexa-ai/harness 0.18.0 → 0.19.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 (41) hide show
  1. package/dist/agents/report-session-agent.d.ts +56 -0
  2. package/dist/agents/report-session-agent.js +72 -0
  3. package/dist/app/report-session-runtime.d.ts +72 -0
  4. package/dist/app/report-session-runtime.js +157 -0
  5. package/dist/memory/thread-store.d.ts +4 -4
  6. package/dist/memory/thread-store.js +18 -9
  7. package/dist/prompts/report-session.d.ts +1 -0
  8. package/dist/prompts/report-session.js +89 -0
  9. package/dist/providers/ai-sdk.d.ts +77 -0
  10. package/dist/providers/ai-sdk.js +149 -19
  11. package/dist/providers/errors.d.ts +14 -0
  12. package/dist/providers/errors.js +103 -18
  13. package/dist/providers/types.d.ts +16 -0
  14. package/dist/providers/types.js +10 -1
  15. package/dist/report-model/{mint-snapshot.d.ts → pin-snapshot.d.ts} +7 -7
  16. package/dist/report-model/{mint-snapshot.js → pin-snapshot.js} +7 -7
  17. package/dist/report-model/reference-resolver.d.ts +1 -1
  18. package/dist/report-model/validate.d.ts +34 -2
  19. package/dist/report-model/validate.js +32 -17
  20. package/dist/report-render/value-bridge.d.ts +62 -0
  21. package/dist/report-render/value-bridge.js +82 -0
  22. package/dist/runtime/assemble.d.ts +15 -0
  23. package/dist/runtime/assemble.js +28 -6
  24. package/dist/state/init.js +23 -0
  25. package/dist/state/purge-analysis.js +1 -0
  26. package/dist/state/report-session-state.d.ts +112 -0
  27. package/dist/state/report-session-state.js +150 -0
  28. package/dist/state/report-versions.d.ts +3 -6
  29. package/dist/state/report-versions.js +11 -38
  30. package/dist/state/snapshot-parse.d.ts +34 -0
  31. package/dist/state/snapshot-parse.js +82 -0
  32. package/dist/tools/ad-hoc-router.d.ts +7 -2
  33. package/dist/tools/ad-hoc-router.js +2 -1
  34. package/dist/tools/report-authoring/authoring-tools.d.ts +131 -24
  35. package/dist/tools/report-authoring/authoring-tools.js +152 -44
  36. package/dist/tools/report-session/index.d.ts +8 -0
  37. package/dist/tools/report-session/index.js +8 -0
  38. package/dist/tools/report-session/preview-report.d.ts +90 -0
  39. package/dist/tools/report-session/preview-report.js +245 -0
  40. package/dist/tools/research/generate-plan.js +4 -2
  41. package/package.json +1 -1
@@ -17,7 +17,7 @@ import { columnsHeldByNoRow } from "./reference-resolver.js";
17
17
  * A production resolver reads storage for each reference, and a large report holds hundreds of them. An
18
18
  * unbounded fan-out would open one read for each reference at the same moment.
19
19
  */
20
- const RESOLUTION_CONCURRENCY = 8;
20
+ export const RESOLUTION_CONCURRENCY = 8;
21
21
  /**
22
22
  * Match each column that a chart encoding names against the table that its binding resolved to.
23
23
  *
@@ -25,7 +25,7 @@ const RESOLUTION_CONCURRENCY = 8;
25
25
  * bound resolver broke its own contract. That is reported and never ignored, because a chart with no
26
26
  * table behind it renders nothing and a silent skip would pass it as grounded.
27
27
  */
28
- function checkChartEncoding(entry, value) {
28
+ export function checkChartEncoding(entry, value) {
29
29
  const encodingColumns = entry.encodingColumns;
30
30
  if (encodingColumns === undefined || encodingColumns.length === 0) {
31
31
  return undefined;
@@ -43,6 +43,34 @@ function checkChartEncoding(entry, value) {
43
43
  detail: `the encoding names column ${absent.join(", ")}, which the bound table does not hold`,
44
44
  };
45
45
  }
46
+ /**
47
+ * Resolve each reference of a block tree under the concurrency bound, and run the chart-encoding match.
48
+ *
49
+ * The walk collects each reference one time, `allWithConcurrency` bounds the fan-out, and
50
+ * `checkChartEncoding` catches a chart that plots a column which the bound table does not hold. The record
51
+ * gate and the preview share this one pass, thus the two refuse the same references. A value-bearing block
52
+ * whose reference resolved carries its value in the map. A claim or a citation reference reads no value
53
+ * from the map. The walk collects every failure, thus a reviewer sees each block that broke.
54
+ */
55
+ export async function resolveDocumentReferences(sections, snapshot, resolver) {
56
+ const { references } = walkBlocks(sections);
57
+ const resolved = await allWithConcurrency(references.map((entry) => () => resolver.resolve(entry.reference, snapshot).then((result) => ({ entry, result }))), RESOLUTION_CONCURRENCY);
58
+ const failures = [];
59
+ const resolvedByBlock = new Map();
60
+ for (const { entry, result } of resolved) {
61
+ if (result.isErr()) {
62
+ failures.push({ blockId: entry.blockId, failure: result.error });
63
+ continue;
64
+ }
65
+ const encodingFailure = checkChartEncoding(entry, result.value);
66
+ if (encodingFailure !== undefined) {
67
+ failures.push({ blockId: entry.blockId, failure: encodingFailure });
68
+ continue;
69
+ }
70
+ resolvedByBlock.set(entry.blockId, result.value);
71
+ }
72
+ return { resolvedByBlock, failures };
73
+ }
46
74
  /** Resolve each reference, collect the schema conformance and the resolution outcomes, and warn on prose. */
47
75
  export async function validateReport(document, snapshot, resolver) {
48
76
  const parsed = ReportDocumentSchema.safeParse(document);
@@ -53,21 +81,8 @@ export async function validateReport(document, snapshot, resolver) {
53
81
  }));
54
82
  return { valid: false, schemaIssues, warnings: [] };
55
83
  }
56
- const { references, repeatedIds, warnings } = walkBlocks(parsed.data.sections);
57
- const resolved = await allWithConcurrency(references.map((entry) => () => resolver.resolve(entry.reference, snapshot).then((result) => ({ entry, result }))), RESOLUTION_CONCURRENCY);
58
- // Collect every failure. A reviewer must see each block that broke, thus the walk does not stop at
59
- // the first unresolved reference. The `Err` channel carries the unresolved reference itself.
60
- const resolutionFailures = [];
61
- for (const { entry, result } of resolved) {
62
- if (result.isErr()) {
63
- resolutionFailures.push({ blockId: entry.blockId, failure: result.error });
64
- continue;
65
- }
66
- const encodingFailure = checkChartEncoding(entry, result.value);
67
- if (encodingFailure !== undefined) {
68
- resolutionFailures.push({ blockId: entry.blockId, failure: encodingFailure });
69
- }
70
- }
84
+ const { repeatedIds, warnings } = walkBlocks(parsed.data.sections);
85
+ const { failures: resolutionFailures } = await resolveDocumentReferences(parsed.data.sections, snapshot, resolver);
71
86
  if (repeatedIds.length > 0 || resolutionFailures.length > 0) {
72
87
  return {
73
88
  valid: false,
@@ -0,0 +1,62 @@
1
+ /**
2
+ * The value bridge: a resolved value maps onto a render value, keyed by block id.
3
+ *
4
+ * The resolver gives one `ResolvedValue` for each reference. The renderer takes a `RenderValues` map. The
5
+ * two models agree on a scalar, a table, and a citation, but they part on a figure. A `ResolvedValue`
6
+ * file echo carries a path and a hash, and a page needs a source string. Thus the bridge takes a
7
+ * caller-supplied policy that turns a file echo into a `src` string. The preview tool gives the concrete
8
+ * policy, because the page directory and its asset access are a session concern.
9
+ *
10
+ * The bridge is pure. It reads no file, it resolves no reference, and it imports nothing from the state
11
+ * layer or the tool layer. A block kind that carries no render value maps to no entry. A block kind whose
12
+ * resolved value has the wrong type becomes one typed mismatch. The bridge collects every mismatch, thus
13
+ * one call reports each fault at one time.
14
+ */
15
+ import { type Result } from "neverthrow";
16
+ import type { ResolvedValue } from "../report-model/reference-resolver.js";
17
+ import type { RenderValues } from "./types.js";
18
+ /** The block kinds that carry no render value. Each one renders from the block and the ledger alone. */
19
+ type NoValueKind = "text" | "claim" | "citation" | "section";
20
+ /** The block kinds that carry one render value under the block id in the map. */
21
+ type ValueBearingKind = "metric" | "table" | "chart" | "figure";
22
+ /** The file echo that a figure binding resolves to: a whole-file pin of a path and a hash. */
23
+ export type ResolvedFile = Extract<ResolvedValue, {
24
+ type: "file";
25
+ }>;
26
+ /** The policy that turns a resolved file echo into a figure `src` string. The caller owns the policy. */
27
+ export type FigureSourcePolicy = (file: ResolvedFile) => string;
28
+ /**
29
+ * The resolution result of one block: the block id, the block kind, and the resolved value of its binding.
30
+ *
31
+ * A value-bearing kind carries the resolved value. A no-value kind carries none, thus a text block with
32
+ * no binding is representable and a value on it is not.
33
+ */
34
+ export type BlockResolution = {
35
+ blockId: string;
36
+ kind: NoValueKind;
37
+ } | {
38
+ blockId: string;
39
+ kind: ValueBearingKind;
40
+ resolved: ResolvedValue;
41
+ };
42
+ /**
43
+ * One typed mismatch between a block kind and a resolved value type. `blockId` names the block, and
44
+ * `blockKind` names the kind that sets the expectation. `expected` names the resolved type that the kind
45
+ * needs, and `actual` names the resolved type that arrived.
46
+ */
47
+ export interface BridgeMismatch {
48
+ blockId: string;
49
+ blockKind: ValueBearingKind;
50
+ expected: ResolvedValue["type"];
51
+ actual: ResolvedValue["type"];
52
+ }
53
+ /**
54
+ * Map the resolved values of the blocks onto a `RenderValues` map.
55
+ *
56
+ * The bridge walks each resolution in order. A no-value kind adds no entry. A value-bearing kind whose
57
+ * resolved type matches adds one entry. A value-bearing kind whose resolved type does not match adds one
58
+ * mismatch. A run with a mismatch returns the mismatch list and no map. A run with no mismatch returns the
59
+ * complete map.
60
+ */
61
+ export declare function bridgeValues(resolutions: readonly BlockResolution[], figureSrc: FigureSourcePolicy): Result<RenderValues, BridgeMismatch[]>;
62
+ export {};
@@ -0,0 +1,82 @@
1
+ /**
2
+ * The value bridge: a resolved value maps onto a render value, keyed by block id.
3
+ *
4
+ * The resolver gives one `ResolvedValue` for each reference. The renderer takes a `RenderValues` map. The
5
+ * two models agree on a scalar, a table, and a citation, but they part on a figure. A `ResolvedValue`
6
+ * file echo carries a path and a hash, and a page needs a source string. Thus the bridge takes a
7
+ * caller-supplied policy that turns a file echo into a `src` string. The preview tool gives the concrete
8
+ * policy, because the page directory and its asset access are a session concern.
9
+ *
10
+ * The bridge is pure. It reads no file, it resolves no reference, and it imports nothing from the state
11
+ * layer or the tool layer. A block kind that carries no render value maps to no entry. A block kind whose
12
+ * resolved value has the wrong type becomes one typed mismatch. The bridge collects every mismatch, thus
13
+ * one call reports each fault at one time.
14
+ */
15
+ import { err, ok } from "neverthrow";
16
+ /**
17
+ * Map the resolved values of the blocks onto a `RenderValues` map.
18
+ *
19
+ * The bridge walks each resolution in order. A no-value kind adds no entry. A value-bearing kind whose
20
+ * resolved type matches adds one entry. A value-bearing kind whose resolved type does not match adds one
21
+ * mismatch. A run with a mismatch returns the mismatch list and no map. A run with no mismatch returns the
22
+ * complete map.
23
+ */
24
+ export function bridgeValues(resolutions, figureSrc) {
25
+ const values = {};
26
+ const mismatches = [];
27
+ for (const resolution of resolutions) {
28
+ switch (resolution.kind) {
29
+ case "text":
30
+ case "claim":
31
+ case "citation":
32
+ case "section":
33
+ break;
34
+ case "metric": {
35
+ const value = resolution.resolved;
36
+ if (value.type !== "scalar") {
37
+ mismatches.push(mismatch(resolution.blockId, "metric", "scalar", value.type));
38
+ break;
39
+ }
40
+ values[resolution.blockId] = { type: "scalar", value: value.value };
41
+ break;
42
+ }
43
+ case "table":
44
+ case "chart": {
45
+ const value = resolution.resolved;
46
+ if (value.type !== "table") {
47
+ mismatches.push(mismatch(resolution.blockId, resolution.kind, "table", value.type));
48
+ break;
49
+ }
50
+ values[resolution.blockId] = tableValue(value);
51
+ break;
52
+ }
53
+ case "figure": {
54
+ const value = resolution.resolved;
55
+ if (value.type !== "file") {
56
+ mismatches.push(mismatch(resolution.blockId, "figure", "file", value.type));
57
+ break;
58
+ }
59
+ values[resolution.blockId] = { type: "figure", src: figureSrc(value) };
60
+ break;
61
+ }
62
+ }
63
+ }
64
+ if (mismatches.length > 0) {
65
+ return err(mismatches);
66
+ }
67
+ return ok(values);
68
+ }
69
+ /**
70
+ * Map a resolved table onto a render table. The explicit column order carries through when it is present,
71
+ * and it stays absent otherwise, thus the renderer keeps its first-row order as the default.
72
+ */
73
+ function tableValue(value) {
74
+ if (value.columns !== undefined) {
75
+ return { type: "table", rows: value.rows, columns: value.columns };
76
+ }
77
+ return { type: "table", rows: value.rows };
78
+ }
79
+ /** Make one mismatch that names the block, its kind, the resolved type that it needs, and the type that arrived. */
80
+ function mismatch(blockId, blockKind, expected, actual) {
81
+ return { blockId, blockKind, expected, actual };
82
+ }
@@ -16,6 +16,7 @@
16
16
  */
17
17
  import { type Result } from "neverthrow";
18
18
  import { type ConversationAgentDeps } from "../agents/conversation-agent.js";
19
+ import { type ReportSessionRuntime } from "../app/report-session-runtime.js";
19
20
  import type { UsageRecorder } from "../billing/usage-recorder.js";
20
21
  import type { ResourcePolicy } from "../config/resource-limits.js";
21
22
  import type { AgentDefinition } from "../loop/types.js";
@@ -26,6 +27,7 @@ import { type ExecuteTargetAssessmentDeps, type ExecuteTargetAssessmentInput, ty
26
27
  import { type DataProfileDeps, type DataProfileWorkflowInput } from "../tasks/data-profile.js";
27
28
  import { type CitationResolverConfig } from "../citations/resolve.js";
28
29
  import type { CitationResolver } from "../citations/types.js";
30
+ import type { ReferenceResolver } from "../report-model/reference-resolver.js";
29
31
  /** Registered child sandbox-step callable the parent's child dispatch closes over. */
30
32
  export type SandboxStepCallable = (input: SandboxStepInput) => Promise<SandboxStepResult>;
31
33
  /**
@@ -78,6 +80,12 @@ export interface CoreRuntimeDeps {
78
80
  readonly usageRecorder?: UsageRecorder;
79
81
  /** Harness-owned citation capability configuration; no ambient lookup occurs. */
80
82
  readonly citationResolverConfig?: CitationResolverConfig;
83
+ /**
84
+ * Reference-resolution seam for the report preview. Absent by default, thus the
85
+ * preview tool degrades as data until an embedder wires a realization. The
86
+ * realization itself stays out of the harness.
87
+ */
88
+ readonly reportReferenceResolver?: ReferenceResolver;
81
89
  }
82
90
  /**
83
91
  * The refusal a thread type carries no agent yet. `report` is a valid
@@ -120,6 +128,13 @@ export interface CoreRuntime {
120
128
  * on the `Result` channel.
121
129
  */
122
130
  readonly agents: ThreadAgentResolver;
131
+ /**
132
+ * The turn-start anchor of a report session. A serving path runs it at the
133
+ * turn start, thus a report tool call never arrives before the session state
134
+ * exists. The gateway stays behind the tool boundary, thus the runtime exposes
135
+ * the anchor operation alone here, and a later change wires it at the turn start.
136
+ */
137
+ readonly reportSession: Pick<ReportSessionRuntime, "ensureSessionState">;
123
138
  readonly workflows: RegisteredWorkflows;
124
139
  readonly citationResolver: CitationResolver;
125
140
  }
@@ -16,6 +16,8 @@
16
16
  */
17
17
  import { err, ok } from "neverthrow";
18
18
  import { createConversationAgent } from "../agents/conversation-agent.js";
19
+ import { createReportSessionAgent } from "../agents/report-session-agent.js";
20
+ import { createReportSessionRuntime } from "../app/report-session-runtime.js";
19
21
  import { createNoopUsageRecorder } from "../billing/noop-usage-recorder.js";
20
22
  import { registerExecuteAnalysis } from "../workflows/execute-analysis.js";
21
23
  import { registerSandboxStep } from "../workflows/sandbox-step.js";
@@ -67,17 +69,37 @@ export function assembleCoreRuntime(deps) {
67
69
  usageRecorder,
68
70
  citationResolver,
69
71
  });
70
- // The single registration point for every typed agent. One entry today; a
71
- // future thread type's agent plugs in here at assembly with no embedder
72
- // change. `conversation` is required at the type level because boot resolves
73
- // it unconditionally (`boot.ts` backfill) — a dropped registration must fail
74
- // tsc here, not surface only as a boot-time throw. The `Partial` over the
75
- // rest keeps the compiler honest that `report` has no agent yet.
72
+ // The report agent is a singleton over the conversation deps. The session
73
+ // runtime binds the per-session state to the thread behind the tool boundary,
74
+ // thus one definition serves every report thread. Its preview tool writes the
75
+ // page into the analysis tree and returns the path, thus the page write reaches
76
+ // no host seam. The optional reference resolver is the one seam it can use, and
77
+ // it is absent by default.
78
+ const reportSession = createReportSessionRuntime({ pool: conversation.pool, ...(conversation.logger ? { logger: conversation.logger } : {}) });
79
+ const reportAgent = createReportSessionAgent({
80
+ model: conversation.model,
81
+ pool: conversation.pool,
82
+ embedding: conversation.embedding,
83
+ workspaceFs: conversation.workspaceFs,
84
+ gateway: reportSession.gateway,
85
+ resolveWorkspaceRoot: conversation.resolveWorkspaceRoot,
86
+ ...(deps.reportReferenceResolver ? { resolver: deps.reportReferenceResolver } : {}),
87
+ ...(conversation.logger ? { logger: conversation.logger } : {}),
88
+ });
89
+ // The single registration point for every typed agent. A future thread type's
90
+ // agent plugs in here at assembly with no embedder change. `conversation` is
91
+ // required at the type level because boot resolves it unconditionally
92
+ // (`boot.ts` backfill). `report` is required now that its agent registers here,
93
+ // so a dropped registration fails tsc, and it does not surface only as a
94
+ // resolution refusal. The `Partial` over the rest keeps the compiler honest
95
+ // about a type that has no agent yet.
76
96
  const agents = {
77
97
  conversation: conversationAgent,
98
+ report: reportAgent,
78
99
  };
79
100
  return {
80
101
  agents: createThreadAgentResolver(agents),
102
+ reportSession: { ensureSessionState: reportSession.ensureSessionState },
81
103
  workflows: {
82
104
  executeAnalysis,
83
105
  sandboxStep,
@@ -161,6 +161,29 @@ CREATE TABLE IF NOT EXISTS cortex_report_versions (
161
161
  CREATE INDEX IF NOT EXISTS idx_cortex_report_versions_analysis
162
162
  ON cortex_report_versions(analysis_id);
163
163
 
164
+ -- Report session state — one mutable row for each report session thread. The row
165
+ -- holds the in-progress draft document and the pinned snapshot of the session. The
166
+ -- thread id is the primary key, thus one session binds to one thread. The document
167
+ -- column and the snapshot column are each nullable, because the pin writes the
168
+ -- snapshot first and the document lands later. A purge of the analysis removes the
169
+ -- row through the analysis_id cascade. The explicit purge delete stays a backstop,
170
+ -- the same as the plans delete, because a database whose table predates the key
171
+ -- never acquires one. The thread id carries no foreign key, the same as the
172
+ -- messages rows, thus a purge of a thread cannot reach this row through a cascade
173
+ -- and purgeThread removes it by name.
174
+ CREATE TABLE IF NOT EXISTS cortex_report_session_state (
175
+ thread_id TEXT PRIMARY KEY,
176
+ analysis_id TEXT NOT NULL
177
+ REFERENCES cortex_analysis_state(analysis_id)
178
+ ON DELETE CASCADE,
179
+ document JSONB,
180
+ snapshot JSONB,
181
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
182
+ );
183
+
184
+ CREATE INDEX IF NOT EXISTS idx_cortex_report_session_state_analysis
185
+ ON cortex_report_session_state(analysis_id);
186
+
164
187
  -- Target assessments — organization-scoped, snapshot-style target dossiers.
165
188
  -- Independent of analyses, projects, runs, and chat threads. The full
166
189
  -- dossier is persisted as JSONB on the row, no separate artifacts table.
@@ -81,6 +81,7 @@ const ANALYSIS_KEYED_DELETES = [
81
81
  { op: "purgeAnalysis.askGrants", sql: "DELETE FROM cortex_ask_grants WHERE analysis_id = $1" },
82
82
  { op: "purgeAnalysis.plans", sql: "DELETE FROM cortex_plans WHERE analysis_id = $1" },
83
83
  { op: "purgeAnalysis.reportVersions", sql: "DELETE FROM cortex_report_versions WHERE analysis_id = $1" },
84
+ { op: "purgeAnalysis.reportSessionState", sql: "DELETE FROM cortex_report_session_state WHERE analysis_id = $1" },
84
85
  { op: "purgeAnalysis.analysisState", sql: "DELETE FROM cortex_analysis_state WHERE analysis_id = $1" },
85
86
  ];
86
87
  /**
@@ -0,0 +1,112 @@
1
+ /**
2
+ * The report session-state store -- the mutable working state of one report session.
3
+ *
4
+ * One row holds the state of one report session, keyed by the thread id. The row
5
+ * holds the in-progress draft document and the pinned snapshot. The row is mutable,
6
+ * and it differs from a recorded version this way. A session reads the state, writes
7
+ * the snapshot one time at the start, and persists the document as the composition
8
+ * proceeds.
9
+ *
10
+ * The snapshot writes at row creation. The write is insert-if-absent through
11
+ * ON CONFLICT DO NOTHING. Thus two concurrent first calls make one row, and the
12
+ * winner keeps its snapshot. The store reads the row back after the write, thus the
13
+ * caller sees the durable state whichever call won.
14
+ *
15
+ * The document column and the snapshot column are each nullable. A row exists with a
16
+ * snapshot and no document yet, because the pin writes the snapshot first and the
17
+ * document lands later. A null column reads as a null value.
18
+ *
19
+ * A read parses the stored document with the draft schema, and the stored snapshot
20
+ * with `parseSnapshot`. The document is the draft document, because an in-progress
21
+ * document can be incomplete. A column that fails a parse reads as a typed error, and
22
+ * it does not crash. An absent row reads as a normal absence.
23
+ *
24
+ * A persist is a compare-and-swap. The load reads the prior document, and the persist
25
+ * lands only when the row still holds it. Thus two concurrent turns cannot both land,
26
+ * and the loser reads the state again.
27
+ *
28
+ * A purge of the analysis removes the row through the analysis-id cascade.
29
+ */
30
+ import { type ResultAsync } from "neverthrow";
31
+ import type { Pool } from "pg";
32
+ import { type DbError } from "../lib/db-result.js";
33
+ import type { Logger } from "../lib/logger.js";
34
+ import { type DraftDocument } from "../report-model/draft.js";
35
+ import type { ReportSnapshot } from "../report-model/reference-resolver.js";
36
+ import { type SchemaIssue } from "./snapshot-parse.js";
37
+ /** The state of one report session, as the store gives it back. */
38
+ export interface ReportSessionState {
39
+ readonly threadId: string;
40
+ readonly analysisId: string;
41
+ /** The in-progress draft document, or `null` before the first document lands. */
42
+ readonly document: DraftDocument | null;
43
+ /** The pinned snapshot, or `null` before the pin writes it. */
44
+ readonly snapshot: ReportSnapshot | null;
45
+ readonly createdAt: Date;
46
+ }
47
+ /** The snapshot and the anchor of a row to create. */
48
+ export interface WriteSnapshotInput {
49
+ readonly threadId: string;
50
+ readonly analysisId: string;
51
+ /** The pinned snapshot, stored as given. */
52
+ readonly snapshot: ReportSnapshot;
53
+ }
54
+ /** The document to persist against an existing row, and the prior document that the load read. */
55
+ export interface PersistDocumentInput {
56
+ readonly threadId: string;
57
+ /** The draft document, stored as given. */
58
+ readonly document: DraftDocument;
59
+ /**
60
+ * The prior document that the load read, or `null` before the first document lands.
61
+ * The persist lands only when the row still holds it, thus a concurrent turn that
62
+ * landed first turns this persist into a conflict.
63
+ */
64
+ readonly expected: DraftDocument | null;
65
+ }
66
+ /**
67
+ * The outcome of a persist. `persisted` landed the document. `conflict` means the row
68
+ * no longer holds the expected prior document, thus a concurrent turn landed first and
69
+ * this turn must read the state again. `absent` means no row holds the thread.
70
+ */
71
+ export type PersistOutcome = "persisted" | "conflict" | "absent";
72
+ /**
73
+ * A stored row that a read cannot parse with the current schemas. It names the
74
+ * thread id and the part that failed, and it carries the reduced issues.
75
+ */
76
+ export type SessionStateReadError = {
77
+ readonly type: "corrupt_session_state";
78
+ readonly op: string;
79
+ readonly threadId: string;
80
+ readonly part: "document" | "snapshot";
81
+ readonly issues: readonly SchemaIssue[];
82
+ };
83
+ export interface ReportSessionStateStore {
84
+ /**
85
+ * The state of a thread, or `null` when no row holds it. A parse failure on a
86
+ * stored column reads as a typed error, and an absent row reads as a normal
87
+ * absence.
88
+ */
89
+ readState(threadId: string): ResultAsync<ReportSessionState | null, DbError | SessionStateReadError>;
90
+ /**
91
+ * Write the snapshot at row creation, insert-if-absent. Two concurrent first
92
+ * calls make one row through ON CONFLICT DO NOTHING, and the winner keeps its
93
+ * snapshot. The store reads the row back, thus the caller sees the durable state.
94
+ */
95
+ writeSnapshot(input: WriteSnapshotInput): ResultAsync<ReportSessionState, DbError | SessionStateReadError>;
96
+ /**
97
+ * Persist the document of a thread against the prior document that the load read.
98
+ * The row must exist, pinned by `writeSnapshot` first. The update lands only when
99
+ * the row still holds the prior document. `persisted` landed it, `conflict` means a
100
+ * concurrent turn landed first, and `absent` means no row holds the thread.
101
+ */
102
+ persistDocument(input: PersistDocumentInput): ResultAsync<PersistOutcome, DbError>;
103
+ }
104
+ export interface ReportSessionStateStoreDeps {
105
+ readonly pool: Pool;
106
+ readonly logger?: Logger;
107
+ }
108
+ /**
109
+ * Create a `ReportSessionStateStore` bound to a Postgres pool. The
110
+ * `cortex_report_session_state` table is provisioned by the state-init DDL.
111
+ */
112
+ export declare function createReportSessionStateStore({ pool }: ReportSessionStateStoreDeps): ReportSessionStateStore;
@@ -0,0 +1,150 @@
1
+ /**
2
+ * The report session-state store -- the mutable working state of one report session.
3
+ *
4
+ * One row holds the state of one report session, keyed by the thread id. The row
5
+ * holds the in-progress draft document and the pinned snapshot. The row is mutable,
6
+ * and it differs from a recorded version this way. A session reads the state, writes
7
+ * the snapshot one time at the start, and persists the document as the composition
8
+ * proceeds.
9
+ *
10
+ * The snapshot writes at row creation. The write is insert-if-absent through
11
+ * ON CONFLICT DO NOTHING. Thus two concurrent first calls make one row, and the
12
+ * winner keeps its snapshot. The store reads the row back after the write, thus the
13
+ * caller sees the durable state whichever call won.
14
+ *
15
+ * The document column and the snapshot column are each nullable. A row exists with a
16
+ * snapshot and no document yet, because the pin writes the snapshot first and the
17
+ * document lands later. A null column reads as a null value.
18
+ *
19
+ * A read parses the stored document with the draft schema, and the stored snapshot
20
+ * with `parseSnapshot`. The document is the draft document, because an in-progress
21
+ * document can be incomplete. A column that fails a parse reads as a typed error, and
22
+ * it does not crash. An absent row reads as a normal absence.
23
+ *
24
+ * A persist is a compare-and-swap. The load reads the prior document, and the persist
25
+ * lands only when the row still holds it. Thus two concurrent turns cannot both land,
26
+ * and the loser reads the state again.
27
+ *
28
+ * A purge of the analysis removes the row through the analysis-id cascade.
29
+ */
30
+ import { err, ok } from "neverthrow";
31
+ import { tryMutation, tryQuery } from "../lib/db-result.js";
32
+ import { DraftDocumentSchema } from "../report-model/draft.js";
33
+ import { parseSnapshot, reduceIssues } from "./snapshot-parse.js";
34
+ const _assertReadError = true;
35
+ /** The full row projection every read shares. */
36
+ const STATE_COLUMNS = "thread_id, analysis_id, document, snapshot, created_at";
37
+ /**
38
+ * The insert. The row carries the snapshot at creation, and the document lands later
39
+ * through `persistDocument`. ON CONFLICT DO NOTHING keeps the first row, thus a
40
+ * second first call cannot replace the snapshot.
41
+ */
42
+ const INSERT_SQL = `INSERT INTO cortex_report_session_state
43
+ (thread_id, analysis_id, snapshot)
44
+ VALUES ($1, $2, $3::jsonb)
45
+ ON CONFLICT (thread_id) DO NOTHING`;
46
+ function rowToState(row) {
47
+ let document = null;
48
+ if (row.document !== null) {
49
+ const parsed = DraftDocumentSchema.safeParse(row.document);
50
+ if (!parsed.success) {
51
+ return err({
52
+ type: "corrupt_session_state",
53
+ op: "report-session-state.read",
54
+ threadId: row.thread_id,
55
+ part: "document",
56
+ issues: reduceIssues(parsed.error),
57
+ });
58
+ }
59
+ document = parsed.data;
60
+ }
61
+ let snapshot = null;
62
+ if (row.snapshot !== null) {
63
+ const parsed = parseSnapshot(row.snapshot);
64
+ if (parsed.isErr()) {
65
+ return err({
66
+ type: "corrupt_session_state",
67
+ op: "report-session-state.read",
68
+ threadId: row.thread_id,
69
+ part: "snapshot",
70
+ issues: parsed.error,
71
+ });
72
+ }
73
+ snapshot = parsed.value;
74
+ }
75
+ return ok({
76
+ threadId: row.thread_id,
77
+ analysisId: row.analysis_id,
78
+ document,
79
+ snapshot,
80
+ createdAt: row.created_at,
81
+ });
82
+ }
83
+ /**
84
+ * Create a `ReportSessionStateStore` bound to a Postgres pool. The
85
+ * `cortex_report_session_state` table is provisioned by the state-init DDL.
86
+ */
87
+ export function createReportSessionStateStore({ pool }) {
88
+ function selectRow(op, threadId) {
89
+ return tryQuery(op, () => pool.query({
90
+ text: `SELECT ${STATE_COLUMNS} FROM cortex_report_session_state WHERE thread_id = $1`,
91
+ values: [threadId],
92
+ })).map(({ rows }) => rows[0] ?? null);
93
+ }
94
+ function readState(threadId) {
95
+ return selectRow("report-session-state.readState", threadId).andThen((row) => {
96
+ if (!row)
97
+ return ok(null);
98
+ return rowToState(row);
99
+ });
100
+ }
101
+ function writeSnapshot(input) {
102
+ return tryMutation("report-session-state.writeSnapshot", async () => {
103
+ await pool.query({
104
+ text: INSERT_SQL,
105
+ values: [input.threadId, input.analysisId, JSON.stringify(input.snapshot)],
106
+ });
107
+ })
108
+ .andThen(() => selectRow("report-session-state.writeSnapshot.readBack", input.threadId))
109
+ .andThen((row) => {
110
+ if (!row) {
111
+ // The insert-if-absent guarantees a row. Absence here is not a normal
112
+ // condition, thus it rides the error channel.
113
+ return err({
114
+ type: "mutation_failed",
115
+ op: "report-session-state.writeSnapshot.readBack",
116
+ cause: new Error("row absent after insert-if-absent"),
117
+ });
118
+ }
119
+ return rowToState(row);
120
+ });
121
+ }
122
+ function persistDocument(input) {
123
+ return tryMutation("report-session-state.persistDocument", async () => {
124
+ // The prior document rides as SQL NULL when absent, thus the compare-and-swap
125
+ // matches a fresh row whose document column is null. A JSON `null` would be a
126
+ // distinct jsonb value, and it would never match the null column.
127
+ const expected = input.expected === null ? null : JSON.stringify(input.expected);
128
+ // One statement tells a conflict from an absence. `existed` counts the row, and
129
+ // `updated` counts the compare-and-swap. A row that exists but did not update
130
+ // holds a different prior document, thus a concurrent turn landed first.
131
+ const { rows } = await pool.query({
132
+ text: `WITH target AS (
133
+ SELECT 1 FROM cortex_report_session_state WHERE thread_id = $1
134
+ ), updated AS (
135
+ UPDATE cortex_report_session_state
136
+ SET document = $2::jsonb
137
+ WHERE thread_id = $1 AND document IS NOT DISTINCT FROM $3::jsonb
138
+ RETURNING 1
139
+ )
140
+ SELECT (SELECT COUNT(*) FROM target)::int AS existed, (SELECT COUNT(*) FROM updated)::int AS updated`,
141
+ values: [input.threadId, JSON.stringify(input.document), expected],
142
+ });
143
+ const row = rows[0];
144
+ if (row === undefined || row.existed === 0)
145
+ return "absent";
146
+ return row.updated > 0 ? "persisted" : "conflict";
147
+ });
148
+ }
149
+ return { readState, writeSnapshot, persistDocument };
150
+ }
@@ -10,7 +10,7 @@
10
10
  * one thread trips the constraint, and the store maps that trip to the typed
11
11
  * refusal thread_already_holds_version.
12
12
  *
13
- * The store keeps the snapshot and the anchor as given, and it never mints a
13
+ * The store keeps the snapshot and the anchor as given, and it never pins a
14
14
  * snapshot. A read parses the stored document and the stored snapshot with the
15
15
  * current schemas. A row that fails a parse reads as a typed error, and it does
16
16
  * not crash. An absent row reads as a normal absence.
@@ -25,11 +25,8 @@ import { type ReportDocument } from "../contracts/report-blocks.js";
25
25
  import { type DbError } from "../lib/db-result.js";
26
26
  import type { Logger } from "../lib/logger.js";
27
27
  import type { ReportSnapshot } from "../report-model/reference-resolver.js";
28
- /** One reduced schema issue -- the dotted path and the message, without the rest. */
29
- export interface SchemaIssue {
30
- readonly path: string;
31
- readonly message: string;
32
- }
28
+ import { type SchemaIssue } from "./snapshot-parse.js";
29
+ export type { SchemaIssue };
33
30
  /** The anchor and the payload of a version to record. */
34
31
  export interface RecordVersionInput {
35
32
  /** The block document. It rides as `unknown`, and the record parses it. */