@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.
- package/dist/agents/report-session-agent.d.ts +7 -0
- package/dist/agents/report-session-agent.js +2 -1
- package/dist/auth/local-run-authorizer.d.ts +3 -2
- package/dist/auth/local-run-authorizer.js +4 -2
- package/dist/execution/run-authorizer.d.ts +18 -0
- package/dist/execution/run-canceler.d.ts +67 -0
- package/dist/execution/run-canceler.js +130 -0
- package/dist/index.d.ts +5 -2
- package/dist/index.js +6 -1
- package/dist/literature/sources/pubmed.js +2 -1
- package/dist/report-render/assets.d.ts +42 -0
- package/dist/report-render/assets.js +61 -0
- package/dist/report-render/chart.js +31 -10
- package/dist/report-render/design.d.ts +120 -0
- package/dist/report-render/design.js +808 -0
- package/dist/report-render/page.d.ts +32 -115
- package/dist/report-render/page.js +130 -273
- package/dist/report-render/render.js +34 -11
- package/dist/report-render/views/chart-view.d.ts +6 -3
- package/dist/report-render/views/chart-view.js +10 -5
- package/dist/report-render/views/page-view.d.ts +19 -8
- package/dist/report-render/views/page-view.js +31 -11
- package/dist/report-render/views/prose.d.ts +1 -0
- package/dist/report-render/views/prose.js +7 -13
- package/dist/report-render/views/references-view.js +5 -5
- package/dist/report-render/views/values.d.ts +15 -6
- package/dist/report-render/views/values.js +29 -10
- package/dist/runtime/assemble.d.ts +10 -0
- package/dist/runtime/assemble.js +1 -0
- package/dist/sandbox/k8s-client.js +12 -1
- package/dist/sandbox/types.d.ts +4 -0
- package/dist/state/analyses.d.ts +3 -26
- package/dist/state/analyses.js +6 -64
- package/dist/state/index.d.ts +2 -2
- package/dist/state/index.js +2 -2
- package/dist/state/init.js +3 -3
- package/dist/state/runs.d.ts +10 -0
- package/dist/state/runs.js +21 -0
- package/dist/tasks/data-profile.d.ts +7 -0
- package/dist/tasks/data-profile.js +14 -0
- package/dist/tools/approval/gateway.d.ts +8 -1
- package/dist/tools/approval/gateway.js +9 -2
- package/dist/tools/approval/queries.d.ts +9 -2
- package/dist/tools/approval/queries.js +12 -4
- package/dist/tools/report-session/index.d.ts +1 -1
- package/dist/tools/report-session/index.js +1 -1
- package/dist/tools/report-session/preview-report.d.ts +11 -1
- package/dist/tools/report-session/preview-report.js +35 -12
- package/dist/workflows/sandbox-step.d.ts +7 -0
- package/dist/workflows/sandbox-step.js +19 -2
- package/package.json +5 -1
package/dist/state/analyses.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `cortex_analysis_state` operations — analysis lifecycle
|
|
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`
|
|
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,
|
|
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,
|
|
20
|
+
(analysis_id, status, context, data_profile_status,
|
|
29
21
|
seed_input_file_ids, created_at, updated_at)
|
|
30
|
-
VALUES ($1, 'active', $2,
|
|
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
|
-
}
|
package/dist/state/index.d.ts
CHANGED
|
@@ -8,10 +8,10 @@
|
|
|
8
8
|
*/
|
|
9
9
|
export { initCortexState } from "./init.js";
|
|
10
10
|
export type { Querier } from "./db.js";
|
|
11
|
-
export { upsertAnalysis,
|
|
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";
|
package/dist/state/index.js
CHANGED
|
@@ -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,
|
|
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";
|
package/dist/state/init.js
CHANGED
|
@@ -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
|
|
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",
|
package/dist/state/runs.d.ts
CHANGED
|
@@ -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 }?: {
|
package/dist/state/runs.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
67
|
-
|
|
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
|
-
/**
|
|
104
|
-
|
|
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
|
|
108
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
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(() =>
|
|
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.
|
|
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",
|