@inflexa-ai/harness 0.19.1 → 0.20.0

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 (51) hide show
  1. package/dist/agents/report-session-agent.d.ts +7 -0
  2. package/dist/agents/report-session-agent.js +2 -1
  3. package/dist/auth/local-run-authorizer.d.ts +3 -2
  4. package/dist/auth/local-run-authorizer.js +4 -2
  5. package/dist/execution/run-authorizer.d.ts +18 -0
  6. package/dist/execution/run-canceler.d.ts +67 -0
  7. package/dist/execution/run-canceler.js +130 -0
  8. package/dist/index.d.ts +5 -2
  9. package/dist/index.js +6 -1
  10. package/dist/literature/sources/pubmed.js +2 -1
  11. package/dist/report-render/assets.d.ts +42 -0
  12. package/dist/report-render/assets.js +61 -0
  13. package/dist/report-render/chart.js +31 -10
  14. package/dist/report-render/design.d.ts +120 -0
  15. package/dist/report-render/design.js +808 -0
  16. package/dist/report-render/page.d.ts +32 -115
  17. package/dist/report-render/page.js +130 -273
  18. package/dist/report-render/render.js +34 -11
  19. package/dist/report-render/views/chart-view.d.ts +6 -3
  20. package/dist/report-render/views/chart-view.js +10 -5
  21. package/dist/report-render/views/page-view.d.ts +19 -8
  22. package/dist/report-render/views/page-view.js +31 -11
  23. package/dist/report-render/views/prose.d.ts +1 -0
  24. package/dist/report-render/views/prose.js +7 -13
  25. package/dist/report-render/views/references-view.js +5 -5
  26. package/dist/report-render/views/values.d.ts +15 -6
  27. package/dist/report-render/views/values.js +29 -10
  28. package/dist/runtime/assemble.d.ts +10 -0
  29. package/dist/runtime/assemble.js +1 -0
  30. package/dist/sandbox/k8s-client.js +12 -1
  31. package/dist/sandbox/types.d.ts +4 -0
  32. package/dist/state/analyses.d.ts +3 -26
  33. package/dist/state/analyses.js +6 -64
  34. package/dist/state/index.d.ts +2 -2
  35. package/dist/state/index.js +2 -2
  36. package/dist/state/init.js +3 -3
  37. package/dist/state/runs.d.ts +10 -0
  38. package/dist/state/runs.js +21 -0
  39. package/dist/tasks/data-profile.d.ts +7 -0
  40. package/dist/tasks/data-profile.js +14 -0
  41. package/dist/tools/approval/gateway.d.ts +8 -1
  42. package/dist/tools/approval/gateway.js +9 -2
  43. package/dist/tools/approval/queries.d.ts +9 -2
  44. package/dist/tools/approval/queries.js +12 -4
  45. package/dist/tools/report-session/index.d.ts +1 -1
  46. package/dist/tools/report-session/index.js +1 -1
  47. package/dist/tools/report-session/preview-report.d.ts +11 -1
  48. package/dist/tools/report-session/preview-report.js +35 -12
  49. package/dist/workflows/sandbox-step.d.ts +7 -0
  50. package/dist/workflows/sandbox-step.js +19 -2
  51. package/package.json +5 -1
@@ -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 --
@@ -491,6 +490,8 @@ export async function initCortexState(pool, injected) {
491
490
  "ALTER TABLE cortex_analysis_state DROP COLUMN IF EXISTS profile",
492
491
  "ALTER TABLE cortex_analysis_state DROP COLUMN IF EXISTS input_files",
493
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",
494
495
  // v2 vestiges on cortex_runs.
495
496
  "ALTER TABLE cortex_runs DROP COLUMN IF EXISTS plan",
496
497
  "ALTER TABLE cortex_runs DROP COLUMN IF EXISTS plan_version",
@@ -523,7 +524,6 @@ export async function initCortexState(pool, injected) {
523
524
  "ALTER TABLE cortex_runs ADD COLUMN IF NOT EXISTS synthesis_status TEXT",
524
525
  "ALTER TABLE cortex_runs ADD COLUMN IF NOT EXISTS synthesis_reason TEXT",
525
526
  "ALTER TABLE cortex_analysis_state ADD COLUMN IF NOT EXISTS data_profile_result JSONB",
526
- "ALTER TABLE cortex_analysis_state ADD COLUMN IF NOT EXISTS billing_context JSONB",
527
527
  // Sandbox reliability telemetry (from sandbox-reliability change on main).
528
528
  "ALTER TABLE cortex_step_executions ADD COLUMN IF NOT EXISTS attempts INTEGER NOT NULL DEFAULT 1",
529
529
  "ALTER TABLE cortex_step_executions ADD COLUMN IF NOT EXISTS last_error_class TEXT",
@@ -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({
@@ -56,6 +56,13 @@ export interface DataProfileDeps extends EnvironmentStorePaths {
56
56
  readonly skillsDir: string;
57
57
  /** LLM usage-accounting seam for the profiler agent loop; omitted falls back to the no-op recorder. */
58
58
  readonly usageRecorder?: UsageRecorder;
59
+ /**
60
+ * Host-supplied labels for the profiler's sandbox pod, resolved under the
61
+ * profiling session. The map is opaque to the harness: it stamps each entry
62
+ * and interprets none of it. Absent in a wiring that attributes nothing —
63
+ * the pod then carries the harness's own labels only.
64
+ */
65
+ readonly resolvePodLabels?: (session: RunSession) => Promise<Record<string, string>>;
59
66
  }
60
67
  /**
61
68
  * Workflow input. JSON-serialisable — DBOS persists it as the workflow's
@@ -226,12 +226,26 @@ export async function runDataProfileBody(input, deps) {
226
226
  // from the agent loop would leave the longest wait of all unreported — the same defect the
227
227
  // run panel's activity readout was built to remove.
228
228
  await activity.sandboxInit();
229
+ let podLabels;
230
+ if (deps.resolvePodLabels) {
231
+ try {
232
+ podLabels = await deps.resolvePodLabels(childSession);
233
+ }
234
+ catch (err) {
235
+ logger.warn("pod-label resolution failed", logger.errorFields(err));
236
+ }
237
+ // A wired resolver that yields nothing is the one loud case: the host
238
+ // asked for attribution and the pod spawns without it.
239
+ if (!podLabels || Object.keys(podLabels).length === 0)
240
+ logger.warn("sandbox spawned with no pod 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
+ podLabels,
235
249
  }, mintSandboxIdentity(DATA_PROFILE_RUN_LITERAL));
236
250
  try {
237
251
  // Checkpointed clock, not `Date.now()`: `awaitExec` gates on this absolute
@@ -34,6 +34,13 @@ export interface AskGateway {
34
34
  ask(request: AskRequest, ctx: AskContext): Promise<AskApproval>;
35
35
  answer(id: string, reply: AskReply): Promise<AnswerOutcome>;
36
36
  pending(): Promise<PendingAsk[]>;
37
- sweepExpired(): Promise<number>;
37
+ /** Expire pending asks older than `maxAgeMs` (default {@link DEFAULT_SWEEP_MAX_AGE_MS}). */
38
+ sweepExpired(maxAgeMs?: number): Promise<number>;
38
39
  }
40
+ /**
41
+ * Pending rows older than this are treated as orphans of a dead process. Far
42
+ * beyond any plausible live turn, so a boot sweep during a rolling deployment
43
+ * cannot expire an ask another pod is still polling.
44
+ */
45
+ export declare const DEFAULT_SWEEP_MAX_AGE_MS: number;
39
46
  export declare function createAskGateway(deps: AskGatewayDeps): AskGateway;
@@ -15,6 +15,12 @@ import { answerAsk, insertGrantedAsk, insertPendingAsk, markAborted, selectAskRe
15
15
  import { uuidv7 } from "./uuidv7.js";
16
16
  /** Fixed poll cadence — imperceptible against human decision speed, low chatter. */
17
17
  const POLL_INTERVAL_MS = 200;
18
+ /**
19
+ * Pending rows older than this are treated as orphans of a dead process. Far
20
+ * beyond any plausible live turn, so a boot sweep during a rolling deployment
21
+ * cannot expire an ask another pod is still polling.
22
+ */
23
+ export const DEFAULT_SWEEP_MAX_AGE_MS = 24 * 60 * 60 * 1000;
18
24
  export function createAskGateway(deps) {
19
25
  const { pool } = deps;
20
26
  async function ask(request, ctx) {
@@ -74,8 +80,9 @@ export function createAskGateway(deps) {
74
80
  async function pending() {
75
81
  return unwrapOrThrow(await selectPending(pool));
76
82
  }
77
- async function sweepExpired() {
78
- return unwrapOrThrow(await sweepExpiredRows(pool, new Date().toISOString()));
83
+ async function sweepExpired(maxAgeMs = DEFAULT_SWEEP_MAX_AGE_MS) {
84
+ const now = Date.now();
85
+ return unwrapOrThrow(await sweepExpiredRows(pool, new Date(now).toISOString(), new Date(now - maxAgeMs).toISOString()));
79
86
  }
80
87
  return { ask, answer, pending, sweepExpired };
81
88
  }
@@ -63,8 +63,15 @@ export declare function selectAskResolution(querier: Querier, id: string): Resul
63
63
  export declare function markAborted(querier: Querier, id: string, resolvedAt: string): ResultAsync<void, DbError>;
64
64
  /** Enumerate every unresolved ask, oldest first. */
65
65
  export declare function selectPending(querier: Querier): ResultAsync<PendingAsk[], DbError>;
66
- /** Sweep every `pending` row to `expired`, returning the count swept. */
67
- export declare function sweepExpired(querier: Querier, expiredAt: string): ResultAsync<number, DbError>;
66
+ /**
67
+ * Sweep `pending` rows created before `olderThan` to `expired`, returning the
68
+ * count swept. The age predicate is what keeps a boot sweep from expiring an ask
69
+ * another live pod is still polling — only rows old enough to be orphans of a
70
+ * dead process are touched. Both bounds compare as text: every `created_at` is a
71
+ * fixed-width UTC ISO string, so lexicographic order is chronological (the same
72
+ * property `selectPending`'s ORDER BY relies on).
73
+ */
74
+ export declare function sweepExpired(querier: Querier, expiredAt: string, olderThan: string): ResultAsync<number, DbError>;
68
75
  /**
69
76
  * Record a decision against a still-`pending` row and, for an `always` reply,
70
77
  * write the standing grant — both in one transaction, so a crash between them can
@@ -100,12 +100,20 @@ export function selectPending(querier) {
100
100
  }));
101
101
  });
102
102
  }
103
- /** Sweep every `pending` row to `expired`, returning the count swept. */
104
- export function sweepExpired(querier, expiredAt) {
103
+ /**
104
+ * Sweep `pending` rows created before `olderThan` to `expired`, returning the
105
+ * count swept. The age predicate is what keeps a boot sweep from expiring an ask
106
+ * another live pod is still polling — only rows old enough to be orphans of a
107
+ * dead process are touched. Both bounds compare as text: every `created_at` is a
108
+ * fixed-width UTC ISO string, so lexicographic order is chronological (the same
109
+ * property `selectPending`'s ORDER BY relies on).
110
+ */
111
+ export function sweepExpired(querier, expiredAt, olderThan) {
105
112
  return tryMutation("asks.sweepExpired", async () => {
106
113
  const result = await querier.query({
107
- text: `UPDATE cortex_asks SET status = 'expired', resolved_at = $1 WHERE status = 'pending'`,
108
- values: [expiredAt],
114
+ text: `UPDATE cortex_asks SET status = 'expired', resolved_at = $1
115
+ WHERE status = 'pending' AND created_at < $2`,
116
+ values: [expiredAt, olderThan],
109
117
  });
110
118
  return result.rowCount ?? 0;
111
119
  });
@@ -5,6 +5,6 @@
5
5
  * render seams, thus each tool binds the per-session state to the thread of the call. The barrel exports
6
6
  * the factories and their public types for the composition root.
7
7
  */
8
- export { createPreviewReportTool, type PreviewReportInput, type PreviewReportResult, type PreviewReportToolDeps } from "./preview-report.js";
8
+ export { createPreviewReportTool, type PreviewReportInput, type PreviewReportResult, type PreviewReportToolDeps, type ResolvePageAsset, } from "./preview-report.js";
9
9
  export { createExaminePageTool, type CapturePage, type ExaminePageInput, type ExaminePageResult, type ExaminePageToolDeps, type FailedRequest, type PageCapture, } from "./examine-page.js";
10
10
  export { createRecordVersionTool, type RecordVersionInput, type RecordVersionResult, type RecordVersionToolDeps } from "./record-version.js";
@@ -5,6 +5,6 @@
5
5
  * render seams, thus each tool binds the per-session state to the thread of the call. The barrel exports
6
6
  * the factories and their public types for the composition root.
7
7
  */
8
- export { createPreviewReportTool } from "./preview-report.js";
8
+ export { createPreviewReportTool, } from "./preview-report.js";
9
9
  export { createExaminePageTool, } from "./examine-page.js";
10
10
  export { createRecordVersionTool } from "./record-version.js";
@@ -6,7 +6,7 @@
6
6
  * resolves each reference through the injected resolver, bridges the resolved values into the render model,
7
7
  * and renders the page.
8
8
  *
9
- * The page and its staged image assets land in `report-sessions/{threadId}/` under the workspace root. That
9
+ * The page and its staged assets land in `report-sessions/{threadId}/` under the workspace root. That
10
10
  * namespace belongs to this path alone, thus the tool never writes under the old `previews/` or `reports/`
11
11
  * trees. The result carries the absolute page path, thus a local host shows the page with no seam.
12
12
  *
@@ -77,6 +77,11 @@ export type PreviewReportResult = {
77
77
  outcome: "rendered";
78
78
  pagePath: string;
79
79
  };
80
+ /**
81
+ * The lookup of the source file of one staged asset. It maps the module specifier of a manifest entry
82
+ * (`report-render/assets.ts`) onto an absolute path on disk.
83
+ */
84
+ export type ResolvePageAsset = (specifier: string) => string;
80
85
  /**
81
86
  * The construction deps of the preview tool.
82
87
  *
@@ -84,6 +89,10 @@ export type PreviewReportResult = {
84
89
  * serves every analysis and it resolves the root per call from the scope. `makeResolver` is optional, because
85
90
  * a resolver realization can be absent. It binds one analysis, thus the tool makes the resolver over the
86
91
  * scope of the call.
92
+ *
93
+ * `resolvePageAsset` is optional, and absent it resolves each specifier against the installation of the
94
+ * harness. An embedder that ships the asset bytes packed, for example a compiled single-file binary with no
95
+ * `node_modules` tree, materializes them to disk and binds its own lookup here.
87
96
  */
88
97
  export interface PreviewReportToolDeps {
89
98
  readonly gateway: ReportSessionStateGateway;
@@ -92,6 +101,7 @@ export interface PreviewReportToolDeps {
92
101
  auth: AuthContext;
93
102
  }) => ReferenceResolver;
94
103
  readonly resolveWorkspaceRoot: ResolveWorkspaceRoot;
104
+ readonly resolvePageAsset?: ResolvePageAsset;
95
105
  readonly logger?: Logger;
96
106
  }
97
107
  /**
@@ -6,7 +6,7 @@
6
6
  * resolves each reference through the injected resolver, bridges the resolved values into the render model,
7
7
  * and renders the page.
8
8
  *
9
- * The page and its staged image assets land in `report-sessions/{threadId}/` under the workspace root. That
9
+ * The page and its staged assets land in `report-sessions/{threadId}/` under the workspace root. That
10
10
  * namespace belongs to this path alone, thus the tool never writes under the old `previews/` or `reports/`
11
11
  * trees. The result carries the absolute page path, thus a local host shows the page with no seam.
12
12
  *
@@ -26,6 +26,7 @@
26
26
  */
27
27
  import { err, ok } from "neverthrow";
28
28
  import { copyFile, mkdir, writeFile } from "node:fs/promises";
29
+ import { createRequire } from "node:module";
29
30
  import { extname, join } from "node:path";
30
31
  import { z } from "zod";
31
32
  import { resolveWorkspacePath } from "../../workspace/paths.js";
@@ -36,6 +37,7 @@ import { computeDraftHash } from "../../report-model/draft-hash.js";
36
37
  import { finishDraft } from "../../report-model/draft-finish.js";
37
38
  import { resolveDocumentReferences } from "../../report-model/validate.js";
38
39
  import { bridgeValues } from "../../report-render/value-bridge.js";
40
+ import { PAGE_ASSETS } from "../../report-render/assets.js";
39
41
  import { renderReportPage } from "../../report-render/render.js";
40
42
  import { defineTool } from "../define-tool.js";
41
43
  import { openReportThread } from "../report-authoring/authoring-tools.js";
@@ -56,6 +58,13 @@ function assetFileName(file) {
56
58
  function figureSourcePolicy(file) {
57
59
  return `assets/${assetFileName(file)}`;
58
60
  }
61
+ /**
62
+ * The resolver of a package source. A manifest entry names a module specifier, and the resolution reads
63
+ * the installation of the harness. Thus the staged bytes are the bytes of the pinned version.
64
+ */
65
+ const moduleResolver = createRequire(import.meta.url);
66
+ /** The default asset lookup: the module resolution of the installation of the harness. */
67
+ const resolvePageAssetFromInstallation = (specifier) => moduleResolver.resolve(specifier);
59
68
  /**
60
69
  * Pair each block with the resolved value of its binding, in document order.
61
70
  *
@@ -116,7 +125,7 @@ async function resolveDocument(document, resolver, snapshot) {
116
125
  return { resolutions: collectResolutions(document.sections, resolvedByBlock), unresolved: [] };
117
126
  }
118
127
  /**
119
- * Resolve the page root, stage each bound image, and write the page.
128
+ * Resolve the page root, stage each bound image and each manifest entry, and write the page.
120
129
  *
121
130
  * The workspace-root seam signals an unresolvable resource by a throw, thus the resolution sits inside
122
131
  * the protection and its throw becomes a value. Each `fs` call runs through `tryFsWrite`, the one
@@ -126,6 +135,10 @@ async function resolveDocument(document, resolver, snapshot) {
126
135
  * A bound figure names a snapshot path, which is untrusted. A registered `../../` path escapes the root,
127
136
  * thus the containment test runs before any copy and reuses the one workspace-path resolver. A source
128
137
  * outside the root refuses and names the block, and no copy runs.
138
+ *
139
+ * The page references the chart runtime and the fonts under the same `assets/` directory, thus one copy
140
+ * loop stages the figures and the manifest entries together. The manifest is never empty, thus the assets
141
+ * directory exists beside every page.
129
142
  */
130
143
  async function renderToWorkspace(args) {
131
144
  let root;
@@ -155,20 +168,28 @@ async function renderToWorkspace(args) {
155
168
  }
156
169
  sources.set(name, resolved.absolute);
157
170
  }
171
+ // Each manifest entry, mapped from its staged name to the file that the asset lookup gives. A specifier
172
+ // that does not resolve is a fault of the installation, thus it rides the `fs` kind.
173
+ for (const asset of PAGE_ASSETS) {
174
+ try {
175
+ sources.set(asset.file, args.resolvePageAsset(asset.specifier));
176
+ }
177
+ catch (cause) {
178
+ return err({ kind: "fs", error: { type: "read_failed", op: "preview.resolveAsset", path: asset.specifier, cause } });
179
+ }
180
+ }
158
181
  const madeSession = await tryFsWrite("preview.mkdir", () => mkdir(sessionDir, { recursive: true }), { path: sessionDir });
159
182
  if (madeSession.isErr()) {
160
183
  return err({ kind: "fs", error: madeSession.error });
161
184
  }
162
- if (sources.size > 0) {
163
- const madeAssets = await tryFsWrite("preview.mkdir", () => mkdir(assetsDir, { recursive: true }), { path: assetsDir });
164
- if (madeAssets.isErr()) {
165
- return err({ kind: "fs", error: madeAssets.error });
166
- }
167
- for (const [name, source] of sources) {
168
- const copied = await tryFsWrite("preview.copyFile", () => copyFile(source, join(assetsDir, name)), { path: source });
169
- if (copied.isErr()) {
170
- return err({ kind: "fs", error: copied.error });
171
- }
185
+ const madeAssets = await tryFsWrite("preview.mkdir", () => mkdir(assetsDir, { recursive: true }), { path: assetsDir });
186
+ if (madeAssets.isErr()) {
187
+ return err({ kind: "fs", error: madeAssets.error });
188
+ }
189
+ for (const [name, source] of sources) {
190
+ const copied = await tryFsWrite("preview.copyFile", () => copyFile(source, join(assetsDir, name)), { path: source });
191
+ if (copied.isErr()) {
192
+ return err({ kind: "fs", error: copied.error });
172
193
  }
173
194
  }
174
195
  const wrote = await tryFsWrite("preview.writeFile", () => writeFile(pagePath, args.page, "utf8"), { path: pagePath });
@@ -186,6 +207,7 @@ async function renderToWorkspace(args) {
186
207
  */
187
208
  export function createPreviewReportTool(deps) {
188
209
  const logger = (deps.logger ?? createNoopLogger()).named("preview-report");
210
+ const resolvePageAsset = deps.resolvePageAsset ?? resolvePageAssetFromInstallation;
189
211
  return defineTool({
190
212
  id: "preview_report",
191
213
  description: "Render the current draft to a self-contained HTML page, and give back the page path. " +
@@ -235,6 +257,7 @@ export function createPreviewReportTool(deps) {
235
257
  }
236
258
  const written = await renderToWorkspace({
237
259
  resolveWorkspaceRoot: deps.resolveWorkspaceRoot,
260
+ resolvePageAsset,
238
261
  analysisId,
239
262
  threadId,
240
263
  resolutions,
@@ -232,6 +232,13 @@ export interface SandboxStepDeps {
232
232
  /** Write-side embedder for the post-step vector index. */
233
233
  readonly embedding: EmbeddingProvider;
234
234
  readonly sandboxClient: SandboxClient;
235
+ /**
236
+ * Host-supplied labels for this step's sandbox pod, resolved under the
237
+ * step's session. The map is opaque to the harness: it stamps each entry
238
+ * and interprets none of it. Absent in a wiring that attributes nothing —
239
+ * the pod then carries the harness's own labels only.
240
+ */
241
+ readonly resolvePodLabels?: (session: RunSession) => Promise<Record<string, string>>;
235
242
  /**
236
243
  * External artifact registration + sync seam. The harness's post-step pipeline
237
244
  * registers each step's outputs through it (filesystem index in the
@@ -151,7 +151,9 @@ export async function runSandboxStepBody(input, deps) {
151
151
  const identity = await DBOS.runStep(() => Promise.resolve(mintSandboxIdentity(input.runId)), { name: "sandbox.mint" });
152
152
  // (2b) sandbox.create — spawn (or adopt) the machine under the minted
153
153
  // identity. The handle (secret included) is cached so recovery picks the
154
- // same machine back up without re-provisioning.
154
+ // same machine back up without re-provisioning. Pod-label resolution lives
155
+ // INSIDE this step (not as its own step) so the child's step sequence is
156
+ // unchanged — in-flight workflows resumed across a deploy replay cleanly.
155
157
  const sandboxMeta = {
156
158
  runId: input.runId,
157
159
  stepId: input.stepId,
@@ -161,7 +163,22 @@ export async function runSandboxStepBody(input, deps) {
161
163
  extraEnv: input.extraEnv,
162
164
  resources: input.resources,
163
165
  };
164
- const sandbox = await DBOS.runStep(() => deps.sandboxClient.createSandbox(sandboxMeta, identity), { name: "sandbox.create" });
166
+ const sandbox = await DBOS.runStep(async () => {
167
+ let podLabels;
168
+ if (deps.resolvePodLabels) {
169
+ try {
170
+ podLabels = await deps.resolvePodLabels(session);
171
+ }
172
+ catch (err) {
173
+ logger.warn("pod-label resolution failed", { analysisId: input.analysisId, ...logger.errorFields(err) });
174
+ }
175
+ // A wired resolver that yields nothing is the one loud case: the
176
+ // host asked for attribution and the pod spawns without it.
177
+ if (!podLabels || Object.keys(podLabels).length === 0)
178
+ logger.warn("sandbox spawned with no pod labels", { analysisId: input.analysisId });
179
+ }
180
+ return deps.sandboxClient.createSandbox({ ...sandboxMeta, podLabels }, identity);
181
+ }, { name: "sandbox.create" });
165
182
  // Recovery path: re-check `isAlive` on the persisted ref before continuing.
166
183
  // A classified-dead sandbox triggers a fresh `createSandbox` (task 4.6).
167
184
  // For first execution this is a cheap no-op; on replay it's the seam
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@inflexa-ai/harness",
3
- "version": "0.19.1",
3
+ "version": "0.20.0",
4
4
  "description": "Host-agnostic bioinformatics agent harness \u2014 hand-rolled agent loop, DBOS-durable workflows, sandboxed compute.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -34,6 +34,7 @@
34
34
  },
35
35
  "scripts": {
36
36
  "build": "tsc -p tsconfig.json",
37
+ "design:fixture": "bun scripts/render-fixture.ts",
37
38
  "format": "prettier --write src/",
38
39
  "format:file": "prettier --write",
39
40
  "lint": "eslint .",
@@ -51,6 +52,8 @@
51
52
  "@ai-sdk/provider-utils": "5.0.27",
52
53
  "@anthropic-ai/sdk": "^0.116.0",
53
54
  "@dbos-inc/dbos-sdk": "^4.23.6",
55
+ "@fontsource-variable/space-grotesk": "5.1.1",
56
+ "@fontsource/ibm-plex-mono": "5.1.1",
54
57
  "@kubernetes/client-node": "^1.4.0",
55
58
  "@opentelemetry/api": "^1.9.1",
56
59
  "@opentelemetry/core": "^2.9.0",
@@ -64,6 +67,7 @@
64
67
  "ai": "^7.0.61",
65
68
  "cheerio": "^1.2.0",
66
69
  "dockerode": "^4.0.12",
70
+ "echarts": "5.5.1",
67
71
  "fast-xml-parser": "^5.10.0",
68
72
  "hono": "^4.12.30",
69
73
  "hyparquet": "^1.28.1",