@inflexa-ai/harness 0.14.2 → 0.15.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (128) hide show
  1. package/README.md +2 -1
  2. package/dist/agents/conversation-agent.d.ts +7 -0
  3. package/dist/agents/conversation-agent.js +5 -3
  4. package/dist/agents/sandbox/drug-repurposing-agent.js +5 -1
  5. package/dist/agents/sandbox/immune-profiling-agent.js +4 -1
  6. package/dist/app/synthesize-run.d.ts +3 -0
  7. package/dist/app/synthesize-run.js +2 -1
  8. package/dist/billing/noop-usage-recorder.d.ts +7 -0
  9. package/dist/billing/noop-usage-recorder.js +10 -0
  10. package/dist/billing/usage-recorder.d.ts +63 -0
  11. package/dist/billing/usage-recorder.js +15 -0
  12. package/dist/contracts/chat-events.d.ts +44 -3
  13. package/dist/contracts/chat-parts.d.ts +38 -2
  14. package/dist/contracts/chat-parts.js +1 -1
  15. package/dist/contracts/data-profile.d.ts +26 -0
  16. package/dist/contracts/data-profile.js +26 -0
  17. package/dist/contracts/index.d.ts +3 -1
  18. package/dist/contracts/index.js +1 -0
  19. package/dist/contracts/message.d.ts +21 -1
  20. package/dist/contracts/part-registry.js +1 -0
  21. package/dist/contracts/schemas/chat-events.d.ts +48 -2
  22. package/dist/contracts/schemas/chat-events.js +8 -1
  23. package/dist/contracts/schemas/chat-parts.d.ts +46 -3
  24. package/dist/contracts/schemas/chat-parts.js +13 -0
  25. package/dist/contracts/schemas/index.d.ts +2 -1
  26. package/dist/contracts/schemas/index.js +2 -1
  27. package/dist/contracts/schemas/usage.d.ts +11 -0
  28. package/dist/contracts/schemas/usage.js +18 -0
  29. package/dist/contracts/usage.d.ts +51 -0
  30. package/dist/contracts/usage.js +41 -0
  31. package/dist/execution/artifact-metadata.d.ts +3 -0
  32. package/dist/execution/artifact-metadata.js +1 -0
  33. package/dist/execution/dbos-workflow-purger.d.ts +30 -0
  34. package/dist/execution/dbos-workflow-purger.js +193 -0
  35. package/dist/execution/post-step-pipeline.d.ts +3 -0
  36. package/dist/execution/post-step-pipeline.js +2 -0
  37. package/dist/execution/reconcile-manifest.js +28 -15
  38. package/dist/execution/report-runner.d.ts +16 -0
  39. package/dist/execution/report-runner.js +4 -0
  40. package/dist/execution/run-synthesis.d.ts +3 -0
  41. package/dist/execution/run-synthesis.js +2 -0
  42. package/dist/execution/step-summary.d.ts +3 -0
  43. package/dist/execution/step-summary.js +1 -0
  44. package/dist/execution/workflow-purger.d.ts +38 -0
  45. package/dist/execution/workflow-purger.js +24 -0
  46. package/dist/index.d.ts +24 -2
  47. package/dist/index.js +27 -0
  48. package/dist/lib/metrics.js +2 -1
  49. package/dist/loop/metrics.d.ts +18 -0
  50. package/dist/loop/metrics.js +18 -1
  51. package/dist/loop/run-agent.d.ts +35 -0
  52. package/dist/loop/run-agent.js +149 -23
  53. package/dist/loop/run-to-terminal.d.ts +5 -2
  54. package/dist/loop/run-to-terminal.js +36 -2
  55. package/dist/loop/tool-detail.d.ts +54 -0
  56. package/dist/loop/tool-detail.js +105 -0
  57. package/dist/loop/tool-outcome.d.ts +21 -0
  58. package/dist/loop/tool-outcome.js +25 -0
  59. package/dist/loop/types.d.ts +15 -1
  60. package/dist/memory/content-to-cortex.d.ts +28 -4
  61. package/dist/memory/content-to-cortex.js +70 -14
  62. package/dist/memory/thread-history.d.ts +21 -1
  63. package/dist/memory/thread-history.js +29 -7
  64. package/dist/memory/thread-store.d.ts +65 -8
  65. package/dist/memory/thread-store.js +81 -17
  66. package/dist/prompts/sandbox/bulk-transcriptomics-agent.d.ts +1 -1
  67. package/dist/prompts/sandbox/bulk-transcriptomics-agent.js +17 -13
  68. package/dist/prompts/sandbox/chromatin-agent.d.ts +1 -1
  69. package/dist/prompts/sandbox/chromatin-agent.js +45 -54
  70. package/dist/prompts/sandbox/genomic-variant-agent.d.ts +1 -1
  71. package/dist/prompts/sandbox/genomic-variant-agent.js +39 -37
  72. package/dist/prompts/sandbox-standards.d.ts +1 -1
  73. package/dist/prompts/sandbox-standards.js +21 -2
  74. package/dist/provenance/collector.d.ts +5 -3
  75. package/dist/provenance/collector.js +5 -3
  76. package/dist/providers/ai-sdk.js +91 -16
  77. package/dist/providers/streaming-chat.js +5 -0
  78. package/dist/providers/types.d.ts +23 -0
  79. package/dist/providers/types.js +1 -0
  80. package/dist/reference-data/catalog.js +536 -0
  81. package/dist/runtime/assemble.d.ts +25 -8
  82. package/dist/runtime/assemble.js +7 -4
  83. package/dist/sandbox/sandbox-step-translate.d.ts +22 -1
  84. package/dist/sandbox/sandbox-step-translate.js +23 -36
  85. package/dist/schemas/run-synthesis.d.ts +2 -2
  86. package/dist/state/index.d.ts +2 -0
  87. package/dist/state/index.js +3 -0
  88. package/dist/state/init.js +28 -0
  89. package/dist/state/purge-analysis.d.ts +74 -0
  90. package/dist/state/purge-analysis.js +205 -0
  91. package/dist/state/vector-store.d.ts +6 -0
  92. package/dist/state/vector-store.js +22 -2
  93. package/dist/tasks/data-profile-activity.d.ts +14 -2
  94. package/dist/tasks/data-profile-activity.js +1 -1
  95. package/dist/tasks/data-profile.d.ts +3 -0
  96. package/dist/tasks/data-profile.js +9 -3
  97. package/dist/tools/bio/pubmed.js +15 -0
  98. package/dist/tools/bio/search-gene.js +12 -0
  99. package/dist/tools/define-tool.d.ts +41 -3
  100. package/dist/tools/define-tool.js +4 -1
  101. package/dist/tools/detail-resolver.d.ts +35 -0
  102. package/dist/tools/detail-resolver.js +39 -0
  103. package/dist/tools/execute-analysis.js +3 -0
  104. package/dist/tools/iterate-report.d.ts +3 -0
  105. package/dist/tools/iterate-report.js +3 -0
  106. package/dist/tools/memory/update-working-memory.js +8 -0
  107. package/dist/tools/research/generate-analogy-report.d.ts +3 -0
  108. package/dist/tools/research/generate-analogy-report.js +6 -0
  109. package/dist/tools/research/generate-plan.d.ts +3 -0
  110. package/dist/tools/research/generate-plan.js +6 -0
  111. package/dist/tools/research/inspect-run.js +4 -0
  112. package/dist/tools/research/literature-reviewer.d.ts +3 -0
  113. package/dist/tools/research/literature-reviewer.js +6 -0
  114. package/dist/tools/workspace/edit-file.js +2 -0
  115. package/dist/tools/workspace/execute-command.js +25 -0
  116. package/dist/tools/workspace/grep.js +3 -0
  117. package/dist/tools/workspace/list-files.js +2 -0
  118. package/dist/tools/workspace/read-file.js +7 -0
  119. package/dist/tools/workspace/workspace-search.js +1 -0
  120. package/dist/tools/workspace/write-file.js +3 -0
  121. package/dist/workflows/execute-analysis.d.ts +7 -0
  122. package/dist/workflows/execute-analysis.js +31 -2
  123. package/dist/workflows/execute-target-assessment.d.ts +3 -0
  124. package/dist/workflows/sandbox-step.d.ts +23 -0
  125. package/dist/workflows/sandbox-step.js +33 -2
  126. package/dist/workflows/target-assessment/lib/clinical-consequence-annotator.d.ts +3 -0
  127. package/dist/workflows/target-assessment/lib/clinical-consequence-annotator.js +1 -0
  128. package/package.json +1 -1
@@ -15,6 +15,7 @@
15
15
  * before launch — a blue/green drain treats them as a single cohort.
16
16
  */
17
17
  import { type ConversationAgentDeps } from "../agents/conversation-agent.js";
18
+ import type { UsageRecorder } from "../billing/usage-recorder.js";
18
19
  import type { ResourcePolicy } from "../config/resource-limits.js";
19
20
  import type { AgentDefinition } from "../loop/types.js";
20
21
  import { type ExecuteAnalysisDeps, type ExecuteAnalysisInput, type ExecuteAnalysisResult } from "../workflows/execute-analysis.js";
@@ -27,12 +28,20 @@ export type SandboxStepCallable = (input: SandboxStepInput) => Promise<SandboxSt
27
28
  * Deps bundles for the durable workflows. `executeAnalysis` is a builder
28
29
  * because its `sandboxStepCallable` is the registered sandbox-step callable,
29
30
  * which does not exist until registration runs inside `assembleCoreRuntime`.
31
+ *
32
+ * Each bag omits `usageRecorder` for the same reason `ConversationAssemblyDeps`
33
+ * does: the recorder is resolved once below and stamped onto every bag this
34
+ * call registers, so one runtime reports to one ledger. Leaving it settable
35
+ * here would let an embedder wire a recorder the workflows use and the
36
+ * conversation agent does not — a half-wired ledger that reads as a complete
37
+ * one. The protection has to hold on both sides of the assembly, not just the
38
+ * conversation side.
30
39
  */
31
40
  export interface CoreWorkflowDeps {
32
- readonly sandboxStep: SandboxStepDeps;
33
- readonly buildExecuteAnalysis: (sandboxStep: SandboxStepCallable) => ExecuteAnalysisDeps;
34
- readonly executeTargetAssessment: ExecuteTargetAssessmentDeps;
35
- readonly dataProfile: DataProfileDeps;
41
+ readonly sandboxStep: Omit<SandboxStepDeps, "usageRecorder">;
42
+ readonly buildExecuteAnalysis: (sandboxStep: SandboxStepCallable) => Omit<ExecuteAnalysisDeps, "usageRecorder">;
43
+ readonly executeTargetAssessment: Omit<ExecuteTargetAssessmentDeps, "usageRecorder">;
44
+ readonly dataProfile: Omit<DataProfileDeps, "usageRecorder">;
36
45
  }
37
46
  /** The registered, callable workflow handles. */
38
47
  export interface RegisteredWorkflows {
@@ -42,11 +51,12 @@ export interface RegisteredWorkflows {
42
51
  readonly dataProfile: (input: DataProfileWorkflowInput) => Promise<void>;
43
52
  }
44
53
  /**
45
- * Conversation-agent deps minus the workflow callable and the resource
46
- * policy — `assembleCoreRuntime` supplies those itself so a caller cannot wire
47
- * a stale callable or a policy that diverges from the one the workflows see.
54
+ * Conversation-agent deps minus the workflow callable, the resource policy, and
55
+ * the usage recorder — `assembleCoreRuntime` supplies those itself so a caller
56
+ * cannot wire a stale callable, a policy that diverges from the one the
57
+ * workflows see, or a recorder that only half the agent tree reports to.
48
58
  */
49
- export type ConversationAssemblyDeps = Omit<ConversationAgentDeps, "executeAnalysisWorkflow" | "resourcePolicy">;
59
+ export type ConversationAssemblyDeps = Omit<ConversationAgentDeps, "executeAnalysisWorkflow" | "resourcePolicy" | "usageRecorder">;
50
60
  export interface CoreRuntimeDeps {
51
61
  readonly conversation: ConversationAssemblyDeps;
52
62
  readonly workflows: CoreWorkflowDeps;
@@ -55,6 +65,13 @@ export interface CoreRuntimeDeps {
55
65
  * point for planner/routing guidance and workflow-input budget snapshots.
56
66
  */
57
67
  readonly resourcePolicy?: ResourcePolicy;
68
+ /**
69
+ * LLM usage-accounting seam. Resolved once here and stamped onto the
70
+ * conversation agent and every workflow deps bag this call registers, so
71
+ * one runtime reports to one ledger. Omitted falls back to
72
+ * `createNoopUsageRecorder()`.
73
+ */
74
+ readonly usageRecorder?: UsageRecorder;
58
75
  }
59
76
  export interface CoreRuntime {
60
77
  readonly conversationAgent: AgentDefinition;
@@ -15,20 +15,23 @@
15
15
  * before launch — a blue/green drain treats them as a single cohort.
16
16
  */
17
17
  import { createConversationAgent } from "../agents/conversation-agent.js";
18
+ import { createNoopUsageRecorder } from "../billing/noop-usage-recorder.js";
18
19
  import { registerExecuteAnalysis } from "../workflows/execute-analysis.js";
19
20
  import { registerSandboxStep } from "../workflows/sandbox-step.js";
20
21
  import { registerExecuteTargetAssessment, } from "../workflows/execute-target-assessment.js";
21
22
  import { registerDataProfileWorkflow } from "../tasks/data-profile.js";
22
23
  export function assembleCoreRuntime(deps) {
23
24
  const { conversation, workflows: wf, resourcePolicy } = deps;
24
- const sandboxStep = registerSandboxStep(wf.sandboxStep);
25
- const executeAnalysis = registerExecuteAnalysis(wf.buildExecuteAnalysis(sandboxStep));
26
- const executeTargetAssessment = registerExecuteTargetAssessment(wf.executeTargetAssessment);
27
- const dataProfile = registerDataProfileWorkflow(wf.dataProfile);
25
+ const usageRecorder = deps.usageRecorder ?? createNoopUsageRecorder();
26
+ const sandboxStep = registerSandboxStep({ ...wf.sandboxStep, usageRecorder });
27
+ const executeAnalysis = registerExecuteAnalysis({ ...wf.buildExecuteAnalysis(sandboxStep), usageRecorder });
28
+ const executeTargetAssessment = registerExecuteTargetAssessment({ ...wf.executeTargetAssessment, usageRecorder });
29
+ const dataProfile = registerDataProfileWorkflow({ ...wf.dataProfile, usageRecorder });
28
30
  const conversationAgent = createConversationAgent({
29
31
  ...conversation,
30
32
  executeAnalysisWorkflow: executeAnalysis,
31
33
  resourcePolicy,
34
+ usageRecorder,
32
35
  });
33
36
  return {
34
37
  conversationAgent,
@@ -6,6 +6,7 @@
6
6
  * into its emitters.
7
7
  */
8
8
  import type { ChatDataPart, EmitFn } from "../loop/types.js";
9
+ import type { ToolDetailResolver } from "../tools/detail-resolver.js";
9
10
  /**
10
11
  * Stable reconciliation id for a step's reconciling parts
11
12
  * (`data-step-activity`, `data-step-file-tree`). One id per (runId, stepId)
@@ -18,7 +19,27 @@ import type { ChatDataPart, EmitFn } from "../loop/types.js";
18
19
  * producers construct it identically.
19
20
  */
20
21
  export declare function stepPartId(kind: string, runId: string, stepId: string): string;
21
- export declare function activityForTool(name: string, input?: unknown): string;
22
+ /**
23
+ * The live-activity phrase for one tool call: the tool's name, then its own
24
+ * description of this call when it has one.
25
+ *
26
+ * The description comes from the tool's `describeCall` hook, resolved through
27
+ * `resolveDetail` — the same resolver the chat chip and the reloaded transcript
28
+ * use. A caller supplies the resolver for the agent whose calls it is
29
+ * describing, because a sandbox agent's tool list is not the conversation
30
+ * roster's and no single list serves both.
31
+ *
32
+ * The name always leads, and that is load-bearing rather than cosmetic. This
33
+ * phrase is rendered alone, with no tool name beside it — unlike the chat chip,
34
+ * which prints the name itself and can afford a bare detail. `write_file` and
35
+ * `edit_file` both describe a call by its path, so a detail on its own would
36
+ * render a write and an edit of the same file identically.
37
+ *
38
+ * A tool with no hook, an unknown tool, and an input its schema rejects all fall
39
+ * back to the name alone. No verb is added: the phrase is carried on a part that
40
+ * already states the phase, so "Running" would only restate it.
41
+ */
42
+ export declare function activityForTool(name: string, input?: unknown, resolveDetail?: ToolDetailResolver): string;
22
43
  /** On-change working-tree delta the sandbox executor posts per exec (paths
23
44
  * only; the executor skips directories). */
24
45
  export interface SandboxTreeDelta {
@@ -19,42 +19,29 @@
19
19
  export function stepPartId(kind, runId, stepId) {
20
20
  return `${kind}-${runId}-${stepId}`;
21
21
  }
22
- /** Friendly live-activity label per sandbox tool id. Falls back to the raw
23
- * tool name so a newly added tool still reads sensibly. */
24
- const TOOL_ACTIVITY = {
25
- execute_command: "Running script",
26
- write_file: "Writing file",
27
- edit_file: "Editing file",
28
- read_file: "Reading file",
29
- grep: "Searching files",
30
- list_files: "Listing files",
31
- };
32
- /** Last path segment of a posix/win path-like string. */
33
- function baseName(p) {
34
- const parts = p.split(/[\\/]/);
35
- return parts[parts.length - 1] || p;
36
- }
37
- /** Display file name for a tool's live-activity label, or null when the input
38
- * carries no sensible name. Path tools (`write_file`/`edit_file`/`read_file`)
39
- * carry `path`; `execute_command` carries an argv `command` whose first
40
- * script-like token names what's running. */
41
- function activityFileName(name, input) {
42
- if (input === null || typeof input !== "object")
43
- return null;
44
- const obj = input;
45
- if (name === "execute_command") {
46
- const cmd = obj.command;
47
- if (!Array.isArray(cmd))
48
- return null;
49
- const script = cmd.find((a) => typeof a === "string" && /\.(py|R|r|sh|js|ts|ipynb)$/.test(a));
50
- return script ? baseName(script) : null;
51
- }
52
- return typeof obj.path === "string" && obj.path.length > 0 ? baseName(obj.path) : null;
53
- }
54
- export function activityForTool(name, input) {
55
- const label = TOOL_ACTIVITY[name] ?? `Running ${name}`;
56
- const file = activityFileName(name, input);
57
- return file ? `${label} ${file}` : label;
22
+ /**
23
+ * The live-activity phrase for one tool call: the tool's name, then its own
24
+ * description of this call when it has one.
25
+ *
26
+ * The description comes from the tool's `describeCall` hook, resolved through
27
+ * `resolveDetail` — the same resolver the chat chip and the reloaded transcript
28
+ * use. A caller supplies the resolver for the agent whose calls it is
29
+ * describing, because a sandbox agent's tool list is not the conversation
30
+ * roster's and no single list serves both.
31
+ *
32
+ * The name always leads, and that is load-bearing rather than cosmetic. This
33
+ * phrase is rendered alone, with no tool name beside it — unlike the chat chip,
34
+ * which prints the name itself and can afford a bare detail. `write_file` and
35
+ * `edit_file` both describe a call by its path, so a detail on its own would
36
+ * render a write and an edit of the same file identically.
37
+ *
38
+ * A tool with no hook, an unknown tool, and an input its schema rejects all fall
39
+ * back to the name alone. No verb is added: the phrase is carried on a part that
40
+ * already states the phase, so "Running" would only restate it.
41
+ */
42
+ export function activityForTool(name, input, resolveDetail) {
43
+ const detail = resolveDetail?.(name, input);
44
+ return detail === undefined ? name : `${name} ${detail}`;
58
45
  }
59
46
  /** Extract the tree delta from a `data-sandbox-event` part, or null when the
60
47
  * part is some other sandbox event. The wrapper shape is set by
@@ -57,6 +57,7 @@ export declare const SynthesizedFindingSchema: z.ZodObject<{
57
57
  "clinical-evidence": "clinical-evidence";
58
58
  }>;
59
59
  actionType: z.ZodOptional<z.ZodEnum<{
60
+ none: "none";
60
61
  "therapeutic-target": "therapeutic-target";
61
62
  "predictive-biomarker": "predictive-biomarker";
62
63
  "prognostic-biomarker": "prognostic-biomarker";
@@ -65,7 +66,6 @@ export declare const SynthesizedFindingSchema: z.ZodObject<{
65
66
  "resistance-mechanism": "resistance-mechanism";
66
67
  "patient-stratification": "patient-stratification";
67
68
  "mechanism-of-action": "mechanism-of-action";
68
- none: "none";
69
69
  }>>;
70
70
  rationale: z.ZodString;
71
71
  }, z.core.$strip>>;
@@ -123,6 +123,7 @@ export declare const RunSynthesisSchema: z.ZodObject<{
123
123
  "clinical-evidence": "clinical-evidence";
124
124
  }>;
125
125
  actionType: z.ZodOptional<z.ZodEnum<{
126
+ none: "none";
126
127
  "therapeutic-target": "therapeutic-target";
127
128
  "predictive-biomarker": "predictive-biomarker";
128
129
  "prognostic-biomarker": "prognostic-biomarker";
@@ -131,7 +132,6 @@ export declare const RunSynthesisSchema: z.ZodObject<{
131
132
  "resistance-mechanism": "resistance-mechanism";
132
133
  "patient-stratification": "patient-stratification";
133
134
  "mechanism-of-action": "mechanism-of-action";
134
- none: "none";
135
135
  }>>;
136
136
  rationale: z.ZodString;
137
137
  }, z.core.$strip>>;
@@ -23,6 +23,8 @@ export type { InsertPlanInput, UpsertPlanInput } from "./plans.js";
23
23
  export { tryStartDataProfile, tryRetryDataProfile, tryRerunDataProfile, completeDataProfile, failDataProfile, expireStaleDataProfile, reconcileOrphanedDataProfile, clearDataProfile, loadDataProfileStatus, loadSeedInputFileIds, recordDataProfileWorkflowId, } from "./data-profile.js";
24
24
  export type { DataProfileStatus, DataProfileResult, DataProfileInputFile, DataProfileFile, DataProfileQualityAssessment } from "./data-profile.js";
25
25
  export { queryRunCountsByAnalyses, queryThreadCountsByAnalyses, queryDataProfileStatusByAnalyses } from "./analyses-metrics.js";
26
+ export { createAnalysisPurge } from "./purge-analysis.js";
27
+ export type { AnalysisPurge, AnalysisPurgeDeps, AnalysisPurgeOutcome } from "./purge-analysis.js";
26
28
  export { AnalysisStateRowSchema, ArtifactRole, ArtifactRowSchema, RunStatus, StepExecutionStatus, SynthesisStatus, CortexRunRowSchema, StepExecutionRowSchema, CortexPlanRowSchema, } from "./schema.js";
27
29
  export type { AnalysisStateRow, ArtifactRow, CortexRunRow, StepExecutionRow, CortexPlanRow } from "./schema.js";
28
30
  export { insertAssessment, updateProgress, setDossier, markFailed, getAssessment, listAssessmentsByOrg, softDeleteAssessment, TargetAssessmentStatusSchema, TargetAssessmentErrorSchema, TargetAssessmentRowSchema, } from "./target-assessments.js";
@@ -16,5 +16,8 @@ export { setSandboxRef, setActiveExecId, clearSandboxRef, queryActiveSandboxes,
16
16
  export { insertPlan, upsertPlan, loadPlan } from "./plans.js";
17
17
  export { tryStartDataProfile, tryRetryDataProfile, tryRerunDataProfile, completeDataProfile, failDataProfile, expireStaleDataProfile, reconcileOrphanedDataProfile, clearDataProfile, loadDataProfileStatus, loadSeedInputFileIds, recordDataProfileWorkflowId, } from "./data-profile.js";
18
18
  export { queryRunCountsByAnalyses, queryThreadCountsByAnalyses, queryDataProfileStatusByAnalyses } from "./analyses-metrics.js";
19
+ // Not per-entity: one analysis's whole Postgres footprint, reached from its id
20
+ // alone, with the workflow ledger behind an injected seam.
21
+ export { createAnalysisPurge } from "./purge-analysis.js";
19
22
  export { AnalysisStateRowSchema, ArtifactRole, ArtifactRowSchema, RunStatus, StepExecutionStatus, SynthesisStatus, CortexRunRowSchema, StepExecutionRowSchema, CortexPlanRowSchema, } from "./schema.js";
20
23
  export { insertAssessment, updateProgress, setDossier, markFailed, getAssessment, listAssessmentsByOrg, softDeleteAssessment, TargetAssessmentStatusSchema, TargetAssessmentErrorSchema, TargetAssessmentRowSchema, } from "./target-assessments.js";
@@ -8,6 +8,9 @@
8
8
  */
9
9
  import { backfillAiSdkMessageEnvelopes } from "../memory/message-backfill.js";
10
10
  import { createNoopLogger } from "../lib/console-logger.js";
11
+ // No ';' may appear anywhere in this text, comments included: it is executed by
12
+ // splitting on ';' (see `DDL.split` below), so a semicolon inside a comment cuts
13
+ // the statement it precedes in half and Postgres rejects the fragment.
11
14
  const DDL = `
12
15
  -- Analysis-level state (status, context, data-profile tracking, billing identity).
13
16
  --
@@ -227,6 +230,25 @@ CREATE INDEX IF NOT EXISTS idx_cortex_regulatory_chunks_metadata
227
230
  -- re-sent to the provider on the next turn, where a reordered prefix misses the
228
231
  -- prompt cache. JSON stores the text verbatim. Nothing indexes this column, and
229
232
  -- the JSON operators retractLastTurn uses work the same on either type.
233
+ --
234
+ -- reported_usage holds a whole turn's TokenUsageRollup — what providers reported
235
+ -- for the turn this row completed — and is written only on the LAST assistant row
236
+ -- of a turn, so a reloaded transcript can show the figure the live turn showed.
237
+ -- It is NOT the tokens column beside it, and the two must never be read for
238
+ -- each other: tokens is an offline js-tiktoken estimate stamped on EVERY row so
239
+ -- the read path can window by budget without a provider round trip, and it exists
240
+ -- for rows no provider ever saw. reported_usage is a provider's own report, and
241
+ -- covers a turn rather than a row. Both are plausible-looking integers on one row,
242
+ -- which is exactly why the column names carry the distinction.
243
+ --
244
+ -- Nullable with no default: absent means nothing was reported, and a zero would
245
+ -- claim a turn cost nothing. A row written before this column existed is absent
246
+ -- for the same reason, so the migration needs no backfill.
247
+ --
248
+ -- JSONB here, unlike message_envelope above. The verbatim-bytes requirement that
249
+ -- forces JSON on the envelope is about model content that is re-sent to the
250
+ -- provider. This record is harness-generated, has a fixed field set, and never
251
+ -- reaches a prompt, so it does not share that requirement.
230
252
  CREATE TABLE IF NOT EXISTS messages (
231
253
  thread_id TEXT NOT NULL,
232
254
  seq BIGINT NOT NULL,
@@ -234,6 +256,7 @@ CREATE TABLE IF NOT EXISTS messages (
234
256
  content_jsonb JSONB,
235
257
  message_envelope JSON,
236
258
  tokens INTEGER NOT NULL,
259
+ reported_usage JSONB,
237
260
  created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
238
261
  PRIMARY KEY (thread_id, seq)
239
262
  );
@@ -432,6 +455,11 @@ export async function initCortexState(pool, injected) {
432
455
  // dropping an absent NOT NULL no-ops, so this is a no-op on fresh DBs.
433
456
  "ALTER TABLE cortex_analysis_state ALTER COLUMN data_profile_status DROP NOT NULL",
434
457
  "ALTER TABLE messages ADD COLUMN IF NOT EXISTS message_envelope JSON",
458
+ // The turn's reported usage rollup (see the CREATE TABLE comment).
459
+ // Purely additive: nullable with no default, so adding it rewrites no
460
+ // row and every pre-existing message reads back with no rollup — which
461
+ // is the honest value, the figures having never been recorded.
462
+ "ALTER TABLE messages ADD COLUMN IF NOT EXISTS reported_usage JSONB",
435
463
  // Demote an existing JSONB envelope column to JSON (see the CREATE
436
464
  // TABLE comment). Type-guarded because ALTER ... TYPE rewrites the
437
465
  // whole table and this runs at every boot; scoped to
@@ -0,0 +1,74 @@
1
+ /**
2
+ * purgeAnalysis — the single place that knows what an analysis's persisted
3
+ * Postgres footprint is.
4
+ *
5
+ * A host asks for it with an `analysisId` and nothing else: every keyed store,
6
+ * the analysis's dynamic vector-table name, and its workflow ids are all
7
+ * derivable from that id, so no caller enumerates tables or supplies a run list.
8
+ * The workflow ledger is part of the footprint, not a follow-on — the sandbox
9
+ * transcripts and run-event streams live there and are the dominant share of an
10
+ * analysis's stored bytes — and it is reached through the injected
11
+ * `WorkflowPurger` seam so the durability engine stays out of the state layer.
12
+ *
13
+ * There is no single transaction across the stages: the purge spans app tables, a
14
+ * DDL `DROP TABLE`, and the engine's system schema, so instead of faking atomicity
15
+ * every stage is idempotent and the whole operation is safe to re-run. A purge of
16
+ * an unknown or already-purged analysis therefore succeeds, reporting zeroes. Each
17
+ * stage carries the reason it sits where it does.
18
+ *
19
+ * A purge is NOT serialized against work still starting on the analysis. The
20
+ * mapping from an analysis to its workflows is read once, up front, out of
21
+ * `cortex_runs`; a run inserted after that read is outside the captured set, and
22
+ * the `cortex_runs` delete later in the same purge removes the only row that could
23
+ * ever have named it. Its workflow row and every byte cascading off it then belong
24
+ * to no analysis — no retry reaches them, because nothing is left that attributes
25
+ * them to one. Quiesce the analysis before purging it: no new runs, no new
26
+ * data-profile triggers. This module cannot observe a host's in-flight work, so it
27
+ * does not enforce that.
28
+ *
29
+ * What it does not reach, so that absent coverage is never read as delivered:
30
+ * scheduled operational workflows (they belong to no analysis and accumulate
31
+ * independently of any purge), target assessments and their annotations (a
32
+ * separate top-level entity), the shared regulatory corpus, `messages` rows whose
33
+ * thread row is already gone (nothing attributes them to an analysis), and
34
+ * workspace files on disk (the embedder owns their disposal).
35
+ */
36
+ import { ResultAsync } from "neverthrow";
37
+ import type { Pool } from "pg";
38
+ import type { WorkflowPurger } from "../execution/workflow-purger.js";
39
+ import { type DbError } from "../lib/db-result.js";
40
+ import type { Logger } from "../lib/logger.js";
41
+ /**
42
+ * What one invocation reclaimed. It is a report of this call, not a claim that the
43
+ * analysis is now empty: a re-run still reclaims whatever a failed stage left
44
+ * behind, and rows written under a workflow the purge already deleted, which trip
45
+ * their foreign key.
46
+ */
47
+ export interface AnalysisPurgeOutcome {
48
+ readonly threads: number;
49
+ readonly messages: number;
50
+ /**
51
+ * How many workflows the ledger delete targeted, counted immediately before it
52
+ * and outside any transaction, because the engine owns the delete and reports no
53
+ * count of its own. It is not a post-delete tally: a row arriving between the
54
+ * count and the delete is deleted without being counted.
55
+ */
56
+ readonly workflows: number;
57
+ readonly vectorIndexDropped: boolean;
58
+ }
59
+ export interface AnalysisPurgeDeps {
60
+ readonly pool: Pool;
61
+ /** The workflow-ledger seam. Cancellation and deletion by id, engine-agnostic. */
62
+ readonly workflows: WorkflowPurger;
63
+ readonly logger?: Logger;
64
+ }
65
+ export interface AnalysisPurge {
66
+ /**
67
+ * Remove the analysis's entire persisted Postgres footprint. Absence is a
68
+ * normal outcome: an unknown or already-purged analysis succeeds with zeroes.
69
+ * Every failure rides the error channel — the operation never reports a purge
70
+ * it did not achieve. The caller quiesces the analysis first (module note).
71
+ */
72
+ purgeAnalysis(analysisId: string): ResultAsync<AnalysisPurgeOutcome, DbError>;
73
+ }
74
+ export declare function createAnalysisPurge({ pool, workflows, logger: injected }: AnalysisPurgeDeps): AnalysisPurge;
@@ -0,0 +1,205 @@
1
+ /**
2
+ * purgeAnalysis — the single place that knows what an analysis's persisted
3
+ * Postgres footprint is.
4
+ *
5
+ * A host asks for it with an `analysisId` and nothing else: every keyed store,
6
+ * the analysis's dynamic vector-table name, and its workflow ids are all
7
+ * derivable from that id, so no caller enumerates tables or supplies a run list.
8
+ * The workflow ledger is part of the footprint, not a follow-on — the sandbox
9
+ * transcripts and run-event streams live there and are the dominant share of an
10
+ * analysis's stored bytes — and it is reached through the injected
11
+ * `WorkflowPurger` seam so the durability engine stays out of the state layer.
12
+ *
13
+ * There is no single transaction across the stages: the purge spans app tables, a
14
+ * DDL `DROP TABLE`, and the engine's system schema, so instead of faking atomicity
15
+ * every stage is idempotent and the whole operation is safe to re-run. A purge of
16
+ * an unknown or already-purged analysis therefore succeeds, reporting zeroes. Each
17
+ * stage carries the reason it sits where it does.
18
+ *
19
+ * A purge is NOT serialized against work still starting on the analysis. The
20
+ * mapping from an analysis to its workflows is read once, up front, out of
21
+ * `cortex_runs`; a run inserted after that read is outside the captured set, and
22
+ * the `cortex_runs` delete later in the same purge removes the only row that could
23
+ * ever have named it. Its workflow row and every byte cascading off it then belong
24
+ * to no analysis — no retry reaches them, because nothing is left that attributes
25
+ * them to one. Quiesce the analysis before purging it: no new runs, no new
26
+ * data-profile triggers. This module cannot observe a host's in-flight work, so it
27
+ * does not enforce that.
28
+ *
29
+ * What it does not reach, so that absent coverage is never read as delivered:
30
+ * scheduled operational workflows (they belong to no analysis and accumulate
31
+ * independently of any purge), target assessments and their annotations (a
32
+ * separate top-level entity), the shared regulatory corpus, `messages` rows whose
33
+ * thread row is already gone (nothing attributes them to an analysis), and
34
+ * workspace files on disk (the embedder owns their disposal).
35
+ */
36
+ import { errAsync, okAsync } from "neverthrow";
37
+ import { createNoopLogger } from "../lib/console-logger.js";
38
+ import { tryMutation, tryQuery, withTransaction } from "../lib/db-result.js";
39
+ import { searchIndexName } from "../workspace/search-config.js";
40
+ import { isSqlIdentifier } from "./vector-store.js";
41
+ /**
42
+ * The shape an `analysisId` must take to be purgeable. It is load-bearing twice
43
+ * over, and neither job survives the other being dropped:
44
+ *
45
+ * - Safe DDL interpolation. The analysis's vector-table name is derived from the
46
+ * id and interpolated into `DROP TABLE`, because an identifier cannot be a bind
47
+ * parameter. Confining the id to `[a-z0-9_-]` is what makes that derived name an
48
+ * identifier at all.
49
+ * - An unambiguous `dataprofile:` namespace. Profile workflows are found by the id
50
+ * prefix `dataprofile:{analysisId}:`, matched with `starts_with` and no
51
+ * delimiter check. An id holding a `:` makes analysis `a`'s prefix equally a
52
+ * prefix of every workflow id of an analysis named `a:x` — and each id that
53
+ * prefix returns is cancelled, then deleted with its descendants, taking another
54
+ * analysis's ledger rows and everything cascading off them. Excluding `:` is the
55
+ * only thing that keeps a purge inside the analysis it was asked for.
56
+ *
57
+ * Empty is outside the shape too: it derives the bare table prefix, which is a
58
+ * perfectly legal identifier and belongs to no analysis.
59
+ */
60
+ const PURGEABLE_ANALYSIS_ID = /^[a-z0-9_-]+$/;
61
+ /**
62
+ * The analysis-keyed deletes that follow the thread/message pair, in the order
63
+ * they run.
64
+ *
65
+ * `cortex_plans` carries its own statement, immediately ahead of the
66
+ * `cortex_analysis_state` row it hangs off. The foreign key between the two
67
+ * cascades, so on a schema that has the key the statement is redundant — but the
68
+ * table is provisioned with `CREATE TABLE IF NOT EXISTS`, which adds no
69
+ * constraint to a table that already exists, so a database whose `cortex_plans`
70
+ * predates the key never acquires one. Resting on the cascade alone would make
71
+ * the completeness of a purge contingent on a constraint being present, and would
72
+ * silently leave the plans behind wherever it is not. The statement removes the
73
+ * rows either way; the cascade stays a backstop behind it.
74
+ */
75
+ const ANALYSIS_KEYED_DELETES = [
76
+ { op: "purgeAnalysis.artifacts", sql: "DELETE FROM cortex_artifacts WHERE analysis_id = $1" },
77
+ { op: "purgeAnalysis.stepExecutions", sql: "DELETE FROM cortex_step_executions WHERE analysis_id = $1" },
78
+ { op: "purgeAnalysis.runs", sql: "DELETE FROM cortex_runs WHERE analysis_id = $1" },
79
+ { op: "purgeAnalysis.workingMemory", sql: "DELETE FROM cortex_working_memory WHERE analysis_id = $1" },
80
+ { op: "purgeAnalysis.asks", sql: "DELETE FROM cortex_asks WHERE analysis_id = $1" },
81
+ { op: "purgeAnalysis.askGrants", sql: "DELETE FROM cortex_ask_grants WHERE analysis_id = $1" },
82
+ { op: "purgeAnalysis.plans", sql: "DELETE FROM cortex_plans WHERE analysis_id = $1" },
83
+ { op: "purgeAnalysis.analysisState", sql: "DELETE FROM cortex_analysis_state WHERE analysis_id = $1" },
84
+ ];
85
+ /**
86
+ * A refusal raised before any stage runs, so nothing is cancelled, no workflow row
87
+ * and no `cortex_*` row is gone, and a retry answers the same way at the same cost.
88
+ * `op` names the check that raised it.
89
+ */
90
+ function refuse(op, message) {
91
+ return errAsync({ type: "mutation_failed", op, cause: new Error(message) });
92
+ }
93
+ /** Run one delete on the transaction's client and report how many rows it took. */
94
+ function deleteRows(client, op, text, analysisId) {
95
+ return tryMutation(op, async () => {
96
+ const { rowCount } = await client.query({ text, values: [analysisId] });
97
+ return rowCount ?? 0;
98
+ });
99
+ }
100
+ export function createAnalysisPurge({ pool, workflows, logger: injected }) {
101
+ const logger = (injected ?? createNoopLogger()).named("purge-analysis");
102
+ /**
103
+ * Every workflow id the analysis can still be traced to, read while the rows
104
+ * that record them are all still on disk. `cortex_runs.run_id` IS the parent
105
+ * workflow id, and those rows together with the `dataprofile:{analysisId}:` id
106
+ * namespace are the ONLY mapping from an analysis to its workflows: a delete
107
+ * of `cortex_runs` that lands before this read completes strands the largest
108
+ * part of the footprint permanently out of reach.
109
+ */
110
+ const collectWorkflowIds = (analysisId) => tryQuery("purgeAnalysis.runWorkflowIds", async () => {
111
+ const { rows } = await pool.query({
112
+ text: "SELECT run_id FROM cortex_runs WHERE analysis_id = $1",
113
+ values: [analysisId],
114
+ });
115
+ return rows.map((row) => row.run_id);
116
+ }).andThen((runIds) => workflows.findByIdPrefix(`dataprofile:${analysisId}:`).map((profileIds) => {
117
+ logger.debug("collected workflow identity", { analysisId, runs: runIds.length, dataProfiles: profileIds.length });
118
+ // The two id sources cannot overlap, but collapsing duplicates keeps a
119
+ // repeated id out of the reclaimed count regardless.
120
+ return [...new Set([...runIds, ...profileIds])];
121
+ }));
122
+ /**
123
+ * One transaction for the whole `cortex_*` stage, so a mid-stage failure leaves
124
+ * no half-deleted analysis behind.
125
+ */
126
+ const deleteCortexRows = (analysisId) => withTransaction(pool, "purgeAnalysis.cortexRows", (client) =>
127
+ // `messages` has no `analysis_id` column and no foreign key: the only
128
+ // route from an analysis to its messages is a join through its thread
129
+ // rows. So the messages go first and the thread rows that name them go
130
+ // second — reversed, the messages become unattributable and unreachable
131
+ // by any later reclamation. No `deleted_at` filter on either: an archived
132
+ // thread is still the analysis's, and a purge takes it too.
133
+ deleteRows(client, "purgeAnalysis.messages", `DELETE FROM messages
134
+ WHERE thread_id IN (SELECT thread_id FROM cortex_analysis_threads WHERE analysis_id = $1)`, analysisId)
135
+ .andThen((messages) => deleteRows(client, "purgeAnalysis.threads", "DELETE FROM cortex_analysis_threads WHERE analysis_id = $1", analysisId).map((threads) => ({
136
+ threads,
137
+ messages,
138
+ })))
139
+ // Folded so the first failure short-circuits the rest. Submitting them
140
+ // together would not: the transaction is aborted the moment one raises,
141
+ // every statement behind it comes back `25P02`, and the `op` naming the
142
+ // delete that actually broke is buried under theirs.
143
+ .andThen((counts) => ANALYSIS_KEYED_DELETES.reduce((stage, { op, sql }) => stage.andThen(() => deleteRows(client, op, sql, analysisId).map(() => undefined)), okAsync(undefined)).map(() => counts)));
144
+ /**
145
+ * `vectorIndexDropped` is established by a `to_regclass` probe taken
146
+ * immediately before the drop, because `DROP TABLE IF EXISTS` reports nothing
147
+ * about whether it found anything. The probe resolves the name exactly as the
148
+ * drop does — both go through `search_path`, and the probe is handed the same
149
+ * double-quoted form the drop interpolates, since `to_regclass` parses its
150
+ * argument as an SQL identifier and would fold an unquoted one to lower case.
151
+ * The drop runs regardless of what the probe saw, so the window between them can
152
+ * at worst misreport the flag for an analysis already being destroyed, never
153
+ * leave a table behind.
154
+ *
155
+ * `indexName` arrives already accepted at the entry point; that is the whole
156
+ * warrant for interpolating it into the DDL below.
157
+ */
158
+ const dropVectorIndex = (indexName) => tryQuery("purgeAnalysis.vectorIndexPresent", async () => {
159
+ const { rows } = await pool.query({
160
+ text: "SELECT to_regclass($1::text) IS NOT NULL AS present",
161
+ values: [`"${indexName}"`],
162
+ });
163
+ return rows[0]?.present ?? false;
164
+ }).andThen((present) => tryMutation("purgeAnalysis.dropVectorIndex", async () => {
165
+ await pool.query(`DROP TABLE IF EXISTS "${indexName}"`);
166
+ }).map(() => present));
167
+ const purgeAnalysis = (analysisId) => {
168
+ if (!PURGEABLE_ANALYSIS_ID.test(analysisId)) {
169
+ return refuse("purgeAnalysis.analysisId", `purge-analysis: refusing unpurgeable analysis id "${analysisId}"`);
170
+ }
171
+ const indexName = searchIndexName(analysisId);
172
+ // The id's shape already settles the derived name's characters; what it cannot
173
+ // settle is how long the derivation makes it. This is the same guard the vector
174
+ // store applies to the same name on the write side, so the accepted shape
175
+ // cannot drift between the two, and a name Postgres would silently truncate
176
+ // onto another analysis's table is refused rather than dropped.
177
+ if (!isSqlIdentifier(indexName)) {
178
+ return refuse("purgeAnalysis.vectorIndexName", `purge-analysis: unsafe vector index name "${indexName}"`);
179
+ }
180
+ return (collectWorkflowIds(analysisId)
181
+ // Cancel before delete. Removing the status row of a running workflow
182
+ // does not stop the executor running it, and that executor goes on
183
+ // writing rows behind the delete — re-materializing exactly the orphans
184
+ // the purge exists to remove. A failed cancel leaves that risk in place,
185
+ // so it ends the purge rather than being absorbed into a completeness
186
+ // claim the operation can no longer make.
187
+ .andThen((ids) => workflows.cancel(ids).map(() => ids))
188
+ // Workflow rows go before the `cortex_*` ledger that names them: a failure
189
+ // here leaves the id mapping on disk and a retry finds it, whereas the
190
+ // reverse order turns one transient failure into a permanent orphan.
191
+ // Descendants are included because a run's child step workflows carry the
192
+ // sandbox transcripts and are reachable only through their parent.
193
+ .andThen((ids) => workflows.deleteWorkflows(ids, true))
194
+ .andThen((workflowsTargeted) => deleteCortexRows(analysisId).map((counts) => ({ ...counts, workflows: workflowsTargeted })))
195
+ // The vector table is dropped after the transaction rather than inside
196
+ // it: `DROP TABLE` takes an ACCESS EXCLUSIVE lock, and enlisting it would
197
+ // hold that lock for the duration of every row delete above.
198
+ .andThen((reclaimed) => dropVectorIndex(indexName).map((vectorIndexDropped) => ({ ...reclaimed, vectorIndexDropped })))
199
+ .map((outcome) => {
200
+ logger.info("purged analysis", { analysisId, ...outcome });
201
+ return outcome;
202
+ }));
203
+ };
204
+ return { purgeAnalysis };
205
+ }
@@ -15,6 +15,12 @@
15
15
  import { ResultAsync } from "neverthrow";
16
16
  import type { Pool } from "pg";
17
17
  import { type DbError } from "../lib/db-result.js";
18
+ /**
19
+ * Whether `name` may be interpolated into a statement as an identifier. Shared
20
+ * with the reclaim side, which guards the same derived names against the same
21
+ * shape — one invariant, one place to read it.
22
+ */
23
+ export declare function isSqlIdentifier(name: string): boolean;
18
24
  export interface CreateIndexInput {
19
25
  readonly indexName: string;
20
26
  readonly dimension: number;
@@ -14,9 +14,29 @@
14
14
  */
15
15
  import { okAsync } from "neverthrow";
16
16
  import { tryMutation, tryQuery } from "../lib/db-result.js";
17
- const IDENT = /^[a-z_][a-z0-9_]*$/;
17
+ /**
18
+ * The identifier shape and length the harness may interpolate into a statement.
19
+ * An identifier cannot be a bind parameter, so a name reaching SQL has to be
20
+ * checked rather than bound, and lower-snake is the whole of what the harness ever
21
+ * derives. The bound is as load-bearing as the shape: Postgres keeps only the
22
+ * first 63 bytes of an identifier and truncates the rest silently, so two longer
23
+ * names sharing a 63-byte prefix resolve to one table — a write that lands in
24
+ * another resource's index, and a drop that takes it. The shape is ASCII-only, so
25
+ * a name that matches it has one byte per character and `length` is the byte count
26
+ * the limit is measured in.
27
+ */
28
+ const SQL_IDENTIFIER = /^[a-z_][a-z0-9_]*$/;
29
+ const MAX_SQL_IDENTIFIER_BYTES = 63;
30
+ /**
31
+ * Whether `name` may be interpolated into a statement as an identifier. Shared
32
+ * with the reclaim side, which guards the same derived names against the same
33
+ * shape — one invariant, one place to read it.
34
+ */
35
+ export function isSqlIdentifier(name) {
36
+ return SQL_IDENTIFIER.test(name) && name.length <= MAX_SQL_IDENTIFIER_BYTES;
37
+ }
18
38
  function assertIdent(name) {
19
- if (!IDENT.test(name)) {
39
+ if (!isSqlIdentifier(name)) {
20
40
  throw new Error(`vector-store: unsafe index name "${name}"`);
21
41
  }
22
42
  }