@inflexa-ai/harness 0.18.0 → 0.19.1

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 (83) hide show
  1. package/dist/agents/report-session-agent.d.ts +72 -0
  2. package/dist/agents/report-session-agent.js +87 -0
  3. package/dist/app/report-session-runtime.d.ts +72 -0
  4. package/dist/app/report-session-runtime.js +183 -0
  5. package/dist/app/spawn-report-session.d.ts +32 -5
  6. package/dist/app/spawn-report-session.js +19 -0
  7. package/dist/config/environment-stores.d.ts +12 -7
  8. package/dist/config/resource-limits.d.ts +6 -10
  9. package/dist/config/resource-limits.js +6 -44
  10. package/dist/contracts/report-reference.d.ts +4 -0
  11. package/dist/contracts/report-reference.js +3 -0
  12. package/dist/index.d.ts +2 -1
  13. package/dist/lib/chrome.d.ts +14 -0
  14. package/dist/lib/chrome.js +7 -0
  15. package/dist/lib/page-capture.d.ts +47 -0
  16. package/dist/lib/page-capture.js +83 -0
  17. package/dist/loop/run-agent.js +104 -15
  18. package/dist/memory/ai-sdk-message-storage.d.ts +9 -4
  19. package/dist/memory/ai-sdk-message-storage.js +9 -4
  20. package/dist/memory/count-tokens.js +34 -5
  21. package/dist/memory/thread-store.d.ts +4 -4
  22. package/dist/memory/thread-store.js +18 -9
  23. package/dist/prompts/report-session.d.ts +1 -0
  24. package/dist/prompts/report-session.js +110 -0
  25. package/dist/providers/ai-sdk.d.ts +158 -3
  26. package/dist/providers/ai-sdk.js +234 -23
  27. package/dist/providers/errors.d.ts +14 -0
  28. package/dist/providers/errors.js +103 -18
  29. package/dist/providers/types.d.ts +33 -0
  30. package/dist/providers/types.js +10 -1
  31. package/dist/report-model/assert-rules.d.ts +44 -0
  32. package/dist/report-model/assert-rules.js +97 -0
  33. package/dist/report-model/draft-hash.d.ts +14 -0
  34. package/dist/report-model/draft-hash.js +36 -0
  35. package/dist/report-model/fixture-resolver.js +2 -71
  36. package/dist/report-model/{mint-snapshot.d.ts → pin-snapshot.d.ts} +7 -7
  37. package/dist/report-model/{mint-snapshot.js → pin-snapshot.js} +7 -7
  38. package/dist/report-model/production-resolver.d.ts +124 -0
  39. package/dist/report-model/production-resolver.js +801 -0
  40. package/dist/report-model/reference-resolver.d.ts +7 -1
  41. package/dist/report-model/validate.d.ts +34 -2
  42. package/dist/report-model/validate.js +37 -17
  43. package/dist/report-render/page.d.ts +23 -0
  44. package/dist/report-render/page.js +36 -3
  45. package/dist/report-render/value-bridge.d.ts +62 -0
  46. package/dist/report-render/value-bridge.js +82 -0
  47. package/dist/runtime/assemble.d.ts +39 -0
  48. package/dist/runtime/assemble.js +67 -6
  49. package/dist/sandbox/mount-plan.d.ts +7 -0
  50. package/dist/sandbox/mount-plan.js +7 -2
  51. package/dist/state/init.js +36 -0
  52. package/dist/state/purge-analysis.js +1 -0
  53. package/dist/state/report-session-state.d.ts +139 -0
  54. package/dist/state/report-session-state.js +177 -0
  55. package/dist/state/report-versions.d.ts +3 -6
  56. package/dist/state/report-versions.js +11 -38
  57. package/dist/state/snapshot-parse.d.ts +34 -0
  58. package/dist/state/snapshot-parse.js +82 -0
  59. package/dist/tasks/extract-values-script.d.ts +64 -0
  60. package/dist/tasks/extract-values-script.js +180 -0
  61. package/dist/tasks/extract-values.d.ts +124 -0
  62. package/dist/tasks/extract-values.js +193 -0
  63. package/dist/tools/ad-hoc-router.d.ts +7 -2
  64. package/dist/tools/ad-hoc-router.js +2 -1
  65. package/dist/tools/define-tool.d.ts +29 -0
  66. package/dist/tools/define-tool.js +26 -0
  67. package/dist/tools/report/preview-snapshot.d.ts +1 -1
  68. package/dist/tools/report/preview-snapshot.js +11 -50
  69. package/dist/tools/report-authoring/authoring-tools.d.ts +181 -24
  70. package/dist/tools/report-authoring/authoring-tools.js +152 -44
  71. package/dist/tools/report-session/examine-page.d.ts +90 -0
  72. package/dist/tools/report-session/examine-page.js +136 -0
  73. package/dist/tools/report-session/index.d.ts +10 -0
  74. package/dist/tools/report-session/index.js +10 -0
  75. package/dist/tools/report-session/preview-report.d.ts +105 -0
  76. package/dist/tools/report-session/preview-report.js +269 -0
  77. package/dist/tools/report-session/record-version.d.ts +102 -0
  78. package/dist/tools/report-session/record-version.js +139 -0
  79. package/dist/tools/research/generate-plan.js +4 -2
  80. package/dist/tools/sandbox/list-available-packages.js +2 -1
  81. package/dist/tools/sandbox/list-available-refs.d.ts +5 -1
  82. package/dist/tools/sandbox/list-available-refs.js +13 -6
  83. package/package.json +10 -8
@@ -161,6 +161,37 @@ 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
+ --
175
+ -- The two hash columns are the look-before-record markers. rendered_document_hash
176
+ -- holds the hash of the draft that the last preview rendered. seen_document_hash
177
+ -- holds the hash that the last eyes capture saw, copied from the rendered hash.
178
+ -- Each is nullable, because a fresh session has run no preview and no eyes. The
179
+ -- record refuses unless the seen hash equals the hash of the current draft.
180
+ CREATE TABLE IF NOT EXISTS cortex_report_session_state (
181
+ thread_id TEXT PRIMARY KEY,
182
+ analysis_id TEXT NOT NULL
183
+ REFERENCES cortex_analysis_state(analysis_id)
184
+ ON DELETE CASCADE,
185
+ document JSONB,
186
+ snapshot JSONB,
187
+ rendered_document_hash TEXT,
188
+ seen_document_hash TEXT,
189
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
190
+ );
191
+
192
+ CREATE INDEX IF NOT EXISTS idx_cortex_report_session_state_analysis
193
+ ON cortex_report_session_state(analysis_id);
194
+
164
195
  -- Target assessments — organization-scoped, snapshot-style target dossiers.
165
196
  -- Independent of analyses, projects, runs, and chat threads. The full
166
197
  -- dossier is persisted as JSONB on the row, no separate artifacts table.
@@ -529,6 +560,11 @@ export async function initCortexState(pool, injected) {
529
560
  "ALTER TABLE cortex_step_executions ADD COLUMN IF NOT EXISTS child_workflow_id TEXT",
530
561
  "CREATE INDEX IF NOT EXISTS idx_cortex_step_exec_child_workflow " + "ON cortex_step_executions(child_workflow_id)",
531
562
  "ALTER TABLE cortex_analysis_state ADD COLUMN IF NOT EXISTS seed_input_file_ids JSONB",
563
+ // The look-before-record markers of a report session. Nullable and
564
+ // unbackfilled: a session with no preview and no eyes carries neither hash,
565
+ // which is the honest state, thus an existing row reads back as never-seen.
566
+ "ALTER TABLE cortex_report_session_state ADD COLUMN IF NOT EXISTS rendered_document_hash TEXT",
567
+ "ALTER TABLE cortex_report_session_state ADD COLUMN IF NOT EXISTS seen_document_hash TEXT",
532
568
  // Databases created before `data_profile_status` became nullable still
533
569
  // carry the NOT NULL floor, which would reject a clear. Idempotent:
534
570
  // dropping an absent NOT NULL no-ops, so this is a no-op on fresh DBs.
@@ -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,139 @@
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
+ /** The hash of the draft that the last preview rendered, or `null` before the first preview. */
46
+ readonly renderedDocumentHash: string | null;
47
+ /** The hash that the last eyes capture saw, or `null` before the first look. */
48
+ readonly seenDocumentHash: string | null;
49
+ readonly createdAt: Date;
50
+ }
51
+ /** The snapshot and the anchor of a row to create. */
52
+ export interface WriteSnapshotInput {
53
+ readonly threadId: string;
54
+ readonly analysisId: string;
55
+ /** The pinned snapshot, stored as given. */
56
+ readonly snapshot: ReportSnapshot;
57
+ }
58
+ /** The document to persist against an existing row, and the prior document that the load read. */
59
+ export interface PersistDocumentInput {
60
+ readonly threadId: string;
61
+ /** The draft document, stored as given. */
62
+ readonly document: DraftDocument;
63
+ /**
64
+ * The prior document that the load read, or `null` before the first document lands.
65
+ * The persist lands only when the row still holds it, thus a concurrent turn that
66
+ * landed first turns this persist into a conflict.
67
+ */
68
+ readonly expected: DraftDocument | null;
69
+ }
70
+ /**
71
+ * The outcome of a persist. `persisted` landed the document. `conflict` means the row
72
+ * no longer holds the expected prior document, thus a concurrent turn landed first and
73
+ * this turn must read the state again. `absent` means no row holds the thread.
74
+ */
75
+ export type PersistOutcome = "persisted" | "conflict" | "absent";
76
+ /**
77
+ * The outcome of a stamp. `stamped` wrote the hash on the row. `absent` means that no row holds the
78
+ * thread, thus the stamp wrote nothing. A stamp reads nothing before the write.
79
+ */
80
+ export type StampOutcome = "stamped" | "absent";
81
+ /**
82
+ * The outcome of the seen stamp. `stamped` copied a rendered hash onto the seen hash. `no-rendered`
83
+ * means that the row holds no rendered hash, thus no preview stamped one and the copy found none.
84
+ * `absent` means that no row holds the thread.
85
+ */
86
+ export type SeenStampOutcome = "stamped" | "no-rendered" | "absent";
87
+ /**
88
+ * A stored row that a read cannot parse with the current schemas. It names the
89
+ * thread id and the part that failed, and it carries the reduced issues.
90
+ */
91
+ export type SessionStateReadError = {
92
+ readonly type: "corrupt_session_state";
93
+ readonly op: string;
94
+ readonly threadId: string;
95
+ readonly part: "document" | "snapshot";
96
+ readonly issues: readonly SchemaIssue[];
97
+ };
98
+ export interface ReportSessionStateStore {
99
+ /**
100
+ * The state of a thread, or `null` when no row holds it. A parse failure on a
101
+ * stored column reads as a typed error, and an absent row reads as a normal
102
+ * absence.
103
+ */
104
+ readState(threadId: string): ResultAsync<ReportSessionState | null, DbError | SessionStateReadError>;
105
+ /**
106
+ * Write the snapshot at row creation, insert-if-absent. Two concurrent first
107
+ * calls make one row through ON CONFLICT DO NOTHING, and the winner keeps its
108
+ * snapshot. The store reads the row back, thus the caller sees the durable state.
109
+ */
110
+ writeSnapshot(input: WriteSnapshotInput): ResultAsync<ReportSessionState, DbError | SessionStateReadError>;
111
+ /**
112
+ * Persist the document of a thread against the prior document that the load read.
113
+ * The row must exist, pinned by `writeSnapshot` first. The update lands only when
114
+ * the row still holds the prior document. `persisted` landed it, `conflict` means a
115
+ * concurrent turn landed first, and `absent` means no row holds the thread.
116
+ */
117
+ persistDocument(input: PersistDocumentInput): ResultAsync<PersistOutcome, DbError>;
118
+ /**
119
+ * Stamp the rendered-document hash on the row. The preview calls it when the page lands, thus the row
120
+ * holds the hash of the draft that the page shows. `absent` means that no row holds the thread.
121
+ */
122
+ stampRendered(threadId: string, hash: string): ResultAsync<StampOutcome, DbError>;
123
+ /**
124
+ * Copy the rendered hash onto the seen hash. The eyes call it after a capture, thus the seen hash holds
125
+ * the hash of the draft that the picture shows, and never the current one. The operation reports whether
126
+ * a rendered hash existed to copy. `no-rendered` means that the row holds none, thus no preview stamped
127
+ * one. `absent` means that no row holds the thread.
128
+ */
129
+ stampSeen(threadId: string): ResultAsync<SeenStampOutcome, DbError>;
130
+ }
131
+ export interface ReportSessionStateStoreDeps {
132
+ readonly pool: Pool;
133
+ readonly logger?: Logger;
134
+ }
135
+ /**
136
+ * Create a `ReportSessionStateStore` bound to a Postgres pool. The
137
+ * `cortex_report_session_state` table is provisioned by the state-init DDL.
138
+ */
139
+ export declare function createReportSessionStateStore({ pool }: ReportSessionStateStoreDeps): ReportSessionStateStore;
@@ -0,0 +1,177 @@
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, rendered_document_hash, seen_document_hash, 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
+ renderedDocumentHash: row.rendered_document_hash,
81
+ seenDocumentHash: row.seen_document_hash,
82
+ createdAt: row.created_at,
83
+ });
84
+ }
85
+ /**
86
+ * Create a `ReportSessionStateStore` bound to a Postgres pool. The
87
+ * `cortex_report_session_state` table is provisioned by the state-init DDL.
88
+ */
89
+ export function createReportSessionStateStore({ pool }) {
90
+ function selectRow(op, threadId) {
91
+ return tryQuery(op, () => pool.query({
92
+ text: `SELECT ${STATE_COLUMNS} FROM cortex_report_session_state WHERE thread_id = $1`,
93
+ values: [threadId],
94
+ })).map(({ rows }) => rows[0] ?? null);
95
+ }
96
+ function readState(threadId) {
97
+ return selectRow("report-session-state.readState", threadId).andThen((row) => {
98
+ if (!row)
99
+ return ok(null);
100
+ return rowToState(row);
101
+ });
102
+ }
103
+ function writeSnapshot(input) {
104
+ return tryMutation("report-session-state.writeSnapshot", async () => {
105
+ await pool.query({
106
+ text: INSERT_SQL,
107
+ values: [input.threadId, input.analysisId, JSON.stringify(input.snapshot)],
108
+ });
109
+ })
110
+ .andThen(() => selectRow("report-session-state.writeSnapshot.readBack", input.threadId))
111
+ .andThen((row) => {
112
+ if (!row) {
113
+ // The insert-if-absent guarantees a row. Absence here is not a normal
114
+ // condition, thus it rides the error channel.
115
+ return err({
116
+ type: "mutation_failed",
117
+ op: "report-session-state.writeSnapshot.readBack",
118
+ cause: new Error("row absent after insert-if-absent"),
119
+ });
120
+ }
121
+ return rowToState(row);
122
+ });
123
+ }
124
+ function persistDocument(input) {
125
+ return tryMutation("report-session-state.persistDocument", async () => {
126
+ // The prior document rides as SQL NULL when absent, thus the compare-and-swap
127
+ // matches a fresh row whose document column is null. A JSON `null` would be a
128
+ // distinct jsonb value, and it would never match the null column.
129
+ const expected = input.expected === null ? null : JSON.stringify(input.expected);
130
+ // One statement tells a conflict from an absence. `existed` counts the row, and
131
+ // `updated` counts the compare-and-swap. A row that exists but did not update
132
+ // holds a different prior document, thus a concurrent turn landed first.
133
+ const { rows } = await pool.query({
134
+ text: `WITH target AS (
135
+ SELECT 1 FROM cortex_report_session_state WHERE thread_id = $1
136
+ ), updated AS (
137
+ UPDATE cortex_report_session_state
138
+ SET document = $2::jsonb
139
+ WHERE thread_id = $1 AND document IS NOT DISTINCT FROM $3::jsonb
140
+ RETURNING 1
141
+ )
142
+ SELECT (SELECT COUNT(*) FROM target)::int AS existed, (SELECT COUNT(*) FROM updated)::int AS updated`,
143
+ values: [input.threadId, JSON.stringify(input.document), expected],
144
+ });
145
+ const row = rows[0];
146
+ if (row === undefined || row.existed === 0)
147
+ return "absent";
148
+ return row.updated > 0 ? "persisted" : "conflict";
149
+ });
150
+ }
151
+ function stampRendered(threadId, hash) {
152
+ return tryMutation("report-session-state.stampRendered", async () => {
153
+ const { rowCount } = await pool.query({
154
+ text: "UPDATE cortex_report_session_state SET rendered_document_hash = $2 WHERE thread_id = $1",
155
+ values: [threadId, hash],
156
+ });
157
+ return (rowCount ?? 0) > 0 ? "stamped" : "absent";
158
+ });
159
+ }
160
+ function stampSeen(threadId) {
161
+ return tryMutation("report-session-state.stampSeen", async () => {
162
+ // The seen hash takes the rendered hash of the same row. A copy inside the statement keeps the
163
+ // two markers on one row, thus the eyes copy the rendered hash and never the current one. The
164
+ // `RETURNING` clause reads the copied rendered hash back, thus the caller tells a real stamp from
165
+ // a row that holds no rendered hash to copy.
166
+ const { rows } = await pool.query({
167
+ text: "UPDATE cortex_report_session_state SET seen_document_hash = rendered_document_hash WHERE thread_id = $1 RETURNING rendered_document_hash AS copied",
168
+ values: [threadId],
169
+ });
170
+ const row = rows[0];
171
+ if (row === undefined)
172
+ return "absent";
173
+ return row.copied === null ? "no-rendered" : "stamped";
174
+ });
175
+ }
176
+ return { readState, writeSnapshot, persistDocument, stampRendered, stampSeen };
177
+ }
@@ -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. */
@@ -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.
@@ -21,42 +21,15 @@
21
21
  */
22
22
  import { randomUUID } from "node:crypto";
23
23
  import { err, errAsync, ok } from "neverthrow";
24
- import { z } from "zod";
25
24
  import { ReportDocumentSchema } from "../contracts/report-blocks.js";
26
25
  import { tryMutation, tryQuery } from "../lib/db-result.js";
26
+ import { parseSnapshot, reduceIssues } from "./snapshot-parse.js";
27
27
  /**
28
28
  * The name of the unique constraint on thread_id. Postgres reports it on a
29
29
  * duplicate-thread refusal, and the record matches it to map the trip to the
30
30
  * thread_already_holds_version refusal.
31
31
  */
32
32
  const THREAD_UNIQUE_CONSTRAINT = "cortex_report_versions_one_per_thread";
33
- /**
34
- * One pinned artifact of a stored snapshot. The value carries the content hash,
35
- * an optional file type, and optional rows. The `z.ZodType<ReportSnapshot>`
36
- * annotation below keeps this schema and the `ArtifactSnapshot` type of the
37
- * reference model in step, thus a change to one shows as a compile error there.
38
- */
39
- const ArtifactSnapshotSchema = z.object({
40
- hash: z.string(),
41
- fileType: z.string().nullable().optional(),
42
- rows: z.array(z.record(z.string(), z.union([z.string(), z.number()]))).optional(),
43
- });
44
- /**
45
- * The stored-snapshot schema. The annotation ties the parse output to the
46
- * `ReportSnapshot` type of the reference model, thus the store and the structural
47
- * validation never disagree about the snapshot shape. The mint never fills `rows`,
48
- * but the schema admits what the type admits.
49
- */
50
- const ReportSnapshotSchema = z.object({
51
- artifacts: z.record(z.string(), ArtifactSnapshotSchema),
52
- citations: z.array(z.string()).optional(),
53
- });
54
- function reduceIssues(error) {
55
- return error.issues.map((issue) => ({
56
- path: issue.path.map((segment) => String(segment)).join("."),
57
- message: issue.message,
58
- }));
59
- }
60
33
  const _assertRecordError = true;
61
34
  const _assertReadError = true;
62
35
  /**
@@ -78,9 +51,9 @@ function rowToVersion(row) {
78
51
  if (!document.success) {
79
52
  return err({ type: "corrupt_version", op: "report-versions.read", versionId: row.version_id, part: "document", issues: reduceIssues(document.error) });
80
53
  }
81
- const snapshot = ReportSnapshotSchema.safeParse(row.snapshot);
82
- if (!snapshot.success) {
83
- return err({ type: "corrupt_version", op: "report-versions.read", versionId: row.version_id, part: "snapshot", issues: reduceIssues(snapshot.error) });
54
+ const snapshot = parseSnapshot(row.snapshot);
55
+ if (snapshot.isErr()) {
56
+ return err({ type: "corrupt_version", op: "report-versions.read", versionId: row.version_id, part: "snapshot", issues: snapshot.error });
84
57
  }
85
58
  return ok({
86
59
  versionId: row.version_id,
@@ -90,7 +63,7 @@ function rowToVersion(row) {
90
63
  parentSeq: row.parent_seq === null ? null : Number(row.parent_seq),
91
64
  parentVersionId: row.parent_version_id,
92
65
  document: document.data,
93
- snapshot: snapshot.data,
66
+ snapshot: snapshot.value,
94
67
  createdAt: row.created_at,
95
68
  });
96
69
  }
@@ -126,11 +99,11 @@ export function createReportVersionStore({ pool }) {
126
99
  return errAsync({ type: "malformed_document", op: "report-versions.record", issues: reduceIssues(parsed.error) });
127
100
  }
128
101
  const document = parsed.data;
129
- // The parse guards the row against a caller bug through a cast. The store
130
- // stores the given value, thus the parse changes no value.
131
- const parsedSnapshot = ReportSnapshotSchema.safeParse(input.snapshot);
132
- if (!parsedSnapshot.success) {
133
- return errAsync({ type: "malformed_snapshot", op: "report-versions.record", issues: reduceIssues(parsedSnapshot.error) });
102
+ // The parse guards the row against a caller bug. The store stores the given
103
+ // value, thus the parse changes no value.
104
+ const parsedSnapshot = parseSnapshot(input.snapshot);
105
+ if (parsedSnapshot.isErr()) {
106
+ return errAsync({ type: "malformed_snapshot", op: "report-versions.record", issues: parsedSnapshot.error });
134
107
  }
135
108
  const insert = () => {
136
109
  const versionId = randomUUID();
@@ -0,0 +1,34 @@
1
+ /**
2
+ * The one parse of a stored report snapshot, shared by the two stores.
3
+ *
4
+ * The session-state store and the version store each hold a snapshot column, and
5
+ * each reads it back the same way. A path such as `__proto__` is an ordinary
6
+ * artifact key of the ledger, and the pin keeps it as an own member of a
7
+ * null-prototype map (`report-model/pin-snapshot.ts`). Each object-shaped schema
8
+ * of zod drops such a key, thus a store that parsed the map through one would
9
+ * resolve a reference to that artifact before a reload and refuse it as absent
10
+ * after one. The walk reads the own keys of the stored map, and it parses each
11
+ * value on its own, thus the two stores agree on the key set.
12
+ */
13
+ import { type Result } from "neverthrow";
14
+ import { z } from "zod";
15
+ import type { ReportSnapshot } from "../report-model/reference-resolver.js";
16
+ /** One reduced schema issue -- the dotted path and the message, without the rest. */
17
+ export interface SchemaIssue {
18
+ readonly path: string;
19
+ readonly message: string;
20
+ }
21
+ /** Reduce a zod error to the dotted path and the message of each issue. */
22
+ export declare function reduceIssues(error: z.ZodError): SchemaIssue[];
23
+ /**
24
+ * Parse a stored snapshot.
25
+ *
26
+ * The artifacts map never goes through a zod object schema, for the reason that
27
+ * the module header gives: a zod object drops a `__proto__` key and the pin keeps
28
+ * it. The walk reads the own keys of the stored map, and it parses each value on
29
+ * its own.
30
+ *
31
+ * The `ReportSnapshot` return type ties this parse to the type of the reference
32
+ * model, thus a change to one shows as a compile error here.
33
+ */
34
+ export declare function parseSnapshot(stored: unknown): Result<ReportSnapshot, SchemaIssue[]>;
@@ -0,0 +1,82 @@
1
+ /**
2
+ * The one parse of a stored report snapshot, shared by the two stores.
3
+ *
4
+ * The session-state store and the version store each hold a snapshot column, and
5
+ * each reads it back the same way. A path such as `__proto__` is an ordinary
6
+ * artifact key of the ledger, and the pin keeps it as an own member of a
7
+ * null-prototype map (`report-model/pin-snapshot.ts`). Each object-shaped schema
8
+ * of zod drops such a key, thus a store that parsed the map through one would
9
+ * resolve a reference to that artifact before a reload and refuse it as absent
10
+ * after one. The walk reads the own keys of the stored map, and it parses each
11
+ * value on its own, thus the two stores agree on the key set.
12
+ */
13
+ import { err, ok } from "neverthrow";
14
+ import { z } from "zod";
15
+ /**
16
+ * One pinned artifact of a stored snapshot. The value carries the content hash, an
17
+ * optional file type, and optional rows.
18
+ */
19
+ const ArtifactSnapshotSchema = z.object({
20
+ hash: z.string(),
21
+ fileType: z.string().nullable().optional(),
22
+ rows: z.array(z.record(z.string(), z.union([z.string(), z.number()]))).optional(),
23
+ });
24
+ /** The citation list of a stored snapshot. A snapshot with no list reads as an absent field. */
25
+ const CitationsSchema = z.array(z.string()).optional();
26
+ /** Reduce a zod error to the dotted path and the message of each issue. */
27
+ export function reduceIssues(error) {
28
+ return error.issues.map((issue) => ({
29
+ path: issue.path.map((segment) => String(segment)).join("."),
30
+ message: issue.message,
31
+ }));
32
+ }
33
+ /** Put a prefix before the path of each issue, thus a nested parse names the key that failed. */
34
+ function prefixIssues(prefix, issues) {
35
+ return issues.map((issue) => ({
36
+ path: issue.path === "" ? prefix : `${prefix}.${issue.path}`,
37
+ message: issue.message,
38
+ }));
39
+ }
40
+ /** A stored map is a plain object. An array and a null carry no own key that a map read can use. */
41
+ function isPlainRecord(value) {
42
+ return typeof value === "object" && value !== null && !Array.isArray(value);
43
+ }
44
+ /**
45
+ * Parse a stored snapshot.
46
+ *
47
+ * The artifacts map never goes through a zod object schema, for the reason that
48
+ * the module header gives: a zod object drops a `__proto__` key and the pin keeps
49
+ * it. The walk reads the own keys of the stored map, and it parses each value on
50
+ * its own.
51
+ *
52
+ * The `ReportSnapshot` return type ties this parse to the type of the reference
53
+ * model, thus a change to one shows as a compile error here.
54
+ */
55
+ export function parseSnapshot(stored) {
56
+ if (!isPlainRecord(stored)) {
57
+ return err([{ path: "", message: "the stored snapshot is not an object" }]);
58
+ }
59
+ if (!isPlainRecord(stored.artifacts)) {
60
+ return err([{ path: "artifacts", message: "the artifacts map is not an object" }]);
61
+ }
62
+ const citations = CitationsSchema.safeParse(stored.citations);
63
+ if (!citations.success) {
64
+ return err(prefixIssues("citations", reduceIssues(citations.error)));
65
+ }
66
+ // The map takes a null prototype, the same as the pin, thus a path such as
67
+ // `__proto__` stays an ordinary entry and never reaches a prototype slot.
68
+ const artifacts = Object.create(null);
69
+ const issues = [];
70
+ for (const [path, value] of Object.entries(stored.artifacts)) {
71
+ const parsed = ArtifactSnapshotSchema.safeParse(value);
72
+ if (!parsed.success) {
73
+ issues.push(...prefixIssues(`artifacts.${path}`, reduceIssues(parsed.error)));
74
+ continue;
75
+ }
76
+ artifacts[path] = parsed.data;
77
+ }
78
+ if (issues.length > 0) {
79
+ return err(issues);
80
+ }
81
+ return citations.data === undefined ? ok({ artifacts }) : ok({ artifacts, citations: citations.data });
82
+ }