@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.
- package/dist/agents/report-session-agent.d.ts +25 -9
- package/dist/agents/report-session-agent.js +21 -6
- package/dist/app/report-session-runtime.js +26 -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/prompts/report-session.d.ts +1 -1
- package/dist/prompts/report-session.js +21 -0
- package/dist/providers/ai-sdk.d.ts +81 -3
- package/dist/providers/ai-sdk.js +91 -10
- package/dist/providers/types.d.ts +17 -0
- 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/production-resolver.d.ts +124 -0
- package/dist/report-model/production-resolver.js +801 -0
- package/dist/report-model/reference-resolver.d.ts +6 -0
- package/dist/report-model/validate.js +5 -0
- package/dist/report-render/page.d.ts +23 -0
- package/dist/report-render/page.js +36 -3
- package/dist/runtime/assemble.d.ts +28 -4
- package/dist/runtime/assemble.js +42 -3
- package/dist/sandbox/mount-plan.d.ts +7 -0
- package/dist/sandbox/mount-plan.js +7 -2
- package/dist/state/init.js +20 -7
- package/dist/state/report-session-state.d.ts +27 -0
- package/dist/state/report-session-state.js +29 -2
- 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/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 +54 -4
- package/dist/tools/report-authoring/authoring-tools.js +1 -1
- 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 +2 -0
- package/dist/tools/report-session/index.js +2 -0
- package/dist/tools/report-session/preview-report.d.ts +20 -5
- package/dist/tools/report-session/preview-report.js +28 -4
- package/dist/tools/report-session/record-version.d.ts +102 -0
- package/dist/tools/report-session/record-version.js +139 -0
- 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
|
@@ -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
|
-
|
|
295
|
-
|
|
296
|
-
|
|
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
|
-
*
|
|
85
|
-
*
|
|
86
|
-
*
|
|
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
|
|
112
|
+
readonly makeReportReferenceResolver?: MakeReportReferenceResolver;
|
|
89
113
|
}
|
|
90
114
|
/**
|
|
91
115
|
* The refusal a thread type carries no agent yet. `report` is a valid
|
package/dist/runtime/assemble.js
CHANGED
|
@@ -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.
|
|
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
|
-
|
|
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
|
-
|
|
22
|
-
|
|
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).
|
package/dist/state/init.js
CHANGED
|
@@ -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
|
|
176
|
-
analysis_id
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
document
|
|
180
|
-
snapshot
|
|
181
|
-
|
|
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
|
-
|
|
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 {};
|