@inflexa-ai/harness 0.44.0 → 0.45.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 (187) hide show
  1. package/dist/agents/conversation-agent.d.ts +10 -0
  2. package/dist/agents/conversation-agent.js +19 -1
  3. package/dist/agents/sandbox/shared.d.ts +23 -2
  4. package/dist/agents/sandbox/shared.js +51 -18
  5. package/dist/agents/sandbox/translational-safety-agent.js +26 -0
  6. package/dist/agents/sandbox/types.d.ts +24 -1
  7. package/dist/agents/sandbox-catalog.d.ts +14 -0
  8. package/dist/agents/sandbox-catalog.js +19 -0
  9. package/dist/app/approve-plan.d.ts +51 -0
  10. package/dist/app/approve-plan.js +84 -0
  11. package/dist/app/chat-turn.js +6 -3
  12. package/dist/app/data-profile-view.d.ts +1 -1
  13. package/dist/app/run-activity.js +4 -6
  14. package/dist/config/environment-stores.d.ts +4 -2
  15. package/dist/contracts/chat-events.d.ts +14 -2
  16. package/dist/contracts/chat-events.js +1 -1
  17. package/dist/contracts/chat-frame.d.ts +7 -0
  18. package/dist/contracts/chat-frame.js +24 -6
  19. package/dist/contracts/chat-parts.d.ts +73 -2
  20. package/dist/contracts/chat-parts.js +3 -1
  21. package/dist/contracts/index.d.ts +4 -2
  22. package/dist/contracts/index.js +1 -0
  23. package/dist/contracts/part-registry.js +3 -0
  24. package/dist/contracts/schemas/chat-events.d.ts +38 -0
  25. package/dist/contracts/schemas/chat-events.js +8 -0
  26. package/dist/contracts/schemas/chat-parts.d.ts +239 -15
  27. package/dist/contracts/schemas/chat-parts.js +80 -12
  28. package/dist/contracts/schemas/index.d.ts +2 -2
  29. package/dist/contracts/schemas/index.js +2 -2
  30. package/dist/contracts/subagent-activity.d.ts +12 -0
  31. package/dist/contracts/subagent-activity.js +76 -0
  32. package/dist/execution/artifact-metadata.js +1 -3
  33. package/dist/execution/dbos-workflow-purger.d.ts +2 -0
  34. package/dist/execution/dbos-workflow-purger.js +21 -14
  35. package/dist/execution/run-canceler.d.ts +33 -11
  36. package/dist/execution/run-canceler.js +130 -28
  37. package/dist/execution/run-end-record.d.ts +30 -0
  38. package/dist/execution/run-end-record.js +27 -0
  39. package/dist/execution/run-event-parts.d.ts +6 -5
  40. package/dist/execution/run-event-parts.js +6 -5
  41. package/dist/execution/run-event-stream.d.ts +18 -6
  42. package/dist/execution/run-event-stream.js +67 -17
  43. package/dist/execution/run-synthesis.d.ts +21 -0
  44. package/dist/execution/run-synthesis.js +77 -3
  45. package/dist/index.d.ts +28 -10
  46. package/dist/index.js +26 -2
  47. package/dist/lib/hooks.d.ts +1 -1
  48. package/dist/lib/metrics.d.ts +2 -2
  49. package/dist/loop/call-guard.d.ts +50 -0
  50. package/dist/loop/call-guard.js +90 -0
  51. package/dist/loop/compaction.d.ts +5 -0
  52. package/dist/loop/compaction.js +17 -0
  53. package/dist/loop/run-agent.d.ts +9 -0
  54. package/dist/loop/run-agent.js +16 -1
  55. package/dist/loop/run-to-terminal.js +4 -1
  56. package/dist/loop/types.d.ts +8 -0
  57. package/dist/memory/card-builders.d.ts +11 -0
  58. package/dist/memory/card-builders.js +35 -21
  59. package/dist/memory/conversation-display-recorder.d.ts +2 -2
  60. package/dist/memory/conversation-display-recorder.js +38 -4
  61. package/dist/memory/conversation-display-storage.d.ts +9 -2
  62. package/dist/memory/conversation-display-storage.js +18 -3
  63. package/dist/memory/thread-history.d.ts +42 -8
  64. package/dist/memory/thread-history.js +164 -45
  65. package/dist/memory/thread-store.d.ts +13 -0
  66. package/dist/memory/thread-store.js +15 -1
  67. package/dist/prompts/briefing.d.ts +57 -3
  68. package/dist/prompts/briefing.js +129 -2
  69. package/dist/prompts/conversation.d.ts +1 -1
  70. package/dist/prompts/conversation.js +3 -1
  71. package/dist/prompts/planner.js +13 -2
  72. package/dist/provenance/seam.d.ts +39 -20
  73. package/dist/provenance/seam.js +7 -5
  74. package/dist/providers/ai-sdk.js +93 -11
  75. package/dist/providers/types.d.ts +6 -0
  76. package/dist/reference-data/catalog.js +141 -2
  77. package/dist/report-model/authoring-grammar.d.ts +26 -26
  78. package/dist/report-model/pin-snapshot.d.ts +1 -1
  79. package/dist/report-model/pin-snapshot.js +2 -50
  80. package/dist/runtime/assemble.d.ts +8 -1
  81. package/dist/runtime/assemble.js +11 -0
  82. package/dist/runtime/boot.d.ts +3 -0
  83. package/dist/runtime/boot.js +10 -1
  84. package/dist/sandbox/image-packages.d.ts +26 -4
  85. package/dist/sandbox/image-packages.js +18 -4
  86. package/dist/sandbox/sandbox-step-translate.d.ts +23 -0
  87. package/dist/sandbox/sandbox-step-translate.js +24 -0
  88. package/dist/schemas/artifact-manifest.d.ts +1 -1
  89. package/dist/schemas/artifact-manifest.js +11 -9
  90. package/dist/schemas/data-profile-schemas.d.ts +12 -12
  91. package/dist/schemas/plan-schemas.d.ts +75 -21
  92. package/dist/schemas/plan-schemas.js +1 -1
  93. package/dist/schemas/run-synthesis.d.ts +2 -2
  94. package/dist/schemas/workflow-state.d.ts +113 -0
  95. package/dist/schemas/workflow-state.js +54 -0
  96. package/dist/state/analyses-metrics.d.ts +79 -7
  97. package/dist/state/analyses-metrics.js +141 -6
  98. package/dist/state/artifacts.d.ts +66 -3
  99. package/dist/state/artifacts.js +85 -7
  100. package/dist/state/data-profile.d.ts +4 -2
  101. package/dist/state/data-profile.js +7 -5
  102. package/dist/state/index.d.ts +9 -8
  103. package/dist/state/index.js +5 -5
  104. package/dist/state/migrations/20261012120000_index_the_runs_by_start.d.ts +8 -0
  105. package/dist/state/migrations/20261012120000_index_the_runs_by_start.js +12 -0
  106. package/dist/state/migrations/20261013120000_index_the_ended_runs_and_the_recent_threads.d.ts +8 -0
  107. package/dist/state/migrations/20261013120000_index_the_ended_runs_and_the_recent_threads.js +13 -0
  108. package/dist/state/migrations/20261013180000_narrow_the_active_run_index.d.ts +8 -0
  109. package/dist/state/migrations/20261013180000_narrow_the_active_run_index.js +13 -0
  110. package/dist/state/migrations/20261014120000_index_the_plans_by_parent_and_the_runs_by_plan.d.ts +8 -0
  111. package/dist/state/migrations/20261014120000_index_the_plans_by_parent_and_the_runs_by_plan.js +13 -0
  112. package/dist/state/migrations/index.js +8 -0
  113. package/dist/state/plans.d.ts +21 -0
  114. package/dist/state/plans.js +25 -0
  115. package/dist/state/runs.d.ts +79 -14
  116. package/dist/state/runs.js +150 -33
  117. package/dist/state/schema.d.ts +2 -0
  118. package/dist/state/step-executions.d.ts +9 -0
  119. package/dist/state/step-executions.js +22 -0
  120. package/dist/tasks/data-profile-activity.d.ts +7 -1
  121. package/dist/tasks/data-profile-activity.js +18 -9
  122. package/dist/tasks/data-profile.d.ts +15 -1
  123. package/dist/tasks/data-profile.js +16 -11
  124. package/dist/tools/ad-hoc-router.d.ts +2 -0
  125. package/dist/tools/ad-hoc-router.js +18 -2
  126. package/dist/tools/approval/gateway.js +28 -5
  127. package/dist/tools/capability-data.d.ts +36 -0
  128. package/dist/tools/capability-data.js +73 -0
  129. package/dist/tools/display/show-user.d.ts +1 -1
  130. package/dist/tools/execute-analysis.d.ts +80 -1
  131. package/dist/tools/execute-analysis.js +352 -110
  132. package/dist/tools/knowledge/check.d.ts +60 -0
  133. package/dist/tools/knowledge/check.js +92 -0
  134. package/dist/tools/knowledge/client.d.ts +382 -0
  135. package/dist/tools/knowledge/client.js +302 -0
  136. package/dist/tools/knowledge/environment.d.ts +78 -0
  137. package/dist/tools/knowledge/environment.js +105 -0
  138. package/dist/tools/knowledge/index.d.ts +49 -0
  139. package/dist/tools/knowledge/index.js +70 -0
  140. package/dist/tools/knowledge/local-slots.d.ts +62 -0
  141. package/dist/tools/knowledge/local-slots.js +177 -0
  142. package/dist/tools/knowledge/pin.d.ts +14 -0
  143. package/dist/tools/knowledge/pin.js +17 -0
  144. package/dist/tools/knowledge/recommend.d.ts +92 -0
  145. package/dist/tools/knowledge/recommend.js +98 -0
  146. package/dist/tools/knowledge/situation.d.ts +143 -0
  147. package/dist/tools/knowledge/situation.js +164 -0
  148. package/dist/tools/knowledge/skeleton.d.ts +69 -0
  149. package/dist/tools/knowledge/skeleton.js +202 -0
  150. package/dist/tools/knowledge/template.d.ts +137 -0
  151. package/dist/tools/knowledge/template.js +267 -0
  152. package/dist/tools/research/generate-plan.d.ts +23 -0
  153. package/dist/tools/research/generate-plan.js +203 -12
  154. package/dist/tools/research/guarded-address.d.ts +20 -0
  155. package/dist/tools/research/guarded-address.js +148 -0
  156. package/dist/tools/research/inspect-data-profile.d.ts +1 -1
  157. package/dist/tools/research/inspect-run.d.ts +1 -1
  158. package/dist/tools/research/inspect-run.js +7 -7
  159. package/dist/tools/research/searxng-engine.d.ts +40 -0
  160. package/dist/tools/research/searxng-engine.js +120 -0
  161. package/dist/tools/research/web-fetch.d.ts +124 -0
  162. package/dist/tools/research/web-fetch.js +627 -0
  163. package/dist/tools/research/web-search.d.ts +75 -0
  164. package/dist/tools/research/web-search.js +112 -0
  165. package/dist/tools/sandbox/list-available-packages.d.ts +7 -4
  166. package/dist/tools/sandbox/list-available-packages.js +12 -4
  167. package/dist/tools/sandbox/list-available-refs.d.ts +21 -0
  168. package/dist/tools/sandbox/list-available-refs.js +60 -3
  169. package/dist/tools/sandbox/report-blocker.js +5 -1
  170. package/dist/tools/terminal-text.d.ts +11 -0
  171. package/dist/tools/terminal-text.js +19 -0
  172. package/dist/tools/workspace/decision-record.d.ts +81 -0
  173. package/dist/tools/workspace/decision-record.js +117 -0
  174. package/dist/tools/workspace/edit-file.d.ts +3 -1
  175. package/dist/tools/workspace/edit-file.js +20 -2
  176. package/dist/tools/workspace/execute-command.d.ts +17 -0
  177. package/dist/tools/workspace/execute-command.js +28 -1
  178. package/dist/tools/workspace/index.d.ts +1 -0
  179. package/dist/tools/workspace/index.js +1 -0
  180. package/dist/tools/workspace/mutator.d.ts +1 -1
  181. package/dist/workflows/execute-analysis.d.ts +62 -21
  182. package/dist/workflows/execute-analysis.js +307 -79
  183. package/dist/workflows/plan/graph.d.ts +16 -0
  184. package/dist/workflows/plan/graph.js +36 -0
  185. package/dist/workflows/sandbox-step.d.ts +12 -0
  186. package/dist/workflows/sandbox-step.js +14 -12
  187. package/package.json +5 -1
@@ -8,14 +8,14 @@
8
8
  */
9
9
  export { initCortexState } from "./init.js";
10
10
  export { upsertAnalysis, suspendAnalysis, resumeAnalysis, loadAnalysisStatus } from "./analyses.js";
11
- export { upsertArtifact, upsertArtifacts, queryInputArtifacts, queryStepArtifactPaths, queryUnsyncedStepArtifacts, updateArtifactId, updateFileIds, countArtifactsForRun, } from "./artifacts.js";
12
- export { insertRun, reserveRunById, RunDedupCollisionError, RunIdentityCollisionError, updateRunStatus, markRunCanceledIfActive, promoteFailedToPartial, setRunMandate, // oss-core-managed-ok: run-mandate ledger (nullable; OSS leaves null)
13
- setRunSynthesisOutcome, queryRun, queryActiveRun, queryNonTerminalRunsByAnalysis, queryRunsForInspection, queryRunsByAnalysis, queryRunsByThread, } from "./runs.js";
11
+ export { upsertArtifact, upsertArtifacts, queryInputArtifacts, queryStepArtifactPaths, queryUnsyncedStepArtifacts, updateArtifactId, updateFileIds, queryArtifactPage, countArtifactsByRuns, } from "./artifacts.js";
12
+ export { insertRun, reserveRunById, RunDedupCollisionError, RunIdentityCollisionError, updateRunStatus, reconcileOrphanedRuns, ORPHANED_RUN_GRACE_MS, markRunCanceledIfActive, promoteFailedToPartial, setRunMandate, // oss-core-managed-ok: run-mandate ledger (nullable; OSS leaves null)
13
+ setRunSynthesisOutcome, queryRun, queryActiveRun, queryNonTerminalRunsByAnalysis, queryRunsForInspection, queryRunsByAnalysis, countRunsByAnalysis, queryRunsByThread, queryRunIdsByPlan, queryNewestPlanRun, } from "./runs.js";
14
14
  export { insertStepExecution, seedStepExecutions, sweepPendingStepExecutions, updateStepExecution, queryStepsByRun } from "./step-executions.js";
15
15
  export { setSandboxRef, clearSandboxRef, reconcileReapedSandbox } from "./active-sandboxes.js";
16
- export { insertPlan, upsertPlan, loadPlan } from "./plans.js";
16
+ export { insertPlan, upsertPlan, loadPlan, loadPlanRecord } from "./plans.js";
17
17
  export { tryStartDataProfile, tryRetryDataProfile, tryRerunDataProfile, completeDataProfile, failDataProfile, expireStaleDataProfile, reconcileOrphanedDataProfile, clearDataProfile, completeEmptyDataProfile, loadDataProfileStatus, loadSeedInputFileIds, recordDataProfileWorkflowId, } from "./data-profile.js";
18
- export { queryRunCountsByAnalyses, queryThreadCountsByAnalyses, queryDataProfileStatusByAnalyses } from "./analyses-metrics.js";
18
+ export { queryRunCountsByAnalyses, queryThreadCountsByAnalyses, queryDataProfileStatusByAnalyses, queryLatestRunsByAnalyses, queryRunProgressByRuns, queryRunsEndedSince, } from "./analyses-metrics.js";
19
19
  // Not per-entity: one analysis's whole Postgres footprint, reached from its id
20
20
  // alone, with the workflow ledger behind an injected seam.
21
21
  export { createAnalysisPurge } from "./purge-analysis.js";
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Index the runs of an analysis by their start time, thus the read of the
3
+ * newest run of each analysis of a list reads one index entry for each
4
+ * analysis, and its cost does not grow with the run history. The index on
5
+ * `analysis_id` alone stays: a drop is a separate decision.
6
+ */
7
+ import type { Migration } from "kysely/migration";
8
+ export declare const indexTheRunsByStart: Migration;
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Index the runs of an analysis by their start time, thus the read of the
3
+ * newest run of each analysis of a list reads one index entry for each
4
+ * analysis, and its cost does not grow with the run history. The index on
5
+ * `analysis_id` alone stays: a drop is a separate decision.
6
+ */
7
+ import { sql } from "kysely";
8
+ export const indexTheRunsByStart = {
9
+ async up(db) {
10
+ await sql `CREATE INDEX IF NOT EXISTS idx_cortex_runs_analysis_started ON cortex_runs(analysis_id, started_at DESC, run_id DESC)`.execute(db);
11
+ },
12
+ };
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Index the runs by their completion time and the live threads by their update
3
+ * time, thus the reads over each analysis walk an index and do not sort the
4
+ * whole history. The thread index is partial on the live rows, the predicate of
5
+ * the read.
6
+ */
7
+ import type { Migration } from "kysely/migration";
8
+ export declare const indexTheEndedRunsAndTheRecentThreads: Migration;
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Index the runs by their completion time and the live threads by their update
3
+ * time, thus the reads over each analysis walk an index and do not sort the
4
+ * whole history. The thread index is partial on the live rows, the predicate of
5
+ * the read.
6
+ */
7
+ import { sql } from "kysely";
8
+ export const indexTheEndedRunsAndTheRecentThreads = {
9
+ async up(db) {
10
+ await sql `CREATE INDEX IF NOT EXISTS idx_cortex_runs_completed ON cortex_runs(completed_at DESC, run_id DESC)`.execute(db);
11
+ await sql `CREATE INDEX IF NOT EXISTS idx_cortex_analysis_threads_recent ON cortex_analysis_threads(updated_at DESC, thread_id) WHERE deleted_at IS NULL`.execute(db);
12
+ },
13
+ };
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Narrow the one-active-run index to the `running` status. A run that a host
3
+ * suspended is terminal, thus it no longer blocks a new run of its plan. A drop
4
+ * and a new index replace the index, because Postgres cannot change the
5
+ * predicate of an index.
6
+ */
7
+ import type { Migration } from "kysely/migration";
8
+ export declare const narrowTheActiveRunIndex: Migration;
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Narrow the one-active-run index to the `running` status. A run that a host
3
+ * suspended is terminal, thus it no longer blocks a new run of its plan. A drop
4
+ * and a new index replace the index, because Postgres cannot change the
5
+ * predicate of an index.
6
+ */
7
+ import { sql } from "kysely";
8
+ export const narrowTheActiveRunIndex = {
9
+ async up(db) {
10
+ await sql `DROP INDEX IF EXISTS idx_cortex_runs_active_plan`.execute(db);
11
+ await sql `CREATE UNIQUE INDEX idx_cortex_runs_active_plan ON cortex_runs(analysis_id, plan_id) WHERE status = 'running'`.execute(db);
12
+ },
13
+ };
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Index the plans by their parent, thus the read of the plan that replaced a
3
+ * plan, and the key check when a plan row goes away, read one index entry.
4
+ * Index the runs by their plan, thus the read of the run ids of a plan reads
5
+ * only the runs of that plan.
6
+ */
7
+ import type { Migration } from "kysely/migration";
8
+ export declare const indexThePlansByParentAndTheRunsByPlan: Migration;
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Index the plans by their parent, thus the read of the plan that replaced a
3
+ * plan, and the key check when a plan row goes away, read one index entry.
4
+ * Index the runs by their plan, thus the read of the run ids of a plan reads
5
+ * only the runs of that plan.
6
+ */
7
+ import { sql } from "kysely";
8
+ export const indexThePlansByParentAndTheRunsByPlan = {
9
+ async up(db) {
10
+ await sql `CREATE INDEX IF NOT EXISTS idx_cortex_plans_parent ON cortex_plans(parent_plan_id)`.execute(db);
11
+ await sql `CREATE INDEX IF NOT EXISTS idx_cortex_runs_plan ON cortex_runs(analysis_id, plan_id, started_at DESC, run_id DESC)`.execute(db);
12
+ },
13
+ };
@@ -8,10 +8,18 @@
8
8
  import { baseline } from "./20260927120000_baseline.js";
9
9
  import { threadTitleSetByUser } from "./20260927180000_thread_title_set_by_user.js";
10
10
  import { dropActiveExecTracking } from "./20261005120000_drop_the_active_exec_tracking.js";
11
+ import { indexTheRunsByStart } from "./20261012120000_index_the_runs_by_start.js";
12
+ import { indexTheEndedRunsAndTheRecentThreads } from "./20261013120000_index_the_ended_runs_and_the_recent_threads.js";
13
+ import { narrowTheActiveRunIndex } from "./20261013180000_narrow_the_active_run_index.js";
14
+ import { indexThePlansByParentAndTheRunsByPlan } from "./20261014120000_index_the_plans_by_parent_and_the_runs_by_plan.js";
11
15
  export function cortexMigrations(logger) {
12
16
  return {
13
17
  "20260927120000_baseline": baseline(logger),
14
18
  "20260927180000_thread_title_set_by_user": threadTitleSetByUser,
15
19
  "20261005120000_drop_the_active_exec_tracking": dropActiveExecTracking,
20
+ "20261012120000_index_the_runs_by_start": indexTheRunsByStart,
21
+ "20261013120000_index_the_ended_runs_and_the_recent_threads": indexTheEndedRunsAndTheRecentThreads,
22
+ "20261013180000_narrow_the_active_run_index": narrowTheActiveRunIndex,
23
+ "20261014120000_index_the_plans_by_parent_and_the_runs_by_plan": indexThePlansByParentAndTheRunsByPlan,
16
24
  };
17
25
  }
@@ -54,3 +54,24 @@ export declare function upsertPlan(pool: Querier, input: UpsertPlanInput): Resul
54
54
  export declare function loadPlan(pool: Querier, planId: string, opts: {
55
55
  analysisId: string;
56
56
  }): ResultAsync<unknown | null, DbError>;
57
+ /** A stored plan with its lineage, as {@link loadPlanRecord} reads it. */
58
+ export interface PlanRecord {
59
+ planId: string;
60
+ /** The stored document, as the store holds it. A reader parses it. */
61
+ plan: unknown;
62
+ parentPlanId: string | null;
63
+ /** The newest plan of the same analysis that names this plan as its parent. */
64
+ supersededBy: string | null;
65
+ createdAt: string;
66
+ }
67
+ /**
68
+ * Analysis-scoped read of one plan with its parent and the newest plan that
69
+ * replaced it, in one statement. Returns `ok(null)` for an unknown planId OR a
70
+ * planId of a different analysis, as {@link loadPlan} does.
71
+ *
72
+ * `created_at` is ISO 8601 text, thus the text order is the time order; the plan
73
+ * id breaks a tie so that the answer is stable.
74
+ */
75
+ export declare function loadPlanRecord(pool: Querier, planId: string, opts: {
76
+ analysisId: string;
77
+ }): ResultAsync<PlanRecord | null, DbError>;
@@ -115,3 +115,28 @@ export function loadPlan(pool, planId, opts) {
115
115
  return result.rows[0]?.plan ?? null;
116
116
  });
117
117
  }
118
+ /**
119
+ * Analysis-scoped read of one plan with its parent and the newest plan that
120
+ * replaced it, in one statement. Returns `ok(null)` for an unknown planId OR a
121
+ * planId of a different analysis, as {@link loadPlan} does.
122
+ *
123
+ * `created_at` is ISO 8601 text, thus the text order is the time order; the plan
124
+ * id breaks a tie so that the answer is stable.
125
+ */
126
+ export function loadPlanRecord(pool, planId, opts) {
127
+ return tryQuery("plans.loadPlanRecord", async () => {
128
+ const result = await pool.query({
129
+ text: `SELECT p.plan, p.parent_plan_id, p.created_at,
130
+ (SELECT c.plan_id FROM cortex_plans c
131
+ WHERE c.parent_plan_id = p.plan_id AND c.analysis_id = p.analysis_id
132
+ ORDER BY c.created_at DESC, c.plan_id DESC LIMIT 1) AS superseded_by
133
+ FROM cortex_plans p
134
+ WHERE p.plan_id = $1 AND p.analysis_id = $2`,
135
+ values: [planId, opts.analysisId],
136
+ });
137
+ const row = result.rows[0];
138
+ if (!row)
139
+ return null;
140
+ return { planId, plan: row.plan, parentPlanId: row.parent_plan_id, supersededBy: row.superseded_by, createdAt: row.created_at };
141
+ });
142
+ }
@@ -21,6 +21,8 @@ export interface InsertRunInput {
21
21
  planId?: string | null;
22
22
  mandateJti?: string | null;
23
23
  mandateExpiresAt?: string | null;
24
+ /** The start time as an ISO string. Without it, the row gets the clock of the host. */
25
+ startedAt?: string;
24
26
  }
25
27
  export interface RunReservation {
26
28
  readonly row: CortexRunRow;
@@ -64,6 +66,11 @@ export declare function reserveRunById(pool: Querier, input: InsertRunInput): Re
64
66
  * most one match.
65
67
  */
66
68
  export declare function queryActiveRun(pool: Querier, analysisId: string, planId: string | null): ResultAsync<CortexRunRow | null, DbError>;
69
+ /** The optional text filter of the run reads. */
70
+ export interface RunTextFilter {
71
+ /** A part of the run id or of the plan title, with no regard to case. Each character matches only itself. */
72
+ q?: string;
73
+ }
67
74
  /**
68
75
  * Every NON-TERMINAL run of an analysis, newest-first and deliberately unbounded.
69
76
  *
@@ -77,28 +84,86 @@ export declare function queryActiveRun(pool: Querier, analysisId: string, planId
77
84
  * analysis's live concurrency, not by its history, and a run leaves the set permanently when it
78
85
  * finishes.
79
86
  *
80
- * The status set is the one {@link queryActiveRun} uses — non-terminal means still running, or
81
- * suspended awaiting funds. Keep the two in step: a new non-terminal status added to one and not the
82
- * other makes a live run invisible to exactly one of its readers.
87
+ * The status set is the one {@link queryActiveRun} uses — non-terminal means `running`. Keep the two
88
+ * in step: a new non-terminal status added to one and not the other makes a live run invisible to
89
+ * exactly one of its readers.
90
+ *
91
+ * With `q`, only the runs that the text filter selects are members.
92
+ */
93
+ export declare function queryActiveRunsByAnalysis(pool: Querier, analysisId: string, { q }?: RunTextFilter): ResultAsync<CortexRunRow[], DbError>;
94
+ /**
95
+ * How long a reserved run may wait for its workflow before {@link reconcileOrphanedRuns} fails it. A launch writes
96
+ * the workflow row seconds after the reservation, and the margin covers a slow authorization gate of a managed host.
83
97
  */
84
- export declare function queryActiveRunsByAnalysis(pool: Querier, analysisId: string): ResultAsync<CortexRunRow[], DbError>;
85
- export declare function updateRunStatus(pool: Querier, runId: string, status: "running" | "completed" | "partial" | "failed" | "canceled" | "suspended_insufficient_funds", error?: string | null): ResultAsync<void, DbError>;
98
+ export declare const ORPHANED_RUN_GRACE_MS: number;
99
+ /**
100
+ * Fail each `running` run that started before the grace and that has no DBOS workflow row, and give the ids of
101
+ * the runs that it failed.
102
+ *
103
+ * A launch reserves the run row, then starts the workflow. A host that dies between the two, or a start that throws
104
+ * while the following status write also fails, leaves a `running` row that no workflow will ever settle. That row
105
+ * holds the active-plan slot, thus each later launch of the plan answers `started: false` for a run that never
106
+ * started. Recovery cannot help, because no workflow row exists to recover.
107
+ *
108
+ * Only a run with no workflow row at all is failed. A run whose workflow exists, also a terminal one, is the
109
+ * workflow's to settle: a failed mark could misname a run that completed. Call it after `DBOS.launch()`, thus a
110
+ * recovered workflow is visible to the predicate.
111
+ */
112
+ export declare function reconcileOrphanedRuns(pool: Querier, opts?: {
113
+ now?: number;
114
+ graceMs?: number;
115
+ }): ResultAsync<string[], DbError>;
116
+ /**
117
+ * Write a status to a run row that is still `running`, and report whether the row changed. The first terminal write
118
+ * of a run wins: a row that already holds a terminal status keeps its status, its `completed_at`, and its error.
119
+ * The error text loses each NUL character, because Postgres rejects a NUL in text and the write must not fail for its own value.
120
+ */
121
+ export declare function updateRunStatus(pool: Querier, runId: string, status: "running" | "completed" | "partial" | "failed" | "canceled" | "suspended_insufficient_funds", error?: string | null): ResultAsync<boolean, DbError>;
86
122
  /**
87
123
  * Conditionally transition an ACTIVE run to `canceled`, stamping `completed_at`
88
124
  * and recording `reason` in `error` (the same column `collectAndComplete`
89
125
  * stamps `external_cancel` into). Reports whether a row transitioned — false
90
126
  * 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.
127
+ * external cancel). The guarded set is the active set `queryActiveRun`
128
+ * deduplicates on.
94
129
  */
95
130
  export declare function markRunCanceledIfActive(pool: Querier, runId: string, reason: string): ResultAsync<boolean, DbError>;
96
131
  export declare function promoteFailedToPartial(pool: Querier, runId: string): ResultAsync<boolean, DbError>;
97
132
  export declare function queryRun(pool: Querier, runId: string): ResultAsync<CortexRunRow | null, DbError>;
98
- export declare function queryRunsByAnalysis(pool: Querier, analysisId: string, { limit, offset }?: {
133
+ /**
134
+ * One window of the runs of an analysis, newest start first. With `q`, the window holds only the runs that the text
135
+ * filter selects: the filter applies before `limit` and `offset`, and it does not change the order.
136
+ */
137
+ export declare function queryRunsByAnalysis(pool: Querier, analysisId: string, { limit, offset, q }?: {
99
138
  limit?: number;
100
139
  offset?: number;
101
- }): ResultAsync<CortexRunRow[], DbError>;
140
+ } & RunTextFilter): ResultAsync<CortexRunRow[], DbError>;
141
+ /**
142
+ * The count of the runs of an analysis, in one statement. With `q`, the count holds only the runs that the text filter
143
+ * selects, thus it agrees with the windows of {@link queryRunsByAnalysis} for the same `q`.
144
+ */
145
+ export declare function countRunsByAnalysis(pool: Querier, analysisId: string, { q }?: RunTextFilter): ResultAsync<number, DbError>;
146
+ /**
147
+ * The run ids of one plan of one analysis, newest start first, at most `limit`.
148
+ * The run id breaks a tie of start times, highest first. A plan with no run gives
149
+ * an empty list.
150
+ */
151
+ export declare function queryRunIdsByPlan(pool: Querier, opts: {
152
+ analysisId: string;
153
+ planId: string;
154
+ limit: number;
155
+ }): ResultAsync<string[], DbError>;
156
+ /**
157
+ * The newest run of one plan of one analysis, or `null` for a plan with no run. The order is the one of
158
+ * {@link queryRunIdsByPlan}: the latest start first, and the highest run id on a tie of start times.
159
+ */
160
+ export declare function queryNewestPlanRun(pool: Querier, opts: {
161
+ analysisId: string;
162
+ planId: string;
163
+ }): ResultAsync<{
164
+ runId: string;
165
+ startedAt: string;
166
+ } | null, DbError>;
102
167
  /**
103
168
  * Read the analysis's non-terminal run activity without coupling it to a
104
169
  * conversation thread. The total is counted before the detail limit so a
@@ -106,8 +171,7 @@ export declare function queryRunsByAnalysis(pool: Querier, analysisId: string, {
106
171
  */
107
172
  export declare function queryNonTerminalRunsByAnalysis(pool: Querier, analysisId: string, limit?: number): ResultAsync<RunPage, DbError>;
108
173
  /**
109
- * Read one stable inspection page: running first, then suspended, then
110
- * terminal history. This is deliberately separate from
174
+ * Read one stable inspection page: running first, then each other run. This is deliberately separate from
111
175
  * {@link queryRunsByAnalysis}, whose chronological order is an embedder/UI
112
176
  * contract.
113
177
  */
@@ -115,10 +179,11 @@ export declare function queryRunsForInspection(pool: Querier, analysisId: string
115
179
  limit?: number;
116
180
  offset?: number;
117
181
  }): ResultAsync<RunPage, DbError>;
118
- export declare function queryRunsByThread(pool: Querier, analysisId: string, threadId: string, { limit, offset }?: {
182
+ /** The runs of one thread of an analysis, newest start first. With `q`, the text filter applies before the window. */
183
+ export declare function queryRunsByThread(pool: Querier, analysisId: string, threadId: string, { limit, offset, q }?: {
119
184
  limit?: number;
120
185
  offset?: number;
121
- }): ResultAsync<CortexRunRow[], DbError>;
186
+ } & RunTextFilter): ResultAsync<CortexRunRow[], DbError>;
122
187
  /**
123
188
  * Persist the run mandate jti + expiry for a run. oss-core-managed-ok: run-mandate ledger (nullable; OSS leaves null)
124
189
  * Called by `authorizeForRun` after a successful mint so the cancel
@@ -9,6 +9,7 @@
9
9
  * `DbError`. Every other write maps SQLSTATE 23505/23503/23502/23514 to
10
10
  * `err(constraint_violation)` via `tryMutation`.
11
11
  */
12
+ import { stripNulCharacters } from "../input-sanitization.js";
12
13
  import { tryMutation, tryQuery } from "../lib/db-result.js";
13
14
  export class RunIdentityCollisionError extends Error {
14
15
  runId;
@@ -44,7 +45,7 @@ export class RunDedupCollisionError extends Error {
44
45
  * the err channel as a `DbError`.
45
46
  */
46
47
  export function insertRun(pool, input) {
47
- const now = new Date().toISOString();
48
+ const now = input.startedAt ?? new Date().toISOString();
48
49
  return tryMutation("runs.insertRun", async () => {
49
50
  await pool.query({
50
51
  text: `INSERT INTO cortex_runs
@@ -75,7 +76,7 @@ export function insertRun(pool, input) {
75
76
  }
76
77
  /** Reserve a deterministic run id, treating same-analysis PK conflict as redelivery. */
77
78
  export function reserveRunById(pool, input) {
78
- const now = new Date().toISOString();
79
+ const now = input.startedAt ?? new Date().toISOString();
79
80
  return tryMutation("runs.reserveRunById", async () => {
80
81
  const result = await pool.query({
81
82
  text: `INSERT INTO cortex_runs
@@ -132,13 +133,27 @@ export function queryActiveRun(pool, analysisId, planId) {
132
133
  FROM cortex_runs
133
134
  WHERE analysis_id = $1
134
135
  AND plan_id IS NOT DISTINCT FROM $2
135
- AND status IN ('running','suspended_insufficient_funds')`,
136
+ AND status = 'running'`,
136
137
  values: [analysisId, planId],
137
138
  });
138
139
  const row = result.rows[0];
139
140
  return row ? mapRunRow(row) : null;
140
141
  });
141
142
  }
143
+ /**
144
+ * The SQL condition of the text filter `q` for the bound placeholder `placeholder`. A run matches when its id holds the
145
+ * text, or when the `title` of the plan of the same analysis holds it. `strpos` is the test, because `LIKE` would turn
146
+ * `%` and `_` of the input into wildcards. The `analysis_id` condition keeps the plan of a different analysis out, the
147
+ * same scope as `loadPlan`.
148
+ */
149
+ function runTextCondition(placeholder) {
150
+ return `(strpos(lower(run_id), lower(${placeholder}::text)) > 0
151
+ OR EXISTS (SELECT 1 FROM cortex_plans p
152
+ WHERE p.plan_id = cortex_runs.plan_id
153
+ AND p.analysis_id = cortex_runs.analysis_id
154
+ AND jsonb_typeof(p.plan -> 'title') = 'string'
155
+ AND strpos(lower(p.plan ->> 'title'), lower(${placeholder}::text)) > 0))`;
156
+ }
142
157
  /**
143
158
  * Every NON-TERMINAL run of an analysis, newest-first and deliberately unbounded.
144
159
  *
@@ -152,35 +167,82 @@ export function queryActiveRun(pool, analysisId, planId) {
152
167
  * analysis's live concurrency, not by its history, and a run leaves the set permanently when it
153
168
  * finishes.
154
169
  *
155
- * The status set is the one {@link queryActiveRun} uses — non-terminal means still running, or
156
- * suspended awaiting funds. Keep the two in step: a new non-terminal status added to one and not the
157
- * other makes a live run invisible to exactly one of its readers.
170
+ * The status set is the one {@link queryActiveRun} uses — non-terminal means `running`. Keep the two
171
+ * in step: a new non-terminal status added to one and not the other makes a live run invisible to
172
+ * exactly one of its readers.
173
+ *
174
+ * With `q`, only the runs that the text filter selects are members.
158
175
  */
159
- export function queryActiveRunsByAnalysis(pool, analysisId) {
176
+ export function queryActiveRunsByAnalysis(pool, analysisId, { q } = {}) {
160
177
  return tryQuery("runs.queryActiveRunsByAnalysis", async () => {
178
+ const values = [analysisId];
179
+ const text = q === undefined ? "" : `AND ${runTextCondition(`$${values.push(q)}`)}`;
161
180
  const result = await pool.query({
162
181
  text: `SELECT run_id, analysis_id, thread_id, workflow_name,
163
182
  status, started_at, completed_at, error, synthesis_status, synthesis_reason, parts,
164
183
  mandate_jti, mandate_expires_at, plan_id -- oss-core-managed-ok: run-mandate ledger (nullable; OSS leaves null)
165
184
  FROM cortex_runs
166
185
  WHERE analysis_id = $1
167
- AND status IN ('running','suspended_insufficient_funds')
186
+ AND status = 'running'
187
+ ${text}
168
188
  ORDER BY started_at DESC`,
169
- values: [analysisId],
189
+ values,
170
190
  });
171
191
  return result.rows.map(mapRunRow);
172
192
  });
173
193
  }
194
+ /**
195
+ * How long a reserved run may wait for its workflow before {@link reconcileOrphanedRuns} fails it. A launch writes
196
+ * the workflow row seconds after the reservation, and the margin covers a slow authorization gate of a managed host.
197
+ */
198
+ export const ORPHANED_RUN_GRACE_MS = 5 * 60 * 1000;
199
+ /**
200
+ * Fail each `running` run that started before the grace and that has no DBOS workflow row, and give the ids of
201
+ * the runs that it failed.
202
+ *
203
+ * A launch reserves the run row, then starts the workflow. A host that dies between the two, or a start that throws
204
+ * while the following status write also fails, leaves a `running` row that no workflow will ever settle. That row
205
+ * holds the active-plan slot, thus each later launch of the plan answers `started: false` for a run that never
206
+ * started. Recovery cannot help, because no workflow row exists to recover.
207
+ *
208
+ * Only a run with no workflow row at all is failed. A run whose workflow exists, also a terminal one, is the
209
+ * workflow's to settle: a failed mark could misname a run that completed. Call it after `DBOS.launch()`, thus a
210
+ * recovered workflow is visible to the predicate.
211
+ */
212
+ export function reconcileOrphanedRuns(pool, opts = {}) {
213
+ const now = opts.now ?? Date.now();
214
+ const before = new Date(now - (opts.graceMs ?? ORPHANED_RUN_GRACE_MS)).toISOString();
215
+ return tryMutation("runs.reconcileOrphanedRuns", async () => {
216
+ const result = await pool.query({
217
+ text: `UPDATE cortex_runs r
218
+ SET status = 'failed',
219
+ error = 'The workflow of the run never started.',
220
+ completed_at = $1
221
+ WHERE r.status = 'running'
222
+ AND r.started_at < $2
223
+ AND NOT EXISTS (SELECT 1 FROM dbos.workflow_status ws WHERE ws.workflow_uuid = r.run_id)
224
+ RETURNING r.run_id`,
225
+ values: [new Date(now).toISOString(), before],
226
+ });
227
+ return result.rows.map((row) => row.run_id);
228
+ });
229
+ }
230
+ /**
231
+ * Write a status to a run row that is still `running`, and report whether the row changed. The first terminal write
232
+ * of a run wins: a row that already holds a terminal status keeps its status, its `completed_at`, and its error.
233
+ * The error text loses each NUL character, because Postgres rejects a NUL in text and the write must not fail for its own value.
234
+ */
174
235
  export function updateRunStatus(pool, runId, status, error) {
175
236
  const now = new Date().toISOString();
176
- const isTerminal = status === "completed" || status === "partial" || status === "failed" || status === "canceled";
237
+ const isTerminal = status === "completed" || status === "partial" || status === "failed" || status === "canceled" || status === "suspended_insufficient_funds";
177
238
  return tryMutation("runs.updateRunStatus", async () => {
178
- await pool.query({
239
+ const result = await pool.query({
179
240
  text: `UPDATE cortex_runs
180
241
  SET status = $1, completed_at = $2, error = $3
181
- WHERE run_id = $4`,
182
- values: [status, isTerminal ? now : null, error ?? null, runId],
242
+ WHERE run_id = $4 AND status = 'running'`,
243
+ values: [status, isTerminal ? now : null, error === undefined || error === null ? null : stripNulCharacters(error), runId],
183
244
  });
245
+ return (result.rowCount ?? 0) > 0;
184
246
  });
185
247
  }
186
248
  /**
@@ -188,9 +250,8 @@ export function updateRunStatus(pool, runId, status, error) {
188
250
  * and recording `reason` in `error` (the same column `collectAndComplete`
189
251
  * stamps `external_cancel` into). Reports whether a row transitioned — false
190
252
  * 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.
253
+ * external cancel). The guarded set is the active set `queryActiveRun`
254
+ * deduplicates on.
194
255
  */
195
256
  export function markRunCanceledIfActive(pool, runId, reason) {
196
257
  const now = new Date().toISOString();
@@ -198,7 +259,7 @@ export function markRunCanceledIfActive(pool, runId, reason) {
198
259
  const result = await pool.query({
199
260
  text: `UPDATE cortex_runs
200
261
  SET status = 'canceled', completed_at = $2, error = $3
201
- WHERE run_id = $1 AND status IN ('running','suspended_insufficient_funds')`,
262
+ WHERE run_id = $1 AND status = 'running'`,
202
263
  values: [runId, now, reason],
203
264
  });
204
265
  return (result.rowCount ?? 0) > 0;
@@ -228,20 +289,79 @@ export function queryRun(pool, runId) {
228
289
  return row ? mapRunRow(row) : null;
229
290
  });
230
291
  }
231
- export function queryRunsByAnalysis(pool, analysisId, { limit = 50, offset = 0 } = {}) {
292
+ /**
293
+ * One window of the runs of an analysis, newest start first. With `q`, the window holds only the runs that the text
294
+ * filter selects: the filter applies before `limit` and `offset`, and it does not change the order.
295
+ */
296
+ export function queryRunsByAnalysis(pool, analysisId, { limit = 50, offset = 0, q } = {}) {
232
297
  return tryQuery("runs.queryRunsByAnalysis", async () => {
298
+ const values = [analysisId, limit, offset];
299
+ const text = q === undefined ? "" : `AND ${runTextCondition(`$${values.push(q)}`)}`;
233
300
  const result = await pool.query({
234
301
  text: `SELECT run_id, analysis_id, thread_id, workflow_name,
235
302
  status, started_at, completed_at, error, synthesis_status, synthesis_reason, parts,
236
303
  mandate_jti, mandate_expires_at, plan_id -- oss-core-managed-ok: run-mandate ledger (nullable; OSS leaves null)
237
304
  FROM cortex_runs
238
305
  WHERE analysis_id = $1
306
+ ${text}
239
307
  ORDER BY started_at DESC LIMIT $2 OFFSET $3`,
240
- values: [analysisId, limit, offset],
308
+ values,
241
309
  });
242
310
  return result.rows.map(mapRunRow);
243
311
  });
244
312
  }
313
+ /**
314
+ * The count of the runs of an analysis, in one statement. With `q`, the count holds only the runs that the text filter
315
+ * selects, thus it agrees with the windows of {@link queryRunsByAnalysis} for the same `q`.
316
+ */
317
+ export function countRunsByAnalysis(pool, analysisId, { q } = {}) {
318
+ return tryQuery("runs.countRunsByAnalysis", async () => {
319
+ const values = [analysisId];
320
+ const text = q === undefined ? "" : `AND ${runTextCondition(`$${values.push(q)}`)}`;
321
+ const result = await pool.query({
322
+ text: `SELECT COUNT(*)::int AS total
323
+ FROM cortex_runs
324
+ WHERE analysis_id = $1
325
+ ${text}`,
326
+ values,
327
+ });
328
+ return Number(result.rows[0]?.total ?? 0);
329
+ });
330
+ }
331
+ /**
332
+ * The run ids of one plan of one analysis, newest start first, at most `limit`.
333
+ * The run id breaks a tie of start times, highest first. A plan with no run gives
334
+ * an empty list.
335
+ */
336
+ export function queryRunIdsByPlan(pool, opts) {
337
+ return tryQuery("runs.queryRunIdsByPlan", async () => {
338
+ const result = await pool.query({
339
+ text: `SELECT run_id FROM cortex_runs
340
+ WHERE analysis_id = $1 AND plan_id = $2
341
+ ORDER BY started_at DESC, run_id DESC
342
+ LIMIT $3`,
343
+ values: [opts.analysisId, opts.planId, opts.limit],
344
+ });
345
+ return result.rows.map((row) => row.run_id);
346
+ });
347
+ }
348
+ /**
349
+ * The newest run of one plan of one analysis, or `null` for a plan with no run. The order is the one of
350
+ * {@link queryRunIdsByPlan}: the latest start first, and the highest run id on a tie of start times.
351
+ */
352
+ export function queryNewestPlanRun(pool, opts) {
353
+ return tryQuery("runs.queryNewestPlanRun", async () => {
354
+ const result = await pool.query({
355
+ text: `SELECT run_id, started_at FROM cortex_runs
356
+ WHERE analysis_id = $1 AND plan_id = $2
357
+ ORDER BY started_at DESC, run_id DESC
358
+ LIMIT 1`,
359
+ values: [opts.analysisId, opts.planId],
360
+ });
361
+ const row = result.rows[0];
362
+ return row ? { runId: row.run_id, startedAt: row.started_at } : null;
363
+ });
364
+ }
245
365
  /**
246
366
  * Read the analysis's non-terminal run activity without coupling it to a
247
367
  * conversation thread. The total is counted before the detail limit so a
@@ -253,7 +373,7 @@ export function queryNonTerminalRunsByAnalysis(pool, analysisId, limit = 20) {
253
373
  text: `SELECT COUNT(*)::int AS total
254
374
  FROM cortex_runs
255
375
  WHERE analysis_id = $1
256
- AND status IN ('running','suspended_insufficient_funds')`,
376
+ AND status = 'running'`,
257
377
  values: [analysisId],
258
378
  });
259
379
  const rowsResult = await pool.query({
@@ -262,13 +382,8 @@ export function queryNonTerminalRunsByAnalysis(pool, analysisId, limit = 20) {
262
382
  mandate_jti, mandate_expires_at, plan_id
263
383
  FROM cortex_runs
264
384
  WHERE analysis_id = $1
265
- AND status IN ('running','suspended_insufficient_funds')
266
- ORDER BY CASE status
267
- WHEN 'running' THEN 0
268
- WHEN 'suspended_insufficient_funds' THEN 1
269
- ELSE 2
270
- END,
271
- started_at DESC,
385
+ AND status = 'running'
386
+ ORDER BY started_at DESC,
272
387
  run_id DESC
273
388
  LIMIT $2`,
274
389
  values: [analysisId, limit],
@@ -280,8 +395,7 @@ export function queryNonTerminalRunsByAnalysis(pool, analysisId, limit = 20) {
280
395
  });
281
396
  }
282
397
  /**
283
- * Read one stable inspection page: running first, then suspended, then
284
- * terminal history. This is deliberately separate from
398
+ * Read one stable inspection page: running first, then each other run. This is deliberately separate from
285
399
  * {@link queryRunsByAnalysis}, whose chronological order is an embedder/UI
286
400
  * contract.
287
401
  */
@@ -299,8 +413,7 @@ export function queryRunsForInspection(pool, analysisId, { limit = 50, offset =
299
413
  WHERE analysis_id = $1
300
414
  ORDER BY CASE status
301
415
  WHEN 'running' THEN 0
302
- WHEN 'suspended_insufficient_funds' THEN 1
303
- ELSE 2
416
+ ELSE 1
304
417
  END,
305
418
  started_at DESC,
306
419
  run_id DESC
@@ -313,16 +426,20 @@ export function queryRunsForInspection(pool, analysisId, { limit = 50, offset =
313
426
  };
314
427
  });
315
428
  }
316
- export function queryRunsByThread(pool, analysisId, threadId, { limit = 50, offset = 0 } = {}) {
429
+ /** The runs of one thread of an analysis, newest start first. With `q`, the text filter applies before the window. */
430
+ export function queryRunsByThread(pool, analysisId, threadId, { limit = 50, offset = 0, q } = {}) {
317
431
  return tryQuery("runs.queryRunsByThread", async () => {
432
+ const values = [analysisId, threadId, limit, offset];
433
+ const text = q === undefined ? "" : `AND ${runTextCondition(`$${values.push(q)}`)}`;
318
434
  const result = await pool.query({
319
435
  text: `SELECT run_id, analysis_id, thread_id, workflow_name,
320
436
  status, started_at, completed_at, error, synthesis_status, synthesis_reason, parts,
321
437
  mandate_jti, mandate_expires_at, plan_id -- oss-core-managed-ok: run-mandate ledger (nullable; OSS leaves null)
322
438
  FROM cortex_runs
323
439
  WHERE analysis_id = $1 AND thread_id = $2
440
+ ${text}
324
441
  ORDER BY started_at DESC LIMIT $3 OFFSET $4`,
325
- values: [analysisId, threadId, limit, offset],
442
+ values,
326
443
  });
327
444
  return result.rows.map(mapRunRow);
328
445
  });