@inflexa-ai/harness 0.19.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 (64) hide show
  1. package/dist/agents/report-session-agent.d.ts +25 -9
  2. package/dist/agents/report-session-agent.js +21 -6
  3. package/dist/app/report-session-runtime.js +26 -0
  4. package/dist/app/spawn-report-session.d.ts +32 -5
  5. package/dist/app/spawn-report-session.js +19 -0
  6. package/dist/config/environment-stores.d.ts +12 -7
  7. package/dist/config/resource-limits.d.ts +6 -10
  8. package/dist/config/resource-limits.js +6 -44
  9. package/dist/contracts/report-reference.d.ts +4 -0
  10. package/dist/contracts/report-reference.js +3 -0
  11. package/dist/index.d.ts +2 -1
  12. package/dist/lib/chrome.d.ts +14 -0
  13. package/dist/lib/chrome.js +7 -0
  14. package/dist/lib/page-capture.d.ts +47 -0
  15. package/dist/lib/page-capture.js +83 -0
  16. package/dist/loop/run-agent.js +104 -15
  17. package/dist/memory/ai-sdk-message-storage.d.ts +9 -4
  18. package/dist/memory/ai-sdk-message-storage.js +9 -4
  19. package/dist/memory/count-tokens.js +34 -5
  20. package/dist/prompts/report-session.d.ts +1 -1
  21. package/dist/prompts/report-session.js +21 -0
  22. package/dist/providers/ai-sdk.d.ts +81 -3
  23. package/dist/providers/ai-sdk.js +91 -10
  24. package/dist/providers/types.d.ts +17 -0
  25. package/dist/report-model/assert-rules.d.ts +44 -0
  26. package/dist/report-model/assert-rules.js +97 -0
  27. package/dist/report-model/draft-hash.d.ts +14 -0
  28. package/dist/report-model/draft-hash.js +36 -0
  29. package/dist/report-model/fixture-resolver.js +2 -71
  30. package/dist/report-model/production-resolver.d.ts +124 -0
  31. package/dist/report-model/production-resolver.js +801 -0
  32. package/dist/report-model/reference-resolver.d.ts +6 -0
  33. package/dist/report-model/validate.js +5 -0
  34. package/dist/report-render/page.d.ts +23 -0
  35. package/dist/report-render/page.js +36 -3
  36. package/dist/runtime/assemble.d.ts +28 -4
  37. package/dist/runtime/assemble.js +42 -3
  38. package/dist/sandbox/mount-plan.d.ts +7 -0
  39. package/dist/sandbox/mount-plan.js +7 -2
  40. package/dist/state/init.js +20 -7
  41. package/dist/state/report-session-state.d.ts +27 -0
  42. package/dist/state/report-session-state.js +29 -2
  43. package/dist/tasks/extract-values-script.d.ts +64 -0
  44. package/dist/tasks/extract-values-script.js +180 -0
  45. package/dist/tasks/extract-values.d.ts +124 -0
  46. package/dist/tasks/extract-values.js +193 -0
  47. package/dist/tools/define-tool.d.ts +29 -0
  48. package/dist/tools/define-tool.js +26 -0
  49. package/dist/tools/report/preview-snapshot.d.ts +1 -1
  50. package/dist/tools/report/preview-snapshot.js +11 -50
  51. package/dist/tools/report-authoring/authoring-tools.d.ts +54 -4
  52. package/dist/tools/report-authoring/authoring-tools.js +1 -1
  53. package/dist/tools/report-session/examine-page.d.ts +90 -0
  54. package/dist/tools/report-session/examine-page.js +136 -0
  55. package/dist/tools/report-session/index.d.ts +2 -0
  56. package/dist/tools/report-session/index.js +2 -0
  57. package/dist/tools/report-session/preview-report.d.ts +20 -5
  58. package/dist/tools/report-session/preview-report.js +28 -4
  59. package/dist/tools/report-session/record-version.d.ts +102 -0
  60. package/dist/tools/report-session/record-version.js +139 -0
  61. package/dist/tools/sandbox/list-available-packages.js +2 -1
  62. package/dist/tools/sandbox/list-available-refs.d.ts +5 -1
  63. package/dist/tools/sandbox/list-available-refs.js +13 -6
  64. package/package.json +10 -8
@@ -71,9 +71,15 @@ export type ResolvedValue = {
71
71
  * The `Ok` channel carries the resolved value. The `Err` channel carries the `UnresolvedReference`,
72
72
  * which names the reason and the detail that a reference did not bind. The validator collects each such
73
73
  * `Err` into its report, thus a caller reads the reason as data and resolution never throws.
74
+ *
75
+ * `prepare` is an optional batch step that runs one time before the per-reference loop. A realization
76
+ * that reads storage groups the references by artifact, reads each file one time, and fills a cache.
77
+ * `resolve` then answers from the cache. The method is optional, thus a realization without it keeps the
78
+ * per-reference behavior and the extension breaks no caller.
74
79
  */
75
80
  export interface ReferenceResolver {
76
81
  resolve(reference: Reference, snapshot: ReportSnapshot): Promise<Result<ResolvedValue, UnresolvedReference>>;
82
+ prepare?(references: readonly Reference[], snapshot: ReportSnapshot): Promise<void>;
77
83
  }
78
84
  /**
79
85
  * Give back each name in `columns` that no row holds, in the order of `columns`.
@@ -54,6 +54,11 @@ export function checkChartEncoding(entry, value) {
54
54
  */
55
55
  export async function resolveDocumentReferences(sections, snapshot, resolver) {
56
56
  const { references } = walkBlocks(sections);
57
+ // A realization that reads storage batches its reads here, one time before the loop. It fills a cache
58
+ // that each `resolve` then answers from. A realization without `prepare` keeps the per-reference read.
59
+ if (resolver.prepare !== undefined) {
60
+ await resolver.prepare(references.map((entry) => entry.reference), snapshot);
61
+ }
57
62
  const resolved = await allWithConcurrency(references.map((entry) => () => resolver.resolve(entry.reference, snapshot).then((result) => ({ entry, result }))), RESOLUTION_CONCURRENCY);
58
63
  const failures = [];
59
64
  const resolvedByBlock = new Map();
@@ -118,10 +118,33 @@ export declare const ECHARTS_THEME: {
118
118
  };
119
119
  };
120
120
  };
121
+ /**
122
+ * The name of the readiness event, and the name of the window sentinel that guards a late listener. The
123
+ * bootstrap below emits both names, thus this module owns them. A capture that waits for the page reads the
124
+ * same two constants, thus the emitter and the waiter cannot disagree over a rename.
125
+ */
126
+ export declare const THEME_READY_EVENT = "inflexa-theme-ready";
127
+ export declare const THEME_READY_SENTINEL = "__inflexaThemeReady";
128
+ /**
129
+ * The navigation budget and the readiness budget of one page capture, in milliseconds. The readiness budget
130
+ * bounds the wait for the event that the bootstrap dispatches. Every capture of this page reads the same two
131
+ * budgets, thus two capture sites cannot drift apart.
132
+ */
133
+ export declare const PAGE_NAV_TIMEOUT_MS = 20000;
134
+ export declare const PAGE_READY_TIMEOUT_MS = 8000;
121
135
  /**
122
136
  * The page-side script that wires each chart. It finds every chart container, reads the option JSON from
123
137
  * the sibling `<script type="application/json">` element, and initializes ECharts with the registered
124
138
  * theme. The skeleton registers the theme before this script runs. A resize handler keeps each chart
125
139
  * fit to the window.
140
+ *
141
+ * The script signals readiness when the bootstrap completes, and immediately when no chart exists. It sets
142
+ * the `window.__inflexaThemeReady` sentinel and dispatches the `inflexa-theme-ready` event on the document.
143
+ * A reader that captures the page keys on this signal, thus the capture returns when the page is ready and
144
+ * not at a timeout. The sentinel guards a listener that registers after the dispatch, thus a late listener
145
+ * still resolves.
146
+ *
147
+ * Each chart initializes inside a guard. One malformed option is exactly the fault that a look must
148
+ * diagnose, thus it must never stop a sibling chart and it must never withhold the readiness signal.
126
149
  */
127
150
  export declare const CHART_BOOTSTRAP: string;
@@ -274,14 +274,42 @@ export const ECHARTS_THEME = {
274
274
  splitLine: { lineStyle: { color: "#f1f5f9", type: "dashed" } },
275
275
  },
276
276
  };
277
+ /**
278
+ * The name of the readiness event, and the name of the window sentinel that guards a late listener. The
279
+ * bootstrap below emits both names, thus this module owns them. A capture that waits for the page reads the
280
+ * same two constants, thus the emitter and the waiter cannot disagree over a rename.
281
+ */
282
+ export const THEME_READY_EVENT = "inflexa-theme-ready";
283
+ export const THEME_READY_SENTINEL = "__inflexaThemeReady";
284
+ /**
285
+ * The navigation budget and the readiness budget of one page capture, in milliseconds. The readiness budget
286
+ * bounds the wait for the event that the bootstrap dispatches. Every capture of this page reads the same two
287
+ * budgets, thus two capture sites cannot drift apart.
288
+ */
289
+ export const PAGE_NAV_TIMEOUT_MS = 20_000;
290
+ export const PAGE_READY_TIMEOUT_MS = 8_000;
277
291
  /**
278
292
  * The page-side script that wires each chart. It finds every chart container, reads the option JSON from
279
293
  * the sibling `<script type="application/json">` element, and initializes ECharts with the registered
280
294
  * theme. The skeleton registers the theme before this script runs. A resize handler keeps each chart
281
295
  * fit to the window.
296
+ *
297
+ * The script signals readiness when the bootstrap completes, and immediately when no chart exists. It sets
298
+ * the `window.__inflexaThemeReady` sentinel and dispatches the `inflexa-theme-ready` event on the document.
299
+ * A reader that captures the page keys on this signal, thus the capture returns when the page is ready and
300
+ * not at a timeout. The sentinel guards a listener that registers after the dispatch, thus a late listener
301
+ * still resolves.
302
+ *
303
+ * Each chart initializes inside a guard. One malformed option is exactly the fault that a look must
304
+ * diagnose, thus it must never stop a sibling chart and it must never withhold the readiness signal.
282
305
  */
283
306
  export const CHART_BOOTSTRAP = `(function () {
307
+ function signalReady() {
308
+ window.${THEME_READY_SENTINEL} = true;
309
+ document.dispatchEvent(new Event(${JSON.stringify(THEME_READY_EVENT)}));
310
+ }
284
311
  if (typeof echarts === "undefined") {
312
+ signalReady();
285
313
  return;
286
314
  }
287
315
  var containers = document.querySelectorAll("[data-echarts-id]");
@@ -291,9 +319,13 @@ export const CHART_BOOTSTRAP = `(function () {
291
319
  if (!optionScript || optionScript.getAttribute("type") !== "application/json") {
292
320
  continue;
293
321
  }
294
- var option = JSON.parse(optionScript.textContent || "{}");
295
- var chart = echarts.init(container, ${JSON.stringify(ECHARTS_THEME_NAME)});
296
- chart.setOption(option);
322
+ try {
323
+ var option = JSON.parse(optionScript.textContent || "{}");
324
+ var chart = echarts.init(container, ${JSON.stringify(ECHARTS_THEME_NAME)});
325
+ chart.setOption(option);
326
+ } catch (cause) {
327
+ console.error("chart bootstrap failed for " + (container.getAttribute("data-echarts-id") || "(no id)") + ": " + (cause && cause.message ? cause.message : cause));
328
+ }
297
329
  }
298
330
  window.addEventListener("resize", function () {
299
331
  var nodes = document.querySelectorAll("[data-echarts-id]");
@@ -304,4 +336,5 @@ export const CHART_BOOTSTRAP = `(function () {
304
336
  }
305
337
  }
306
338
  });
339
+ signalReady();
307
340
  })();`;
@@ -25,8 +25,10 @@ import { type ExecuteAnalysisDeps, type ExecuteAnalysisInput, type ExecuteAnalys
25
25
  import { type SandboxStepDeps, type SandboxStepInput, type SandboxStepResult } from "../workflows/sandbox-step.js";
26
26
  import { type ExecuteTargetAssessmentDeps, type ExecuteTargetAssessmentInput, type ExecuteTargetAssessmentResult } from "../workflows/execute-target-assessment.js";
27
27
  import { type DataProfileDeps, type DataProfileWorkflowInput } from "../tasks/data-profile.js";
28
+ import { type ExtractValuesResult, type ExtractValuesWorkflowInput } from "../tasks/extract-values.js";
28
29
  import { type CitationResolverConfig } from "../citations/resolve.js";
29
30
  import type { CitationResolver } from "../citations/types.js";
31
+ import type { AuthContext } from "../auth/types.js";
30
32
  import type { ReferenceResolver } from "../report-model/reference-resolver.js";
31
33
  /** Registered child sandbox-step callable the parent's child dispatch closes over. */
32
34
  export type SandboxStepCallable = (input: SandboxStepInput) => Promise<SandboxStepResult>;
@@ -55,6 +57,7 @@ export interface RegisteredWorkflows {
55
57
  readonly sandboxStep: SandboxStepCallable;
56
58
  readonly executeTargetAssessment: (input: ExecuteTargetAssessmentInput) => Promise<ExecuteTargetAssessmentResult>;
57
59
  readonly dataProfile: (input: DataProfileWorkflowInput) => Promise<void>;
60
+ readonly extractValues: (input: ExtractValuesWorkflowInput) => Promise<ExtractValuesResult>;
58
61
  }
59
62
  /**
60
63
  * Conversation-agent deps minus the workflow callable, the resource policy, and
@@ -63,6 +66,19 @@ export interface RegisteredWorkflows {
63
66
  * workflows see, or a recorder that only half the agent tree reports to.
64
67
  */
65
68
  export type ConversationAssemblyDeps = Omit<ConversationAgentDeps, "executeAnalysisWorkflow" | "resourcePolicy" | "usageRecorder" | "citationResolver">;
69
+ /**
70
+ * What binds one reference resolver: the analysis whose pinned artifacts it reads, and the auth of the tool
71
+ * call that reads them. A resolver serves one analysis, thus the report tools build one for each call.
72
+ */
73
+ export interface ReportResolverScope {
74
+ readonly analysisId: string;
75
+ readonly auth: AuthContext;
76
+ }
77
+ /**
78
+ * The factory that gives the reference resolver of one report tool call. The harness wires the production
79
+ * realization, and an embedder can bind its own at its composition root.
80
+ */
81
+ export type MakeReportReferenceResolver = (scope: ReportResolverScope) => ReferenceResolver;
66
82
  export interface CoreRuntimeDeps {
67
83
  readonly conversation: ConversationAssemblyDeps;
68
84
  readonly workflows: CoreWorkflowDeps;
@@ -81,11 +97,19 @@ export interface CoreRuntimeDeps {
81
97
  /** Harness-owned citation capability configuration; no ambient lookup occurs. */
82
98
  readonly citationResolverConfig?: CitationResolverConfig;
83
99
  /**
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.
100
+ * The byte cap on the in-process host read of a report reference. A file over the
101
+ * cap falls through to the extraction arm. Absent, the resolver uses its 16 MiB
102
+ * default. The embedder tunes it here, thus a host changes it with no harness change.
103
+ */
104
+ readonly reportHostReadCapBytes?: number;
105
+ /**
106
+ * The report reference resolver of an embedder. A managed host reads its pinned
107
+ * artifacts from its own store, thus it binds a realization of the seam here and the
108
+ * report tools resolve through it. Absent, the harness wires the production resolver
109
+ * over the workspace tree and the extraction arm, which is the whole OSS path. A bound
110
+ * factory replaces that wiring, thus `reportHostReadCapBytes` then governs nothing.
87
111
  */
88
- readonly reportReferenceResolver?: ReferenceResolver;
112
+ readonly makeReportReferenceResolver?: MakeReportReferenceResolver;
89
113
  }
90
114
  /**
91
115
  * The refusal a thread type carries no agent yet. `report` is a valid
@@ -23,7 +23,11 @@ import { registerExecuteAnalysis } from "../workflows/execute-analysis.js";
23
23
  import { registerSandboxStep } from "../workflows/sandbox-step.js";
24
24
  import { registerExecuteTargetAssessment, } from "../workflows/execute-target-assessment.js";
25
25
  import { registerDataProfileWorkflow } from "../tasks/data-profile.js";
26
+ import { bindExtractionTrigger, createExtractionArm, registerExtractValuesWorkflow, } from "../tasks/extract-values.js";
26
27
  import { createCitationResolver } from "../citations/resolve.js";
28
+ import { createThreadStore } from "../memory/thread-store.js";
29
+ import { createArtifactReadStore, createProductionResolver } from "../report-model/production-resolver.js";
30
+ import { createReportVersionStore } from "../state/report-versions.js";
27
31
  const _assertDomainError = true;
28
32
  /**
29
33
  * Wrap a type→agent registry as the resolution surface. Held apart from
@@ -62,6 +66,14 @@ export function assembleCoreRuntime(deps) {
62
66
  const executeAnalysis = registerExecuteAnalysis({ ...wf.buildExecuteAnalysis(sandboxStep), citationResolver, usageRecorder });
63
67
  const executeTargetAssessment = registerExecuteTargetAssessment({ ...wf.executeTargetAssessment, usageRecorder });
64
68
  const dataProfile = registerDataProfileWorkflow({ ...wf.dataProfile, usageRecorder });
69
+ // The extraction workflow shares the profile's sandbox and authorization rails, thus it draws the same
70
+ // three seams from the profile deps. The report resolver factory binds the extraction arm over this
71
+ // callable, thus a fall-through report reference reads its file out of process on the profile rails.
72
+ const extractValues = registerExtractValuesWorkflow({
73
+ sandboxClient: wf.dataProfile.sandboxClient,
74
+ runAuthorizer: wf.dataProfile.runAuthorizer,
75
+ ...(wf.dataProfile.logger ? { logger: wf.dataProfile.logger } : {}),
76
+ });
65
77
  const conversationAgent = createConversationAgent({
66
78
  ...conversation,
67
79
  executeAnalysisWorkflow: executeAnalysis,
@@ -73,9 +85,32 @@ export function assembleCoreRuntime(deps) {
73
85
  // runtime binds the per-session state to the thread behind the tool boundary,
74
86
  // thus one definition serves every report thread. Its preview tool writes the
75
87
  // 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.
88
+ // no host seam.
78
89
  const reportSession = createReportSessionRuntime({ pool: conversation.pool, ...(conversation.logger ? { logger: conversation.logger } : {}) });
90
+ const reportVersionStore = createReportVersionStore({ pool: conversation.pool, ...(conversation.logger ? { logger: conversation.logger } : {}) });
91
+ const reportThreads = createThreadStore(conversation.pool);
92
+ // The read cache of the production report path, one for each recent analysis. A preview
93
+ // and the record that follows it are two tool calls over one draft, thus they meet the
94
+ // same artifacts. The store carries the streamed hash and the extracted rows between
95
+ // them, and each resolver still binds its own extraction arm below. Thus the auth of a
96
+ // call never outlives the call, and one unchanged file costs one hash and one container.
97
+ const reportArtifactReads = createArtifactReadStore();
98
+ // The reference resolver of the report path, and its production wiring. A resolver reads
99
+ // the pinned artifacts of one analysis, thus the factory binds one analysis and one auth
100
+ // for each tool call. The extraction arm falls through to the registered extract-values
101
+ // callable for an over-cap file or a host parse fault. The cap gates the host read, and
102
+ // the resolver defaults it to 16 MiB.
103
+ const makeProductionReportResolver = (scope) => {
104
+ const arm = createExtractionArm(bindExtractionTrigger({ runAuthorizer: conversation.runAuthorizer, workflow: extractValues }, { auth: scope.auth, analysisId: scope.analysisId }));
105
+ return createProductionResolver({
106
+ workspaceRoot: conversation.resolveWorkspaceRoot(scope.analysisId),
107
+ analysisId: scope.analysisId,
108
+ extractionArm: arm,
109
+ readCache: reportArtifactReads.forAnalysis(scope.analysisId),
110
+ ...(deps.reportHostReadCapBytes !== undefined ? { cap: deps.reportHostReadCapBytes } : {}),
111
+ });
112
+ };
113
+ const makeReportResolver = deps.makeReportReferenceResolver ?? makeProductionReportResolver;
79
114
  const reportAgent = createReportSessionAgent({
80
115
  model: conversation.model,
81
116
  pool: conversation.pool,
@@ -83,7 +118,10 @@ export function assembleCoreRuntime(deps) {
83
118
  workspaceFs: conversation.workspaceFs,
84
119
  gateway: reportSession.gateway,
85
120
  resolveWorkspaceRoot: conversation.resolveWorkspaceRoot,
86
- ...(deps.reportReferenceResolver ? { resolver: deps.reportReferenceResolver } : {}),
121
+ store: reportVersionStore,
122
+ threads: reportThreads,
123
+ chrome: conversation.chrome,
124
+ makeResolver: makeReportResolver,
87
125
  ...(conversation.logger ? { logger: conversation.logger } : {}),
88
126
  });
89
127
  // The single registration point for every typed agent. A future thread type's
@@ -105,6 +143,7 @@ export function assembleCoreRuntime(deps) {
105
143
  sandboxStep,
106
144
  executeTargetAssessment,
107
145
  dataProfile,
146
+ extractValues,
108
147
  },
109
148
  citationResolver,
110
149
  };
@@ -17,6 +17,13 @@
17
17
  * (see {@link buildSessionSubPaths}).
18
18
  */
19
19
  export declare const STEP_SUBDIRS: readonly ["output", "scripts", "figures", "logs", "notebooks"];
20
+ /**
21
+ * Container mountpoints of the two read-only stores. Exported because they are
22
+ * also the discovery tools' default read root: a host whose own process sees a
23
+ * store the way the sandbox does configures nothing further.
24
+ */
25
+ export declare const LIBS_CONTAINER_PATH = "/mnt/libs";
26
+ export declare const REFS_CONTAINER_PATH = "/mnt/refs";
20
27
  export interface MountPlanCoords {
21
28
  analysisId: string;
22
29
  runId: string;
@@ -18,8 +18,13 @@
18
18
  */
19
19
  import { assertSafeId } from "../workspace/paths.js";
20
20
  export const STEP_SUBDIRS = ["output", "scripts", "figures", "logs", "notebooks"];
21
- const LIBS_CONTAINER_PATH = "/mnt/libs";
22
- const REFS_CONTAINER_PATH = "/mnt/refs";
21
+ /**
22
+ * Container mountpoints of the two read-only stores. Exported because they are
23
+ * also the discovery tools' default read root: a host whose own process sees a
24
+ * store the way the sandbox does configures nothing further.
25
+ */
26
+ export const LIBS_CONTAINER_PATH = "/mnt/libs";
27
+ export const REFS_CONTAINER_PATH = "/mnt/refs";
23
28
  /**
24
29
  * The step tail beneath an analysis's workspace root, in both container and
25
30
  * PVC-relative space. The harness owns this layout (workspace-layout spec).
@@ -171,14 +171,22 @@ CREATE INDEX IF NOT EXISTS idx_cortex_report_versions_analysis
171
171
  -- never acquires one. The thread id carries no foreign key, the same as the
172
172
  -- messages rows, thus a purge of a thread cannot reach this row through a cascade
173
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.
174
180
  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()
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()
182
190
  );
183
191
 
184
192
  CREATE INDEX IF NOT EXISTS idx_cortex_report_session_state_analysis
@@ -552,6 +560,11 @@ export async function initCortexState(pool, injected) {
552
560
  "ALTER TABLE cortex_step_executions ADD COLUMN IF NOT EXISTS child_workflow_id TEXT",
553
561
  "CREATE INDEX IF NOT EXISTS idx_cortex_step_exec_child_workflow " + "ON cortex_step_executions(child_workflow_id)",
554
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",
555
568
  // Databases created before `data_profile_status` became nullable still
556
569
  // carry the NOT NULL floor, which would reject a clear. Idempotent:
557
570
  // dropping an absent NOT NULL no-ops, so this is a no-op on fresh DBs.
@@ -42,6 +42,10 @@ export interface ReportSessionState {
42
42
  readonly document: DraftDocument | null;
43
43
  /** The pinned snapshot, or `null` before the pin writes it. */
44
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;
45
49
  readonly createdAt: Date;
46
50
  }
47
51
  /** The snapshot and the anchor of a row to create. */
@@ -69,6 +73,17 @@ export interface PersistDocumentInput {
69
73
  * this turn must read the state again. `absent` means no row holds the thread.
70
74
  */
71
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";
72
87
  /**
73
88
  * A stored row that a read cannot parse with the current schemas. It names the
74
89
  * thread id and the part that failed, and it carries the reduced issues.
@@ -100,6 +115,18 @@ export interface ReportSessionStateStore {
100
115
  * concurrent turn landed first, and `absent` means no row holds the thread.
101
116
  */
102
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>;
103
130
  }
104
131
  export interface ReportSessionStateStoreDeps {
105
132
  readonly pool: Pool;
@@ -33,7 +33,7 @@ import { DraftDocumentSchema } from "../report-model/draft.js";
33
33
  import { parseSnapshot, reduceIssues } from "./snapshot-parse.js";
34
34
  const _assertReadError = true;
35
35
  /** The full row projection every read shares. */
36
- const STATE_COLUMNS = "thread_id, analysis_id, document, snapshot, created_at";
36
+ const STATE_COLUMNS = "thread_id, analysis_id, document, snapshot, rendered_document_hash, seen_document_hash, created_at";
37
37
  /**
38
38
  * The insert. The row carries the snapshot at creation, and the document lands later
39
39
  * through `persistDocument`. ON CONFLICT DO NOTHING keeps the first row, thus a
@@ -77,6 +77,8 @@ function rowToState(row) {
77
77
  analysisId: row.analysis_id,
78
78
  document,
79
79
  snapshot,
80
+ renderedDocumentHash: row.rendered_document_hash,
81
+ seenDocumentHash: row.seen_document_hash,
80
82
  createdAt: row.created_at,
81
83
  });
82
84
  }
@@ -146,5 +148,30 @@ export function createReportSessionStateStore({ pool }) {
146
148
  return row.updated > 0 ? "persisted" : "conflict";
147
149
  });
148
150
  }
149
- return { readState, writeSnapshot, persistDocument };
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 };
150
177
  }
@@ -0,0 +1,64 @@
1
+ /**
2
+ * The fixed extraction script and its wire protocol.
3
+ *
4
+ * The extraction arm runs this one Python script in the sandbox. The script reads one JSON input: a list
5
+ * of path, format, and hash triples. For each triple the script hashes the file bytes with a streamed
6
+ * sha256 before any read. If the on-disk hash does not match the pinned hash, the script refuses the file
7
+ * as a typed error. If the hash matches, the script reads the file from the read-only analysis mount, and
8
+ * it parses the file by the carried format. A parquet file uses pyarrow. The script emits one JSON map:
9
+ * each path to its rows, or to a typed error. The script decides nothing else. It applies no filter and no
10
+ * projection.
11
+ *
12
+ * The host decides the format one time, and the request carries it. Thus the script derives no reader from
13
+ * an extension, and it refuses a request that names no format. The JSON arm obeys the strict host shape, an
14
+ * array of flat objects, thus a shape that the host refuses is a refusal here too and it never comes back
15
+ * as a table that pandas inferred.
16
+ *
17
+ * The harness holds the script as a string constant, thus the package ships no separate asset file. The
18
+ * script reaches the container as the argument of `python3 -c`. The input reaches the container as one
19
+ * environment variable.
20
+ */
21
+ import { z } from "zod";
22
+ /**
23
+ * The environment variable that carries the script input. The workflow body sets it to the JSON list of
24
+ * path, format, and hash triples. The script reads it back one time.
25
+ */
26
+ export declare const EXTRACTION_INPUT_ENV = "CORTEX_EXTRACTION_REQUESTS";
27
+ /**
28
+ * One artifact row, as plain cells. A text-backed format holds a numeric column as a string, thus a cell
29
+ * is a string or a number.
30
+ */
31
+ declare const ExtractionRowSchema: z.ZodRecord<z.ZodString, z.ZodUnion<readonly [z.ZodString, z.ZodNumber]>>;
32
+ export type ExtractionRow = z.infer<typeof ExtractionRowSchema>;
33
+ /**
34
+ * The outcome for one path. A clean read gives the rows. A per-file read fault gives a typed error, and
35
+ * the script still emits the map. The arm keeps the rows and drops the error, thus a failed path reads as
36
+ * an unread artifact downstream.
37
+ */
38
+ declare const ExtractOutcomeSchema: z.ZodUnion<readonly [z.ZodObject<{
39
+ rows: z.ZodArray<z.ZodRecord<z.ZodString, z.ZodUnion<readonly [z.ZodString, z.ZodNumber]>>>;
40
+ }, z.core.$strip>, z.ZodObject<{
41
+ error: z.ZodObject<{
42
+ type: z.ZodString;
43
+ message: z.ZodString;
44
+ }, z.core.$strip>;
45
+ }, z.core.$strip>]>;
46
+ export type ExtractOutcome = z.infer<typeof ExtractOutcomeSchema>;
47
+ /** The script output: each requested path to its outcome. */
48
+ export declare const ExtractValuesResultSchema: z.ZodRecord<z.ZodString, z.ZodUnion<readonly [z.ZodObject<{
49
+ rows: z.ZodArray<z.ZodRecord<z.ZodString, z.ZodUnion<readonly [z.ZodString, z.ZodNumber]>>>;
50
+ }, z.core.$strip>, z.ZodObject<{
51
+ error: z.ZodObject<{
52
+ type: z.ZodString;
53
+ message: z.ZodString;
54
+ }, z.core.$strip>;
55
+ }, z.core.$strip>]>>;
56
+ export type ExtractValuesResult = z.infer<typeof ExtractValuesResultSchema>;
57
+ /**
58
+ * The one extraction script. The workflow body runs it as the argument of `python3 -c`. The script reads
59
+ * the request list from `CORTEX_EXTRACTION_REQUESTS`, and it emits the value map to stdout.
60
+ *
61
+ * The Python comments obey the same Simplified Technical English rule as the harness comments.
62
+ */
63
+ export declare const EXTRACTION_SCRIPT = "import hashlib\nimport json\nimport math\nimport os\nimport sys\n\nimport pandas as pd\n\n\ndef sha256_of(path):\n # The host pins the exact bytes with a streamed sha256, in the \"sha256:<hex>\" form. Mirror that form\n # here, and stream the file in blocks, thus a large file never loads fully into memory.\n digest = hashlib.sha256()\n with open(path, \"rb\") as handle:\n for block in iter(lambda: handle.read(1024 * 1024), b\"\"):\n digest.update(block)\n return \"sha256:\" + digest.hexdigest()\n\n\ndef coerce_cell(value):\n # A null cell becomes None, thus the record drops the key.\n if value is None:\n return None\n # A numpy scalar becomes a plain Python scalar.\n if hasattr(value, \"item\"):\n try:\n value = value.item()\n except (ValueError, TypeError):\n return str(value)\n if isinstance(value, bool):\n return \"true\" if value else \"false\"\n if isinstance(value, float):\n if math.isnan(value) or math.isinf(value):\n return None\n return value\n if isinstance(value, int):\n return value\n if isinstance(value, str):\n return value\n return str(value)\n\n\ndef read_frame(path, fmt):\n # pyarrow reads the parquet columnar format.\n if fmt == \"parquet\":\n return pd.read_parquet(path, engine=\"pyarrow\")\n if fmt == \"tsv\":\n # keep_default_na=False keeps a literal cell such as \"NA\" or an empty string as its text, and the\n # host parser keeps it too. A default read turns each into NaN, and the row builder drops the key.\n return pd.read_csv(path, sep=\"\\t\", dtype=str, keep_default_na=False)\n if fmt == \"csv\":\n # keep_default_na=False keeps a literal cell such as \"NA\" or an empty string as its text, thus a\n # text cell survives and the row keeps the key.\n return pd.read_csv(path, dtype=str, keep_default_na=False)\n # The host decides the format for both arms. A request with no decided format is a protocol fault, thus\n # the file refuses here and no reader guesses at it.\n raise ValueError(\"the request names no supported format: \" + str(fmt))\n\n\ndef json_rows(path):\n # The host reads a JSON table as an array of flat objects, and it refuses any other shape. Obey the\n # same shape here. An inference pass would turn a shape that the host refuses into a different table.\n with open(path, \"rb\") as handle:\n value = json.load(handle)\n if not isinstance(value, list):\n raise ValueError(\"the JSON top-level value is not an array\")\n rows = []\n for item in value:\n if not isinstance(item, dict):\n raise ValueError(\"a JSON array item is not an object\")\n row = {}\n for key, cell in item.items():\n if isinstance(cell, (dict, list)):\n raise ValueError(\"a JSON cell holds a nested value\")\n coerced = coerce_cell(cell)\n if coerced is not None:\n row[str(key)] = coerced\n rows.append(row)\n return rows\n\n\ndef rows_of(frame):\n # A null cell becomes None before the record build, thus a missing cell drops.\n clean = frame.astype(object).where(pd.notnull(frame), None)\n records = clean.to_dict(orient=\"records\")\n rows = []\n for record in records:\n row = {}\n for key, value in record.items():\n cell = coerce_cell(value)\n if cell is not None:\n row[str(key)] = cell\n rows.append(row)\n return rows\n\n\ndef rows_for(path, fmt):\n # The JSON arm reads the strict host shape itself. Each other format reads through pandas.\n if fmt == \"json\":\n return json_rows(path)\n return rows_of(read_frame(path, fmt))\n\n\ndef main():\n raw = os.environ.get(\"CORTEX_EXTRACTION_REQUESTS\", \"[]\")\n requests = json.loads(raw)\n out = {}\n for request in requests:\n path = request[\"path\"]\n fmt = request.get(\"format\")\n expected = request[\"hash\"]\n try:\n # Hash the file before any read. A file that drifted from the pin never reaches the reader, thus\n # the extraction never gives back drifted bytes for the gate to match against.\n actual = sha256_of(path)\n if actual != expected:\n out[path] = {\n \"error\": {\n \"type\": \"hash-mismatch\",\n \"message\": f\"the pinned hash {expected} does not match the on-disk hash {actual}\",\n }\n }\n continue\n out[path] = {\"rows\": rows_for(path, fmt)}\n except Exception as exc:\n # A per-file read fault lands as a typed error, and the map still holds each other path.\n out[path] = {\"error\": {\"type\": \"read-fault\", \"message\": str(exc)}}\n json.dump(out, sys.stdout)\n\n\nmain()\n";
64
+ export {};