@inflexa-ai/harness 0.19.0 → 0.19.2

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 (101) hide show
  1. package/dist/agents/report-session-agent.d.ts +32 -9
  2. package/dist/agents/report-session-agent.js +22 -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/auth/local-run-authorizer.d.ts +3 -2
  7. package/dist/auth/local-run-authorizer.js +4 -2
  8. package/dist/config/environment-stores.d.ts +12 -7
  9. package/dist/config/resource-limits.d.ts +6 -10
  10. package/dist/config/resource-limits.js +6 -44
  11. package/dist/contracts/report-reference.d.ts +4 -0
  12. package/dist/contracts/report-reference.js +3 -0
  13. package/dist/execution/run-authorizer.d.ts +18 -0
  14. package/dist/execution/run-canceler.d.ts +67 -0
  15. package/dist/execution/run-canceler.js +130 -0
  16. package/dist/index.d.ts +7 -3
  17. package/dist/index.js +6 -1
  18. package/dist/lib/chrome.d.ts +14 -0
  19. package/dist/lib/chrome.js +7 -0
  20. package/dist/lib/page-capture.d.ts +47 -0
  21. package/dist/lib/page-capture.js +83 -0
  22. package/dist/loop/run-agent.js +104 -15
  23. package/dist/memory/ai-sdk-message-storage.d.ts +9 -4
  24. package/dist/memory/ai-sdk-message-storage.js +9 -4
  25. package/dist/memory/count-tokens.js +34 -5
  26. package/dist/prompts/report-session.d.ts +1 -1
  27. package/dist/prompts/report-session.js +21 -0
  28. package/dist/providers/ai-sdk.d.ts +81 -3
  29. package/dist/providers/ai-sdk.js +91 -10
  30. package/dist/providers/types.d.ts +17 -0
  31. package/dist/report-model/assert-rules.d.ts +44 -0
  32. package/dist/report-model/assert-rules.js +97 -0
  33. package/dist/report-model/draft-hash.d.ts +14 -0
  34. package/dist/report-model/draft-hash.js +36 -0
  35. package/dist/report-model/fixture-resolver.js +2 -71
  36. package/dist/report-model/production-resolver.d.ts +124 -0
  37. package/dist/report-model/production-resolver.js +801 -0
  38. package/dist/report-model/reference-resolver.d.ts +6 -0
  39. package/dist/report-model/validate.js +5 -0
  40. package/dist/report-render/assets.d.ts +42 -0
  41. package/dist/report-render/assets.js +61 -0
  42. package/dist/report-render/chart.js +31 -10
  43. package/dist/report-render/design.d.ts +120 -0
  44. package/dist/report-render/design.js +808 -0
  45. package/dist/report-render/page.d.ts +51 -111
  46. package/dist/report-render/page.js +158 -268
  47. package/dist/report-render/render.js +34 -11
  48. package/dist/report-render/views/chart-view.d.ts +6 -3
  49. package/dist/report-render/views/chart-view.js +10 -5
  50. package/dist/report-render/views/page-view.d.ts +19 -8
  51. package/dist/report-render/views/page-view.js +31 -11
  52. package/dist/report-render/views/prose.d.ts +1 -0
  53. package/dist/report-render/views/prose.js +7 -13
  54. package/dist/report-render/views/references-view.js +5 -5
  55. package/dist/report-render/views/values.d.ts +15 -6
  56. package/dist/report-render/views/values.js +29 -10
  57. package/dist/runtime/assemble.d.ts +38 -4
  58. package/dist/runtime/assemble.js +43 -3
  59. package/dist/sandbox/k8s-client.js +18 -0
  60. package/dist/sandbox/mount-plan.d.ts +7 -0
  61. package/dist/sandbox/mount-plan.js +7 -2
  62. package/dist/sandbox/types.d.ts +6 -0
  63. package/dist/state/analyses.d.ts +3 -26
  64. package/dist/state/analyses.js +6 -64
  65. package/dist/state/index.d.ts +2 -2
  66. package/dist/state/index.js +2 -2
  67. package/dist/state/init.js +23 -10
  68. package/dist/state/report-session-state.d.ts +27 -0
  69. package/dist/state/report-session-state.js +29 -2
  70. package/dist/state/runs.d.ts +10 -0
  71. package/dist/state/runs.js +21 -0
  72. package/dist/tasks/data-profile.d.ts +4 -0
  73. package/dist/tasks/data-profile.js +14 -0
  74. package/dist/tasks/extract-values-script.d.ts +64 -0
  75. package/dist/tasks/extract-values-script.js +180 -0
  76. package/dist/tasks/extract-values.d.ts +124 -0
  77. package/dist/tasks/extract-values.js +193 -0
  78. package/dist/tools/approval/gateway.d.ts +8 -1
  79. package/dist/tools/approval/gateway.js +9 -2
  80. package/dist/tools/approval/queries.d.ts +9 -2
  81. package/dist/tools/approval/queries.js +12 -4
  82. package/dist/tools/define-tool.d.ts +29 -0
  83. package/dist/tools/define-tool.js +26 -0
  84. package/dist/tools/report/preview-snapshot.d.ts +1 -1
  85. package/dist/tools/report/preview-snapshot.js +11 -50
  86. package/dist/tools/report-authoring/authoring-tools.d.ts +54 -4
  87. package/dist/tools/report-authoring/authoring-tools.js +1 -1
  88. package/dist/tools/report-session/examine-page.d.ts +90 -0
  89. package/dist/tools/report-session/examine-page.js +136 -0
  90. package/dist/tools/report-session/index.d.ts +3 -1
  91. package/dist/tools/report-session/index.js +3 -1
  92. package/dist/tools/report-session/preview-report.d.ts +31 -6
  93. package/dist/tools/report-session/preview-report.js +63 -16
  94. package/dist/tools/report-session/record-version.d.ts +102 -0
  95. package/dist/tools/report-session/record-version.js +139 -0
  96. package/dist/tools/sandbox/list-available-packages.js +2 -1
  97. package/dist/tools/sandbox/list-available-refs.d.ts +5 -1
  98. package/dist/tools/sandbox/list-available-refs.js +13 -6
  99. package/dist/workflows/sandbox-step.d.ts +4 -0
  100. package/dist/workflows/sandbox-step.js +21 -2
  101. package/package.json +14 -8
@@ -32,6 +32,11 @@ const MANAGED_BY_VALUE = "cortex";
32
32
  const OWNER_WORKFLOW_LABEL = "cortex/owner-workflow-id";
33
33
  const RUN_ID_LABEL = "cortex/run-id";
34
34
  const STEP_ID_LABEL = "cortex/step-id";
35
+ // OpenCost sanitizes `/` and `-` to `_`, so these arrive at the metering
36
+ // reconciler as cortex_billing_context / cortex_user_id / cortex_analysis_id.
37
+ const BILLING_CONTEXT_LABEL = "cortex/billing-context";
38
+ const USER_ID_LABEL = "cortex/user-id";
39
+ const ANALYSIS_ID_LABEL = "cortex/analysis-id";
35
40
  /**
36
41
  * Coerce an arbitrary identifier into a valid K8s label value:
37
42
  * ≤63 chars, `[A-Za-z0-9._-]` only, starting and ending alphanumeric.
@@ -214,6 +219,17 @@ function buildJobSpec(meta, config, identity) {
214
219
  podSpec.tolerations = config.tolerations;
215
220
  if (config.runtimeClassName)
216
221
  podSpec.runtimeClassName = config.runtimeClassName;
222
+ // Pod-template placement is load-bearing: the OpenCost reconciler allocates
223
+ // by pod labels and filters on a non-empty cortex_billing_context — a
224
+ // Job-only label is invisible to compute metering.
225
+ const billingLabels = meta.billing
226
+ ? {
227
+ [BILLING_CONTEXT_LABEL]: sanitizeLabelValue(meta.billing.billingContextId),
228
+ [USER_ID_LABEL]: sanitizeLabelValue(meta.billing.userId),
229
+ [ANALYSIS_ID_LABEL]: sanitizeLabelValue(meta.analysisId),
230
+ [RUN_ID_LABEL]: sanitizeLabelValue(meta.runId),
231
+ }
232
+ : {};
217
233
  return {
218
234
  apiVersion: "batch/v1",
219
235
  kind: "Job",
@@ -227,6 +243,7 @@ function buildJobSpec(meta, config, identity) {
227
243
  [OWNER_WORKFLOW_LABEL]: sanitizeLabelValue(meta.childWorkflowId),
228
244
  [RUN_ID_LABEL]: sanitizeLabelValue(meta.runId),
229
245
  [STEP_ID_LABEL]: sanitizeLabelValue(meta.stepId),
246
+ ...billingLabels,
230
247
  },
231
248
  },
232
249
  spec: {
@@ -237,6 +254,7 @@ function buildJobSpec(meta, config, identity) {
237
254
  labels: {
238
255
  role: "sandbox",
239
256
  "cortex/sandbox-id": sandboxId,
257
+ ...billingLabels,
240
258
  },
241
259
  },
242
260
  spec: podSpec,
@@ -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).
@@ -243,6 +243,12 @@ export interface CreateSandboxMeta {
243
243
  /** Enforced read-only: provision with no read-write step mount, only the
244
244
  * read-only analysis tree for generic read-only agents. */
245
245
  readOnly?: boolean;
246
+ /** Billing attribution stamped as pod labels for OpenCost compute metering.
247
+ * Absent ⇒ the pod is invisible to the metering reconciler (filter-level). */
248
+ billing?: {
249
+ billingContextId: string;
250
+ userId: string;
251
+ };
246
252
  }
247
253
  /**
248
254
  * The identity minted for a sandbox machine *before* it is spawned — the
@@ -1,5 +1,5 @@
1
1
  /**
2
- * `cortex_analysis_state` operations — analysis lifecycle and billing context.
2
+ * `cortex_analysis_state` operations — analysis lifecycle.
3
3
  */
4
4
  import type { ResultAsync } from "neverthrow";
5
5
  import { type DbError } from "../lib/db-result.js";
@@ -9,20 +9,12 @@ import type { Querier } from "./db.js";
9
9
  *
10
10
  * The seed endpoint is called repeatedly; this function handles both the
11
11
  * initial INSERT and subsequent UPDATEs by writing ALL mutable fields on
12
- * every call. Re-upserts replace `context` and `billing_context` wholesale.
12
+ * every call. Re-upserts replace `context` wholesale.
13
13
  *
14
14
  * User identity is derived from the ambient credential's JWT `sub` claim
15
15
  * at request time — not persisted in this table.
16
- *
17
- * INTENTIONAL: split-attribution across a single run.
18
- * If the seed re-upserts mid-workflow (e.g., the billing context's VK rotates),
19
- * in-flight steps that have already issued LLM calls keep the old attribution
20
- * on those calls, while subsequent steps in the same run pick up the new
21
- * identity on their next `billedAgent` resolution. This is by design —
22
- * usage rows contain full per-call attribution, so spend is
23
- * always assignable even when it crosses a rotation boundary.
24
16
  */
25
- export declare function upsertAnalysis(pool: Querier, resourceId: string, context: string | null, billingContext: Record<string, string> | null, inputFileIds?: string[]): ResultAsync<void, DbError>;
17
+ export declare function upsertAnalysis(pool: Querier, resourceId: string, context: string | null, inputFileIds?: string[]): ResultAsync<void, DbError>;
26
18
  export declare function loadAnalysisStatus(pool: Querier, resourceId: string): ResultAsync<{
27
19
  status: string;
28
20
  context: string | null;
@@ -41,18 +33,3 @@ export declare function suspendAnalysis(pool: Querier, analysisId: string): Resu
41
33
  * Only transitions from `suspended_insufficient_funds` → `active`.
42
34
  */
43
35
  export declare function resumeAnalysis(pool: Querier, analysisId: string): ResultAsync<void, DbError>;
44
- /**
45
- * Resolve the persisted billing context for an analysis.
46
- *
47
- * Single DB-read chokepoint used by `requireAnalysisBilling` middleware and
48
- * by any downstream code that needs to re-read billing identity. The driver
49
- * read is wrapped as a `DbError`; the missing-row / null-billing /
50
- * unparseable-JSON cases are control-flow throws (a misconfiguration, not a
51
- * driver failure) that short-circuit the chain and surface verbatim.
52
- *
53
- * User identity (`HEADERS.User`) is derived from the ambient credential's
54
- * JWT `sub` claim by `credential-middleware`, not from the DB.
55
- */
56
- export declare function resolveAnalysisBilling(pool: Querier, analysisId: string): ResultAsync<{
57
- billingContext: Record<string, string>;
58
- }, DbError>;
@@ -1,5 +1,5 @@
1
1
  /**
2
- * `cortex_analysis_state` operations — analysis lifecycle and billing context.
2
+ * `cortex_analysis_state` operations — analysis lifecycle.
3
3
  */
4
4
  import { tryMutation, tryQuery } from "../lib/db-result.js";
5
5
  /**
@@ -7,40 +7,24 @@ import { tryMutation, tryQuery } from "../lib/db-result.js";
7
7
  *
8
8
  * The seed endpoint is called repeatedly; this function handles both the
9
9
  * initial INSERT and subsequent UPDATEs by writing ALL mutable fields on
10
- * every call. Re-upserts replace `context` and `billing_context` wholesale.
10
+ * every call. Re-upserts replace `context` wholesale.
11
11
  *
12
12
  * User identity is derived from the ambient credential's JWT `sub` claim
13
13
  * at request time — not persisted in this table.
14
- *
15
- * INTENTIONAL: split-attribution across a single run.
16
- * If the seed re-upserts mid-workflow (e.g., the billing context's VK rotates),
17
- * in-flight steps that have already issued LLM calls keep the old attribution
18
- * on those calls, while subsequent steps in the same run pick up the new
19
- * identity on their next `billedAgent` resolution. This is by design —
20
- * usage rows contain full per-call attribution, so spend is
21
- * always assignable even when it crosses a rotation boundary.
22
14
  */
23
- export function upsertAnalysis(pool, resourceId, context, billingContext, inputFileIds) {
15
+ export function upsertAnalysis(pool, resourceId, context, inputFileIds) {
24
16
  const now = new Date().toISOString();
25
17
  return tryMutation("analyses.upsertAnalysis", async () => {
26
18
  await pool.query({
27
19
  text: `INSERT INTO cortex_analysis_state
28
- (analysis_id, status, context, billing_context, data_profile_status,
20
+ (analysis_id, status, context, data_profile_status,
29
21
  seed_input_file_ids, created_at, updated_at)
30
- VALUES ($1, 'active', $2, $3::jsonb, 'pending', $4::jsonb, $5, $6)
22
+ VALUES ($1, 'active', $2, 'pending', $3::jsonb, $4, $5)
31
23
  ON CONFLICT (analysis_id) DO UPDATE SET
32
24
  context = EXCLUDED.context,
33
- billing_context = EXCLUDED.billing_context,
34
25
  seed_input_file_ids = COALESCE(EXCLUDED.seed_input_file_ids, cortex_analysis_state.seed_input_file_ids),
35
26
  updated_at = EXCLUDED.updated_at`,
36
- values: [
37
- resourceId,
38
- context ?? null,
39
- billingContext === null ? null : JSON.stringify(billingContext),
40
- inputFileIds ? JSON.stringify(inputFileIds) : null,
41
- now,
42
- now,
43
- ],
27
+ values: [resourceId, context ?? null, inputFileIds ? JSON.stringify(inputFileIds) : null, now, now],
44
28
  });
45
29
  });
46
30
  }
@@ -88,45 +72,3 @@ export function resumeAnalysis(pool, analysisId) {
88
72
  });
89
73
  });
90
74
  }
91
- /**
92
- * Resolve the persisted billing context for an analysis.
93
- *
94
- * Single DB-read chokepoint used by `requireAnalysisBilling` middleware and
95
- * by any downstream code that needs to re-read billing identity. The driver
96
- * read is wrapped as a `DbError`; the missing-row / null-billing /
97
- * unparseable-JSON cases are control-flow throws (a misconfiguration, not a
98
- * driver failure) that short-circuit the chain and surface verbatim.
99
- *
100
- * User identity (`HEADERS.User`) is derived from the ambient credential's
101
- * JWT `sub` claim by `credential-middleware`, not from the DB.
102
- */
103
- export function resolveAnalysisBilling(pool, analysisId) {
104
- return tryQuery("analyses.resolveAnalysisBilling", () => pool.query({
105
- text: "SELECT billing_context FROM cortex_analysis_state WHERE analysis_id = $1",
106
- values: [analysisId],
107
- })).map((result) => {
108
- const row = result.rows[0];
109
- if (!row) {
110
- throw new Error(`resolveAnalysisBilling: no cortex_analysis_state row for analysisId=${analysisId}`);
111
- }
112
- const rawBilling = row.billing_context;
113
- if (rawBilling === null || rawBilling === undefined) {
114
- throw new Error(`resolveAnalysisBilling: billing_context missing for analysisId=${analysisId}`);
115
- }
116
- // JSONB is parsed by `pg` into native objects. Legacy TEXT rows (if any
117
- // slipped through) arrive as strings — parse them.
118
- let billingContext;
119
- if (typeof rawBilling === "string") {
120
- try {
121
- billingContext = JSON.parse(rawBilling);
122
- }
123
- catch (err) {
124
- throw new Error(`resolveAnalysisBilling: billing_context is not valid JSON for analysisId=${analysisId}: ${err instanceof Error ? err.message : err}`, { cause: err });
125
- }
126
- }
127
- else {
128
- billingContext = rawBilling;
129
- }
130
- return { billingContext };
131
- });
132
- }
@@ -8,10 +8,10 @@
8
8
  */
9
9
  export { initCortexState } from "./init.js";
10
10
  export type { Querier } from "./db.js";
11
- export { upsertAnalysis, resolveAnalysisBilling, suspendAnalysis, resumeAnalysis, loadAnalysisStatus } from "./analyses.js";
11
+ export { upsertAnalysis, suspendAnalysis, resumeAnalysis, loadAnalysisStatus } from "./analyses.js";
12
12
  export { upsertArtifact, upsertArtifacts, queryInputArtifacts, queryStepArtifactPaths, queryUnsyncedStepArtifacts, updateArtifactId, updateFileIds, countArtifactsForRun, } from "./artifacts.js";
13
13
  export type { RegisterArtifactInput, InputArtifactMeta, StepArtifactRef } from "./artifacts.js";
14
- export { insertRun, reserveRunById, RunDedupCollisionError, RunIdentityCollisionError, updateRunStatus, promoteFailedToPartial, setRunMandate, // oss-core-managed-ok: run-mandate ledger (nullable; OSS leaves null)
14
+ export { insertRun, reserveRunById, RunDedupCollisionError, RunIdentityCollisionError, updateRunStatus, markRunCanceledIfActive, promoteFailedToPartial, setRunMandate, // oss-core-managed-ok: run-mandate ledger (nullable; OSS leaves null)
15
15
  setRunSynthesisOutcome, queryRun, queryActiveRun, queryNonTerminalRunsByAnalysis, queryRunsForInspection, queryRunsByAnalysis, queryRunsByThread, } from "./runs.js";
16
16
  export type { InsertRunInput, RunPage, RunReservation } from "./runs.js";
17
17
  export { insertStepExecution, seedStepExecutions, sweepPendingStepExecutions, updateStepExecution, queryStepsByRun } from "./step-executions.js";
@@ -7,9 +7,9 @@
7
7
  * specific entity module (e.g. `./state/runs.js`) when narrow.
8
8
  */
9
9
  export { initCortexState } from "./init.js";
10
- export { upsertAnalysis, resolveAnalysisBilling, suspendAnalysis, resumeAnalysis, loadAnalysisStatus } from "./analyses.js";
10
+ export { upsertAnalysis, suspendAnalysis, resumeAnalysis, loadAnalysisStatus } from "./analyses.js";
11
11
  export { upsertArtifact, upsertArtifacts, queryInputArtifacts, queryStepArtifactPaths, queryUnsyncedStepArtifacts, updateArtifactId, updateFileIds, countArtifactsForRun, } from "./artifacts.js";
12
- export { insertRun, reserveRunById, RunDedupCollisionError, RunIdentityCollisionError, updateRunStatus, promoteFailedToPartial, setRunMandate, // oss-core-managed-ok: run-mandate ledger (nullable; OSS leaves null)
12
+ export { insertRun, reserveRunById, RunDedupCollisionError, RunIdentityCollisionError, updateRunStatus, markRunCanceledIfActive, promoteFailedToPartial, setRunMandate, // oss-core-managed-ok: run-mandate ledger (nullable; OSS leaves null)
13
13
  setRunSynthesisOutcome, queryRun, queryActiveRun, queryNonTerminalRunsByAnalysis, queryRunsForInspection, queryRunsByAnalysis, queryRunsByThread, } from "./runs.js";
14
14
  export { insertStepExecution, seedStepExecutions, sweepPendingStepExecutions, updateStepExecution, queryStepsByRun } from "./step-executions.js";
15
15
  export { setSandboxRef, setActiveExecId, clearSandboxRef, queryActiveSandboxes, reconcileReapedSandbox } from "./active-sandboxes.js";
@@ -12,7 +12,7 @@ import { createNoopLogger } from "../lib/console-logger.js";
12
12
  // splitting on ';' (see `DDL.split` below), so a semicolon inside a comment cuts
13
13
  // the statement it precedes in half and Postgres rejects the fragment.
14
14
  const DDL = `
15
- -- Analysis-level state (status, context, data-profile tracking, billing identity).
15
+ -- Analysis-level state (status, context, data-profile tracking).
16
16
  --
17
17
  -- User identity is derived from the ambient credential's JWT sub claim
18
18
  -- at request time — not persisted in this table.
@@ -20,7 +20,6 @@ CREATE TABLE IF NOT EXISTS cortex_analysis_state (
20
20
  analysis_id TEXT PRIMARY KEY,
21
21
  status TEXT NOT NULL,
22
22
  context TEXT,
23
- billing_context JSONB,
24
23
  -- Nullable: NULL is the honest "no profile" state, which loadDataProfileStatus
25
24
  -- collapses to null so a cleared profile is indistinguishable from one that never
26
25
  -- existed. The 'pending' default still applies to a row inserted without a status --
@@ -171,14 +170,22 @@ CREATE INDEX IF NOT EXISTS idx_cortex_report_versions_analysis
171
170
  -- never acquires one. The thread id carries no foreign key, the same as the
172
171
  -- messages rows, thus a purge of a thread cannot reach this row through a cascade
173
172
  -- and purgeThread removes it by name.
173
+ --
174
+ -- The two hash columns are the look-before-record markers. rendered_document_hash
175
+ -- holds the hash of the draft that the last preview rendered. seen_document_hash
176
+ -- holds the hash that the last eyes capture saw, copied from the rendered hash.
177
+ -- Each is nullable, because a fresh session has run no preview and no eyes. The
178
+ -- record refuses unless the seen hash equals the hash of the current draft.
174
179
  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()
180
+ thread_id TEXT PRIMARY KEY,
181
+ analysis_id TEXT NOT NULL
182
+ REFERENCES cortex_analysis_state(analysis_id)
183
+ ON DELETE CASCADE,
184
+ document JSONB,
185
+ snapshot JSONB,
186
+ rendered_document_hash TEXT,
187
+ seen_document_hash TEXT,
188
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
182
189
  );
183
190
 
184
191
  CREATE INDEX IF NOT EXISTS idx_cortex_report_session_state_analysis
@@ -483,6 +490,8 @@ export async function initCortexState(pool, injected) {
483
490
  "ALTER TABLE cortex_analysis_state DROP COLUMN IF EXISTS profile",
484
491
  "ALTER TABLE cortex_analysis_state DROP COLUMN IF EXISTS input_files",
485
492
  "ALTER TABLE cortex_analysis_state DROP COLUMN IF EXISTS user_id",
493
+ // Billing identity is resolved lazily at charge-open time, never persisted.
494
+ "ALTER TABLE cortex_analysis_state DROP COLUMN IF EXISTS billing_context",
486
495
  // v2 vestiges on cortex_runs.
487
496
  "ALTER TABLE cortex_runs DROP COLUMN IF EXISTS plan",
488
497
  "ALTER TABLE cortex_runs DROP COLUMN IF EXISTS plan_version",
@@ -515,7 +524,6 @@ export async function initCortexState(pool, injected) {
515
524
  "ALTER TABLE cortex_runs ADD COLUMN IF NOT EXISTS synthesis_status TEXT",
516
525
  "ALTER TABLE cortex_runs ADD COLUMN IF NOT EXISTS synthesis_reason TEXT",
517
526
  "ALTER TABLE cortex_analysis_state ADD COLUMN IF NOT EXISTS data_profile_result JSONB",
518
- "ALTER TABLE cortex_analysis_state ADD COLUMN IF NOT EXISTS billing_context JSONB",
519
527
  // Sandbox reliability telemetry (from sandbox-reliability change on main).
520
528
  "ALTER TABLE cortex_step_executions ADD COLUMN IF NOT EXISTS attempts INTEGER NOT NULL DEFAULT 1",
521
529
  "ALTER TABLE cortex_step_executions ADD COLUMN IF NOT EXISTS last_error_class TEXT",
@@ -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
  }
@@ -83,6 +83,16 @@ export declare function queryActiveRun(pool: Querier, analysisId: string, planId
83
83
  */
84
84
  export declare function queryActiveRunsByAnalysis(pool: Querier, analysisId: string): ResultAsync<CortexRunRow[], DbError>;
85
85
  export declare function updateRunStatus(pool: Querier, runId: string, status: "running" | "completed" | "partial" | "failed" | "canceled" | "suspended_insufficient_funds", error?: string | null): ResultAsync<void, DbError>;
86
+ /**
87
+ * Conditionally transition an ACTIVE run to `canceled`, stamping `completed_at`
88
+ * and recording `reason` in `error` (the same column `collectAndComplete`
89
+ * stamps `external_cancel` into). Reports whether a row transitioned — false
90
+ * means the run was already terminal (e.g. it completed concurrently with an
91
+ * external cancel), and the caller MUST NOT fall back to `updateRunStatus`,
92
+ * which would clobber that terminal status. The guarded set is the active set
93
+ * `queryActiveRun` deduplicates on.
94
+ */
95
+ export declare function markRunCanceledIfActive(pool: Querier, runId: string, reason: string): ResultAsync<boolean, DbError>;
86
96
  export declare function promoteFailedToPartial(pool: Querier, runId: string): ResultAsync<boolean, DbError>;
87
97
  export declare function queryRun(pool: Querier, runId: string): ResultAsync<CortexRunRow | null, DbError>;
88
98
  export declare function queryRunsByAnalysis(pool: Querier, analysisId: string, { limit, offset }?: {
@@ -183,6 +183,27 @@ export function updateRunStatus(pool, runId, status, error) {
183
183
  });
184
184
  });
185
185
  }
186
+ /**
187
+ * Conditionally transition an ACTIVE run to `canceled`, stamping `completed_at`
188
+ * and recording `reason` in `error` (the same column `collectAndComplete`
189
+ * stamps `external_cancel` into). Reports whether a row transitioned — false
190
+ * means the run was already terminal (e.g. it completed concurrently with an
191
+ * external cancel), and the caller MUST NOT fall back to `updateRunStatus`,
192
+ * which would clobber that terminal status. The guarded set is the active set
193
+ * `queryActiveRun` deduplicates on.
194
+ */
195
+ export function markRunCanceledIfActive(pool, runId, reason) {
196
+ const now = new Date().toISOString();
197
+ return tryMutation("runs.markRunCanceledIfActive", async () => {
198
+ const result = await pool.query({
199
+ text: `UPDATE cortex_runs
200
+ SET status = 'canceled', completed_at = $2, error = $3
201
+ WHERE run_id = $1 AND status IN ('running','suspended_insufficient_funds')`,
202
+ values: [runId, now, reason],
203
+ });
204
+ return (result.rowCount ?? 0) > 0;
205
+ });
206
+ }
186
207
  export function promoteFailedToPartial(pool, runId) {
187
208
  return tryMutation("runs.promoteFailedToPartial", async () => {
188
209
  const result = await pool.query({
@@ -23,6 +23,7 @@ import type { RunAuthorization, RunAuthorizer } from "../execution/run-authorize
23
23
  import type { StagedInput } from "../execution/staged-input.js";
24
24
  import type { BioToolKeys } from "../tools/bio/keys.js";
25
25
  import type { Logger } from "../lib/logger.js";
26
+ import type { ResolveBilling } from "../billing/resolver.js";
26
27
  import type { UsageRecorder } from "../billing/usage-recorder.js";
27
28
  import type { ChatProvider, EmbeddingProvider } from "../providers/types.js";
28
29
  import type { SandboxClient } from "../sandbox/client.js";
@@ -56,6 +57,9 @@ export interface DataProfileDeps extends EnvironmentStorePaths {
56
57
  readonly skillsDir: string;
57
58
  /** LLM usage-accounting seam for the profiler agent loop; omitted falls back to the no-op recorder. */
58
59
  readonly usageRecorder?: UsageRecorder;
60
+ /** Resolves the billing context stamped as the sandbox pod's OpenCost
61
+ * labels. Absent in upstream-less (dev/OSS) wiring — pods spawn unlabeled. */
62
+ readonly resolveBilling?: ResolveBilling;
59
63
  }
60
64
  /**
61
65
  * Workflow input. JSON-serialisable — DBOS persists it as the workflow's
@@ -42,6 +42,8 @@ import { ensureSearchIndex, searchIndexName } from "../workspace/search-config.j
42
42
  import { DATA_PROFILE_RUN_LITERAL } from "../contracts/data-profile.js";
43
43
  const DATA_PROFILE_STEP_LITERAL = "profile";
44
44
  const DATA_PROFILE_AGENT_ID = "data-profiler";
45
+ /** Resolved-header key carrying the billing-context id (see `ResolveBilling`). */
46
+ const BILLING_CONTEXT_HEADER = "X-Inflexa-Billing-Context";
45
47
  /** Sandbox-server exec budget for the profile run. */
46
48
  const DEFAULT_DEADLINE_MS = 300_000;
47
49
  /** Build the canonical artifact path for a staged input file. */
@@ -226,12 +228,24 @@ export async function runDataProfileBody(input, deps) {
226
228
  // from the agent loop would leave the longest wait of all unreported — the same defect the
227
229
  // run panel's activity readout was built to remove.
228
230
  await activity.sandboxInit();
231
+ let billingContextId;
232
+ if (deps.resolveBilling) {
233
+ try {
234
+ billingContextId = (await deps.resolveBilling(childSession))[BILLING_CONTEXT_HEADER];
235
+ }
236
+ catch (err) {
237
+ logger.error("[billing] data-profile billing resolution failed", logger.errorFields(err));
238
+ }
239
+ if (!billingContextId)
240
+ logger.error("[billing] sandbox spawned without billing labels");
241
+ }
229
242
  const sandbox = await deps.sandboxClient.createSandbox({
230
243
  runId: DATA_PROFILE_RUN_LITERAL,
231
244
  stepId: DATA_PROFILE_STEP_LITERAL,
232
245
  analysisId,
233
246
  childWorkflowId: workflowId,
234
247
  resources: estimateDataProfileResources(stagedInputs),
248
+ billing: billingContextId ? { billingContextId, userId: childSession.identity.user } : undefined,
235
249
  }, mintSandboxIdentity(DATA_PROFILE_RUN_LITERAL));
236
250
  try {
237
251
  // Checkpointed clock, not `Date.now()`: `awaitExec` gates on this absolute
@@ -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 {};