@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.
- package/dist/agents/report-session-agent.d.ts +72 -0
- package/dist/agents/report-session-agent.js +87 -0
- package/dist/app/report-session-runtime.d.ts +72 -0
- package/dist/app/report-session-runtime.js +183 -0
- package/dist/app/spawn-report-session.d.ts +32 -5
- package/dist/app/spawn-report-session.js +19 -0
- package/dist/config/environment-stores.d.ts +12 -7
- package/dist/config/resource-limits.d.ts +6 -10
- package/dist/config/resource-limits.js +6 -44
- package/dist/contracts/report-reference.d.ts +4 -0
- package/dist/contracts/report-reference.js +3 -0
- package/dist/index.d.ts +2 -1
- package/dist/lib/chrome.d.ts +14 -0
- package/dist/lib/chrome.js +7 -0
- package/dist/lib/page-capture.d.ts +47 -0
- package/dist/lib/page-capture.js +83 -0
- package/dist/loop/run-agent.js +104 -15
- package/dist/memory/ai-sdk-message-storage.d.ts +9 -4
- package/dist/memory/ai-sdk-message-storage.js +9 -4
- package/dist/memory/count-tokens.js +34 -5
- package/dist/memory/thread-store.d.ts +4 -4
- package/dist/memory/thread-store.js +18 -9
- package/dist/prompts/report-session.d.ts +1 -0
- package/dist/prompts/report-session.js +110 -0
- package/dist/providers/ai-sdk.d.ts +158 -3
- package/dist/providers/ai-sdk.js +234 -23
- package/dist/providers/errors.d.ts +14 -0
- package/dist/providers/errors.js +103 -18
- package/dist/providers/types.d.ts +33 -0
- package/dist/providers/types.js +10 -1
- package/dist/report-model/assert-rules.d.ts +44 -0
- package/dist/report-model/assert-rules.js +97 -0
- package/dist/report-model/draft-hash.d.ts +14 -0
- package/dist/report-model/draft-hash.js +36 -0
- package/dist/report-model/fixture-resolver.js +2 -71
- package/dist/report-model/{mint-snapshot.d.ts → pin-snapshot.d.ts} +7 -7
- package/dist/report-model/{mint-snapshot.js → pin-snapshot.js} +7 -7
- package/dist/report-model/production-resolver.d.ts +124 -0
- package/dist/report-model/production-resolver.js +801 -0
- package/dist/report-model/reference-resolver.d.ts +7 -1
- package/dist/report-model/validate.d.ts +34 -2
- package/dist/report-model/validate.js +37 -17
- package/dist/report-render/page.d.ts +23 -0
- package/dist/report-render/page.js +36 -3
- package/dist/report-render/value-bridge.d.ts +62 -0
- package/dist/report-render/value-bridge.js +82 -0
- package/dist/runtime/assemble.d.ts +39 -0
- package/dist/runtime/assemble.js +67 -6
- package/dist/sandbox/mount-plan.d.ts +7 -0
- package/dist/sandbox/mount-plan.js +7 -2
- package/dist/state/init.js +36 -0
- package/dist/state/purge-analysis.js +1 -0
- package/dist/state/report-session-state.d.ts +139 -0
- package/dist/state/report-session-state.js +177 -0
- package/dist/state/report-versions.d.ts +3 -6
- package/dist/state/report-versions.js +11 -38
- package/dist/state/snapshot-parse.d.ts +34 -0
- package/dist/state/snapshot-parse.js +82 -0
- package/dist/tasks/extract-values-script.d.ts +64 -0
- package/dist/tasks/extract-values-script.js +180 -0
- package/dist/tasks/extract-values.d.ts +124 -0
- package/dist/tasks/extract-values.js +193 -0
- package/dist/tools/ad-hoc-router.d.ts +7 -2
- package/dist/tools/ad-hoc-router.js +2 -1
- package/dist/tools/define-tool.d.ts +29 -0
- package/dist/tools/define-tool.js +26 -0
- package/dist/tools/report/preview-snapshot.d.ts +1 -1
- package/dist/tools/report/preview-snapshot.js +11 -50
- package/dist/tools/report-authoring/authoring-tools.d.ts +181 -24
- package/dist/tools/report-authoring/authoring-tools.js +152 -44
- package/dist/tools/report-session/examine-page.d.ts +90 -0
- package/dist/tools/report-session/examine-page.js +136 -0
- package/dist/tools/report-session/index.d.ts +10 -0
- package/dist/tools/report-session/index.js +10 -0
- package/dist/tools/report-session/preview-report.d.ts +105 -0
- package/dist/tools/report-session/preview-report.js +269 -0
- package/dist/tools/report-session/record-version.d.ts +102 -0
- package/dist/tools/report-session/record-version.js +139 -0
- package/dist/tools/research/generate-plan.js +4 -2
- package/dist/tools/sandbox/list-available-packages.js +2 -1
- package/dist/tools/sandbox/list-available-refs.d.ts +5 -1
- package/dist/tools/sandbox/list-available-refs.js +13 -6
- package/package.json +10 -8
package/dist/state/init.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
29
|
-
export
|
|
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
|
|
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 =
|
|
82
|
-
if (
|
|
83
|
-
return err({ type: "corrupt_version", op: "report-versions.read", versionId: row.version_id, part: "snapshot", issues:
|
|
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.
|
|
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
|
|
130
|
-
//
|
|
131
|
-
const parsedSnapshot =
|
|
132
|
-
if (
|
|
133
|
-
return errAsync({ type: "malformed_snapshot", op: "report-versions.record", issues:
|
|
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
|
+
}
|