@inflexa-ai/harness 0.43.1 → 0.45.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (119) hide show
  1. package/dist/agents/report-session-agent.d.ts +1 -1
  2. package/dist/app/approve-plan.d.ts +48 -0
  3. package/dist/app/approve-plan.js +84 -0
  4. package/dist/app/data-profile-view.d.ts +1 -1
  5. package/dist/app/run-activity.js +4 -6
  6. package/dist/app/synthesize-run.js +1 -1
  7. package/dist/contracts/chat-events.d.ts +2 -0
  8. package/dist/contracts/chat-frame.d.ts +7 -0
  9. package/dist/contracts/chat-frame.js +18 -4
  10. package/dist/contracts/chat-parts.d.ts +73 -2
  11. package/dist/contracts/chat-parts.js +3 -1
  12. package/dist/contracts/index.d.ts +3 -1
  13. package/dist/contracts/index.js +1 -0
  14. package/dist/contracts/part-registry.js +3 -0
  15. package/dist/contracts/schemas/chat-events.d.ts +13 -0
  16. package/dist/contracts/schemas/chat-events.js +1 -0
  17. package/dist/contracts/schemas/chat-parts.d.ts +200 -15
  18. package/dist/contracts/schemas/chat-parts.js +63 -12
  19. package/dist/contracts/schemas/index.d.ts +1 -1
  20. package/dist/contracts/schemas/index.js +1 -1
  21. package/dist/contracts/subagent-activity.d.ts +12 -0
  22. package/dist/contracts/subagent-activity.js +76 -0
  23. package/dist/execution/artifact-metadata.js +1 -3
  24. package/dist/execution/post-step-pipeline.js +1 -1
  25. package/dist/execution/run-canceler.js +1 -1
  26. package/dist/execution/run-event-parts.d.ts +6 -5
  27. package/dist/execution/run-event-parts.js +6 -5
  28. package/dist/execution/run-event-stream.d.ts +18 -6
  29. package/dist/execution/run-event-stream.js +67 -17
  30. package/dist/execution/run-synthesis.d.ts +21 -0
  31. package/dist/execution/run-synthesis.js +77 -3
  32. package/dist/index.d.ts +24 -10
  33. package/dist/index.js +17 -3
  34. package/dist/lib/hooks.d.ts +1 -1
  35. package/dist/lib/metrics.d.ts +2 -2
  36. package/dist/loop/run-agent.js +1 -0
  37. package/dist/loop/types.d.ts +2 -0
  38. package/dist/memory/card-builders.d.ts +11 -0
  39. package/dist/memory/card-builders.js +35 -21
  40. package/dist/memory/conversation-display-recorder.d.ts +2 -0
  41. package/dist/memory/conversation-display-recorder.js +10 -1
  42. package/dist/memory/conversation-display-storage.d.ts +9 -2
  43. package/dist/memory/conversation-display-storage.js +18 -3
  44. package/dist/memory/thread-history.d.ts +8 -1
  45. package/dist/memory/thread-history.js +13 -3
  46. package/dist/memory/thread-store.d.ts +13 -0
  47. package/dist/memory/thread-store.js +15 -1
  48. package/dist/prompts/conversation.d.ts +1 -1
  49. package/dist/prompts/conversation.js +3 -1
  50. package/dist/provenance/seam.d.ts +16 -5
  51. package/dist/providers/ai-sdk.js +6 -4
  52. package/dist/reference-data/receipt.d.ts +16 -4
  53. package/dist/reference-data/receipt.js +20 -3
  54. package/dist/report-model/pin-snapshot.d.ts +1 -1
  55. package/dist/report-model/pin-snapshot.js +4 -52
  56. package/dist/runtime/assemble.d.ts +9 -2
  57. package/dist/runtime/assemble.js +13 -2
  58. package/dist/runtime/boot.d.ts +3 -0
  59. package/dist/runtime/boot.js +10 -1
  60. package/dist/sandbox/client.d.ts +1 -1
  61. package/dist/sandbox/create-sandbox.js +1 -1
  62. package/dist/sandbox/docker-client.js +1 -1
  63. package/dist/sandbox/k8s-client.js +5 -5
  64. package/dist/sandbox/sandbox-step-translate.d.ts +23 -0
  65. package/dist/sandbox/sandbox-step-translate.js +24 -0
  66. package/dist/schemas/artifact-manifest.d.ts +1 -1
  67. package/dist/schemas/artifact-manifest.js +11 -9
  68. package/dist/schemas/plan-schemas.d.ts +18 -18
  69. package/dist/schemas/run-synthesis.d.ts +2 -2
  70. package/dist/state/analyses-metrics.d.ts +79 -7
  71. package/dist/state/analyses-metrics.js +141 -6
  72. package/dist/state/artifacts.d.ts +50 -3
  73. package/dist/state/artifacts.js +67 -7
  74. package/dist/state/data-profile.d.ts +4 -2
  75. package/dist/state/data-profile.js +7 -5
  76. package/dist/state/index.d.ts +9 -8
  77. package/dist/state/index.js +5 -5
  78. package/dist/state/migrations/20261012120000_index_the_runs_by_start.d.ts +8 -0
  79. package/dist/state/migrations/20261012120000_index_the_runs_by_start.js +12 -0
  80. package/dist/state/migrations/20261013120000_index_the_ended_runs_and_the_recent_threads.d.ts +8 -0
  81. package/dist/state/migrations/20261013120000_index_the_ended_runs_and_the_recent_threads.js +13 -0
  82. package/dist/state/migrations/20261013180000_narrow_the_active_run_index.d.ts +8 -0
  83. package/dist/state/migrations/20261013180000_narrow_the_active_run_index.js +13 -0
  84. package/dist/state/migrations/20261014120000_index_the_plans_by_parent_and_the_runs_by_plan.d.ts +8 -0
  85. package/dist/state/migrations/20261014120000_index_the_plans_by_parent_and_the_runs_by_plan.js +13 -0
  86. package/dist/state/migrations/index.js +8 -0
  87. package/dist/state/plans.d.ts +21 -0
  88. package/dist/state/plans.js +25 -0
  89. package/dist/state/runs.d.ts +36 -5
  90. package/dist/state/runs.js +65 -19
  91. package/dist/state/schema.d.ts +2 -0
  92. package/dist/tasks/data-profile-activity.d.ts +7 -1
  93. package/dist/tasks/data-profile-activity.js +18 -9
  94. package/dist/tasks/data-profile.d.ts +15 -1
  95. package/dist/tasks/data-profile.js +18 -13
  96. package/dist/tools/approval/gateway.js +28 -5
  97. package/dist/tools/display/show-user.d.ts +1 -1
  98. package/dist/tools/execute-analysis.d.ts +51 -0
  99. package/dist/tools/execute-analysis.js +241 -109
  100. package/dist/tools/report-session/derive-table.js +1 -1
  101. package/dist/tools/report-session/examine-page.js +1 -1
  102. package/dist/tools/report-session/list-artifacts.js +2 -2
  103. package/dist/tools/report-session/preview-report.d.ts +1 -1
  104. package/dist/tools/report-session/preview-report.js +6 -6
  105. package/dist/tools/report-session/record-version.d.ts +1 -1
  106. package/dist/tools/report-session/record-version.js +4 -4
  107. package/dist/tools/research/inspect-data-profile.d.ts +1 -1
  108. package/dist/tools/research/inspect-run.d.ts +1 -1
  109. package/dist/tools/research/inspect-run.js +7 -7
  110. package/dist/tools/workspace/mutator.js +2 -2
  111. package/dist/workflows/execute-analysis.d.ts +4 -3
  112. package/dist/workflows/execute-analysis.js +38 -25
  113. package/dist/workflows/plan/graph.d.ts +16 -0
  114. package/dist/workflows/plan/graph.js +36 -0
  115. package/dist/workflows/sandbox-step.d.ts +2 -2
  116. package/dist/workflows/sandbox-step.js +16 -14
  117. package/dist/workspace/filesystem.js +18 -22
  118. package/dist/workspace/paths.d.ts +7 -4
  119. package/package.json +4 -4
@@ -1,9 +1,11 @@
1
1
  /**
2
- * Cross-entity aggregate reads used by `routes/analyses-metrics.ts` to build
3
- * the per-analysis metrics view. Each query touches one table; the view is
4
- * the batch shape callers want.
2
+ * Cross-entity aggregate reads for a list of analyses. Each read takes the ids
3
+ * of a page and sends one statement, whatever the length of the list: an
4
+ * embedder builds the per-analysis summary of a list from these reads, never
5
+ * from one read for each analysis.
5
6
  */
6
7
  import { okAsync } from "neverthrow";
8
+ import { profileDatasetFileCount } from "../app/data-profile-view.js";
7
9
  import { tryQuery } from "../lib/db-result.js";
8
10
  export function queryRunCountsByAnalyses(pool, analysisIds) {
9
11
  if (analysisIds.length === 0)
@@ -41,19 +43,152 @@ export function queryThreadCountsByAnalyses(pool, analysisIds) {
41
43
  }));
42
44
  });
43
45
  }
46
+ /** A plan title as text: trimmed with `trim()`, and `null` when it is blank. The SQL gives the title only when it is a string. */
47
+ function titleOf(title) {
48
+ const trimmed = title?.trim() ?? "";
49
+ return trimmed === "" ? null : trimmed;
50
+ }
51
+ /**
52
+ * The columns of a run summary over a run alias `r`, and the plan alias `p` and
53
+ * the step count alias `s` of `RUN_PROGRESS_JOINS`. The two reads of a run
54
+ * summary put these after their own selection of `r`, thus the title rule and
55
+ * the step count rule have one text.
56
+ */
57
+ const RUN_PROGRESS_COLUMNS = `r.analysis_id, r.run_id, r.status, r.started_at, r.completed_at,
58
+ CASE WHEN jsonb_typeof(p.plan->'title') = 'string' THEN p.plan->>'title' END AS plan_title,
59
+ s.done_steps, s.total_steps`;
60
+ /** The plan join and the step count subquery for a run alias `r` that selects `analysis_id`, `run_id`, and `plan_id`. */
61
+ const RUN_PROGRESS_JOINS = `LEFT JOIN cortex_plans p ON p.plan_id = r.plan_id AND p.analysis_id = r.analysis_id
62
+ CROSS JOIN LATERAL (
63
+ SELECT COUNT(*)::int AS total_steps,
64
+ (COUNT(*) FILTER (WHERE status = 'completed'))::int AS done_steps
65
+ FROM cortex_step_executions
66
+ WHERE run_id = r.run_id
67
+ ) s`;
68
+ function toRunSummary(row) {
69
+ return {
70
+ analysisId: row.analysis_id,
71
+ runId: row.run_id,
72
+ status: row.status,
73
+ planTitle: titleOf(row.plan_title),
74
+ doneSteps: row.done_steps,
75
+ totalSteps: row.total_steps,
76
+ startedAt: row.started_at,
77
+ completedAt: row.completed_at,
78
+ };
79
+ }
80
+ /**
81
+ * The newest run of each analysis of a list, in one statement. An analysis with
82
+ * no run has no entry. The newest run has the latest `started_at`, and `run_id`
83
+ * breaks a tie in descending order. The `LATERAL` subquery reads
84
+ * `idx_cortex_runs_analysis_started`, thus one index entry for each analysis
85
+ * and not a sort of the run history.
86
+ *
87
+ * The plan joins on `plan_id` and `analysis_id`: a plan of a different analysis
88
+ * gives no title, the scope rule of `loadPlan`. The statement gives the title
89
+ * only when it is a string, and the row mapping trims it with `trim()`, the
90
+ * rule of the cli `planTitleOf`. A blank title or a title that is not a string
91
+ * reads as `null`. The step counts
92
+ * cover the ledger rows of the selected run only, the reserved synthesis row
93
+ * included.
94
+ */
95
+ export function queryLatestRunsByAnalyses(pool, analysisIds) {
96
+ if (analysisIds.length === 0)
97
+ return okAsync([]);
98
+ return tryQuery("analysesMetrics.queryLatestRunsByAnalyses", async () => {
99
+ const result = await pool.query({
100
+ text: `SELECT ${RUN_PROGRESS_COLUMNS}
101
+ FROM unnest($1::text[]) AS a(id)
102
+ CROSS JOIN LATERAL (
103
+ SELECT analysis_id, run_id, status, started_at, completed_at, plan_id
104
+ FROM cortex_runs
105
+ WHERE analysis_id = a.id
106
+ ORDER BY started_at DESC, run_id DESC
107
+ LIMIT 1
108
+ ) r
109
+ ${RUN_PROGRESS_JOINS}`,
110
+ values: [analysisIds],
111
+ });
112
+ return result.rows.map(toRunSummary);
113
+ });
114
+ }
115
+ /**
116
+ * The progress of each run of a list of run ids, in one statement. An id with
117
+ * no run row has no entry. The entries come newest first by `started_at`, and
118
+ * `run_id` breaks a tie in descending order. The plan title and the step counts
119
+ * follow the rules of `queryLatestRunsByAnalyses`, and each entry adds the
120
+ * thread of the run.
121
+ */
122
+ export function queryRunProgressByRuns(pool, runIds) {
123
+ if (runIds.length === 0)
124
+ return okAsync([]);
125
+ return tryQuery("analysesMetrics.queryRunProgressByRuns", async () => {
126
+ const result = await pool.query({
127
+ text: `SELECT ${RUN_PROGRESS_COLUMNS}, r.thread_id
128
+ FROM cortex_runs r
129
+ ${RUN_PROGRESS_JOINS}
130
+ WHERE r.run_id = ANY($1::text[])
131
+ ORDER BY r.started_at DESC, r.run_id DESC`,
132
+ values: [runIds],
133
+ });
134
+ return result.rows.map((row) => ({ ...toRunSummary(row), threadId: row.thread_id }));
135
+ });
136
+ }
137
+ /**
138
+ * The newest `limit` terminal runs of every analysis that ended at or after
139
+ * `since`, newest completion first, with `run_id` breaking a tie in descending
140
+ * order. `since` is in the form of `toISOString()`, the form of each
141
+ * `completed_at` stamp, thus the text comparison is the time comparison. The
142
+ * statement walks `idx_cortex_runs_completed`.
143
+ */
144
+ export function queryRunsEndedSince(pool, input) {
145
+ return tryQuery("analysesMetrics.queryRunsEndedSince", async () => {
146
+ const result = await pool.query({
147
+ text: `SELECT analysis_id, run_id, status, completed_at
148
+ FROM cortex_runs
149
+ WHERE status IN ('completed', 'partial', 'failed', 'canceled', 'suspended_insufficient_funds')
150
+ AND completed_at >= $1
151
+ ORDER BY completed_at DESC, run_id DESC
152
+ LIMIT $2`,
153
+ values: [input.since, input.limit],
154
+ });
155
+ return result.rows.map((row) => ({
156
+ analysisId: row.analysis_id,
157
+ runId: row.run_id,
158
+ status: row.status,
159
+ completedAt: row.completed_at,
160
+ }));
161
+ });
162
+ }
163
+ /**
164
+ * The profile state of each analysis of a list, in one statement. An analysis
165
+ * with no ledger row, or with a cleared row whose status is null, has no entry:
166
+ * the same collapsed absence as `loadDataProfileStatus`. The statement selects
167
+ * only the three parts of the stored result that the file count reads, and the
168
+ * count itself is `profileDatasetFileCount`.
169
+ */
44
170
  export function queryDataProfileStatusByAnalyses(pool, analysisIds) {
45
171
  if (analysisIds.length === 0)
46
172
  return okAsync([]);
47
173
  return tryQuery("analysesMetrics.queryDataProfileStatusByAnalyses", async () => {
48
174
  const result = await pool.query({
49
- text: `SELECT analysis_id, data_profile_status
175
+ text: `SELECT analysis_id, data_profile_status, data_profile_completed_at,
176
+ data_profile_result->'partition' AS partition,
177
+ data_profile_result->'coverage' AS coverage,
178
+ data_profile_result->'kinds' AS kinds
50
179
  FROM cortex_analysis_state
51
- WHERE analysis_id = ANY($1::text[])`,
180
+ WHERE analysis_id = ANY($1::text[]) AND data_profile_status IS NOT NULL`,
52
181
  values: [analysisIds],
53
182
  });
54
183
  return result.rows.map((r) => ({
55
184
  analysisId: r.analysis_id,
56
- status: r.data_profile_status ?? null,
185
+ status: r.data_profile_status,
186
+ completedAt: r.data_profile_completed_at ?? null,
187
+ profiledFiles: profileDatasetFileCount({
188
+ ...(r.partition === null ? {} : { partition: r.partition }),
189
+ ...(r.coverage === null ? {} : { coverage: r.coverage }),
190
+ ...(r.kinds === null ? {} : { kinds: r.kinds }),
191
+ }),
57
192
  }));
58
193
  });
59
194
  }
@@ -5,7 +5,7 @@
5
5
  import type { ResultAsync } from "neverthrow";
6
6
  import { type DbError } from "../lib/db-result.js";
7
7
  import type { Querier } from "./db.js";
8
- import type { ArtifactRow } from "./schema.js";
8
+ import type { ArtifactRole, ArtifactRow } from "./schema.js";
9
9
  export interface RegisterArtifactInput {
10
10
  resourceId: string;
11
11
  path: string;
@@ -75,8 +75,55 @@ export interface AnalysisArtifactRef {
75
75
  * false.
76
76
  */
77
77
  export declare function queryAnalysisArtifacts(pool: Querier, analysisId: string): ResultAsync<AnalysisArtifactRef[], DbError>;
78
- /** Count step-output artifacts produced by a run — for the run-completed card. */
79
- export declare function countArtifactsForRun(pool: Querier, analysisId: string, runId: string): ResultAsync<number, DbError>;
78
+ /** The sort of {@link queryArtifactPage}: `path` is path order, `recent` is newest registration first. */
79
+ export type ArtifactPageSort = "path" | "recent";
80
+ /** The filters, the sort, and the window of {@link queryArtifactPage}. A filter that is absent selects each row. */
81
+ export interface ArtifactPageQuery {
82
+ runId?: string;
83
+ stepId?: string;
84
+ fileType?: string;
85
+ /** A part of the path, with no regard to case. Each character matches only itself. */
86
+ q?: string;
87
+ sort: ArtifactPageSort;
88
+ limit: number;
89
+ offset: number;
90
+ }
91
+ /** One ledger row of {@link queryArtifactPage}. */
92
+ export interface ArtifactPageEntry {
93
+ /** Workspace-root-relative path (`runs/{runId}/{stepId}/output/de.csv`). */
94
+ path: string;
95
+ /** Bytes. */
96
+ size: number;
97
+ fileType: string | null;
98
+ role: ArtifactRole;
99
+ runId: string | null;
100
+ stepId: string | null;
101
+ createdAt: string;
102
+ }
103
+ /** One page of the ledger, and the count of the rows that the filters select. */
104
+ export interface ArtifactPage {
105
+ entries: ArtifactPageEntry[];
106
+ total: number;
107
+ }
108
+ /**
109
+ * One page of the artifact ledger of an analysis, in two statements: the count of the rows that the filters
110
+ * select, and the page. The count is a separate statement because a page past the end has no row to carry it.
111
+ *
112
+ * Every row of the analysis is a member, an `input` row and a row with `unrecoverable_at` included. The cost
113
+ * grows with the ledger of the one analysis, because the count reads each filtered row.
114
+ */
115
+ export declare function queryArtifactPage(pool: Querier, analysisId: string, query: ArtifactPageQuery): ResultAsync<ArtifactPage, DbError>;
116
+ /** The step-output count of one run, from {@link countArtifactsByRuns}. */
117
+ export interface RunArtifactCount {
118
+ runId: string;
119
+ count: number;
120
+ }
121
+ /**
122
+ * The step-output count of each run of a list, in one statement. A run with no step output has no entry,
123
+ * and a caller reads it as 0. An `input` row never counts. The run-completed card and a list of runs use
124
+ * this one rule.
125
+ */
126
+ export declare function countArtifactsByRuns(pool: Querier, analysisId: string, runIds: readonly string[]): ResultAsync<RunArtifactCount[], DbError>;
80
127
  export declare function updateArtifactId(pool: Querier, resourceId: string, path: string, artifactId: string, fileType?: string | null): ResultAsync<void, DbError>;
81
128
  export declare function updateFileIds(pool: Querier, pairs: Array<{
82
129
  artifactId: string;
@@ -179,16 +179,76 @@ export function queryAnalysisArtifacts(pool, analysisId) {
179
179
  return result.rows.map((r) => ({ path: r.path, hash: r.hash, fileType: r.file_type ?? null }));
180
180
  });
181
181
  }
182
- /** Count step-output artifacts produced by a run — for the run-completed card. */
183
- export function countArtifactsForRun(pool, analysisId, runId) {
184
- return tryQuery("artifacts.countArtifactsForRun", async () => {
182
+ /**
183
+ * One page of the artifact ledger of an analysis, in two statements: the count of the rows that the filters
184
+ * select, and the page. The count is a separate statement because a page past the end has no row to carry it.
185
+ *
186
+ * Every row of the analysis is a member, an `input` row and a row with `unrecoverable_at` included. The cost
187
+ * grows with the ledger of the one analysis, because the count reads each filtered row.
188
+ */
189
+ export function queryArtifactPage(pool, analysisId, query) {
190
+ return tryQuery("artifacts.queryArtifactPage", async () => {
191
+ const values = [analysisId];
192
+ const conditions = ["analysis_id = $1"];
193
+ const bind = (value) => {
194
+ values.push(value);
195
+ return `$${values.length}`;
196
+ };
197
+ if (query.runId !== undefined)
198
+ conditions.push(`source_run = ${bind(query.runId)}`);
199
+ if (query.stepId !== undefined)
200
+ conditions.push(`source_step = ${bind(query.stepId)}`);
201
+ if (query.fileType !== undefined)
202
+ conditions.push(`file_type = ${bind(query.fileType)}`);
203
+ // `LIKE` would turn `%` and `_` of the input into wildcards.
204
+ if (query.q !== undefined)
205
+ conditions.push(`strpos(lower(path), lower(${bind(query.q)}::text)) > 0`);
206
+ const where = conditions.join(" AND ");
207
+ const countResult = await pool.query({
208
+ text: `SELECT COUNT(*)::int AS total FROM cortex_artifacts WHERE ${where}`,
209
+ values,
210
+ });
211
+ // `path` is unique in one analysis, thus it breaks each tie and two pages never overlap.
212
+ const order = query.sort === "recent" ? "created_at DESC, path ASC" : "path ASC";
213
+ const rowsResult = await pool.query({
214
+ text: `SELECT path, size, file_type, role, source_run, source_step, created_at
215
+ FROM cortex_artifacts
216
+ WHERE ${where}
217
+ ORDER BY ${order}
218
+ LIMIT ${bind(query.limit)} OFFSET ${bind(query.offset)}`,
219
+ values,
220
+ });
221
+ return {
222
+ entries: rowsResult.rows.map((r) => ({
223
+ path: r.path,
224
+ size: Number(r.size),
225
+ fileType: r.file_type ?? null,
226
+ role: r.role,
227
+ runId: r.source_run ?? null,
228
+ stepId: r.source_step ?? null,
229
+ createdAt: r.created_at,
230
+ })),
231
+ total: Number(countResult.rows[0]?.total ?? 0),
232
+ };
233
+ });
234
+ }
235
+ /**
236
+ * The step-output count of each run of a list, in one statement. A run with no step output has no entry,
237
+ * and a caller reads it as 0. An `input` row never counts. The run-completed card and a list of runs use
238
+ * this one rule.
239
+ */
240
+ export function countArtifactsByRuns(pool, analysisId, runIds) {
241
+ return tryQuery("artifacts.countArtifactsByRuns", async () => {
242
+ if (runIds.length === 0)
243
+ return [];
185
244
  const result = await pool.query({
186
- text: `SELECT COUNT(*)::text AS n
245
+ text: `SELECT source_run, COUNT(*)::int AS n
187
246
  FROM cortex_artifacts
188
- WHERE analysis_id = $1 AND source_run = $2 AND role = 'step_output'`,
189
- values: [analysisId, runId],
247
+ WHERE analysis_id = $1 AND source_run = ANY($2::text[]) AND role = 'step_output'
248
+ GROUP BY source_run`,
249
+ values: [analysisId, [...runIds]],
190
250
  });
191
- return Number(result.rows[0]?.n ?? 0);
251
+ return result.rows.map((r) => ({ runId: r.source_run, count: Number(r.n) }));
192
252
  });
193
253
  }
194
254
  export function updateArtifactId(pool, resourceId, path, artifactId, fileType) {
@@ -101,12 +101,14 @@ export declare function recordDataProfileWorkflowId(pool: Querier, analysisId: s
101
101
  * write per `running` claim, so `stamped: true` is the exactly-once point of a
102
102
  * profile's terminal transition. It carries the `data_profile_started_at` of the
103
103
  * claim it closed, so the caller measures the profile from the ledger's own start.
104
+ * It also carries the `data_profile_completed_at` that the write stamped.
104
105
  */
105
106
  export type DataProfileTerminalWrite = {
106
107
  readonly stamped: false;
107
108
  } | {
108
109
  readonly stamped: true;
109
110
  readonly startedAt: string | null;
111
+ readonly completedAt: string;
110
112
  };
111
113
  /**
112
114
  * Stamp a claimed-`running` row `completed` and record its result — a terminal
@@ -124,7 +126,7 @@ export type DataProfileTerminalWrite = {
124
126
  * guard only ever refuses a row another writer already moved on from.
125
127
  *
126
128
  * `stamped: true` when this call stamped the row, with the `data_profile_started_at`
127
- * of the claim it closed; `stamped: false` when the guard refused it (the row was
129
+ * of the claim it closed and the `data_profile_completed_at` it stamped; `stamped: false` when the guard refused it (the row was
128
130
  * cleared/expired/replayed away) — a no-op the caller logs, not an error.
129
131
  */
130
132
  export declare function completeDataProfile(pool: Querier, analysisId: string, result?: DataProfileResult): ResultAsync<DataProfileTerminalWrite, DbError>;
@@ -137,7 +139,7 @@ export declare function completeDataProfile(pool: Querier, analysisId: string, r
137
139
  * prior profile survives the failure (the re-profile/retry route can keep serving it).
138
140
  *
139
141
  * `stamped: true` when this call stamped the row, with the `data_profile_started_at`
140
- * of the claim it closed; `stamped: false` when the guard refused it — a logged
142
+ * of the claim it closed and the `data_profile_completed_at` it stamped; `stamped: false` when the guard refused it — a logged
141
143
  * no-op, not an error.
142
144
  */
143
145
  export declare function failDataProfile(pool: Querier, analysisId: string, error: string): ResultAsync<DataProfileTerminalWrite, DbError>;
@@ -144,7 +144,9 @@ export function recordDataProfileWorkflowId(pool, analysisId, workflowId) {
144
144
  }
145
145
  function terminalWriteOf(rows) {
146
146
  const row = rows[0];
147
- return row === undefined ? { stamped: false } : { stamped: true, startedAt: row.data_profile_started_at ?? null };
147
+ return row === undefined
148
+ ? { stamped: false }
149
+ : { stamped: true, startedAt: row.data_profile_started_at ?? null, completedAt: row.data_profile_completed_at };
148
150
  }
149
151
  /**
150
152
  * Stamp a claimed-`running` row `completed` and record its result — a terminal
@@ -162,7 +164,7 @@ function terminalWriteOf(rows) {
162
164
  * guard only ever refuses a row another writer already moved on from.
163
165
  *
164
166
  * `stamped: true` when this call stamped the row, with the `data_profile_started_at`
165
- * of the claim it closed; `stamped: false` when the guard refused it (the row was
167
+ * of the claim it closed and the `data_profile_completed_at` it stamped; `stamped: false` when the guard refused it (the row was
166
168
  * cleared/expired/replayed away) — a no-op the caller logs, not an error.
167
169
  */
168
170
  export function completeDataProfile(pool, analysisId, result) {
@@ -173,7 +175,7 @@ export function completeDataProfile(pool, analysisId, result) {
173
175
  SET data_profile_status = 'completed', data_profile_completed_at = $1,
174
176
  data_profile_result = $2::jsonb
175
177
  WHERE analysis_id = $3 AND data_profile_status = 'running'
176
- RETURNING data_profile_started_at`,
178
+ RETURNING data_profile_started_at, data_profile_completed_at`,
177
179
  values: [now, result ? JSON.stringify(result) : null, analysisId],
178
180
  });
179
181
  return terminalWriteOf(res.rows);
@@ -188,7 +190,7 @@ export function completeDataProfile(pool, analysisId, result) {
188
190
  * prior profile survives the failure (the re-profile/retry route can keep serving it).
189
191
  *
190
192
  * `stamped: true` when this call stamped the row, with the `data_profile_started_at`
191
- * of the claim it closed; `stamped: false` when the guard refused it — a logged
193
+ * of the claim it closed and the `data_profile_completed_at` it stamped; `stamped: false` when the guard refused it — a logged
192
194
  * no-op, not an error.
193
195
  */
194
196
  export function failDataProfile(pool, analysisId, error) {
@@ -199,7 +201,7 @@ export function failDataProfile(pool, analysisId, error) {
199
201
  SET data_profile_status = 'failed', data_profile_error = $1,
200
202
  data_profile_completed_at = $2
201
203
  WHERE analysis_id = $3 AND data_profile_status = 'running'
202
- RETURNING data_profile_started_at`,
204
+ RETURNING data_profile_started_at, data_profile_completed_at`,
203
205
  values: [error, now, analysisId],
204
206
  });
205
207
  return terminalWriteOf(res.rows);
@@ -9,20 +9,21 @@
9
9
  export { initCortexState } from "./init.js";
10
10
  export type { Querier } from "./db.js";
11
11
  export { upsertAnalysis, suspendAnalysis, resumeAnalysis, loadAnalysisStatus } from "./analyses.js";
12
- export { upsertArtifact, upsertArtifacts, queryInputArtifacts, queryStepArtifactPaths, queryUnsyncedStepArtifacts, updateArtifactId, updateFileIds, countArtifactsForRun, } from "./artifacts.js";
13
- export type { RegisterArtifactInput, InputArtifactMeta, StepArtifactRef } from "./artifacts.js";
14
- export { insertRun, reserveRunById, RunDedupCollisionError, RunIdentityCollisionError, updateRunStatus, markRunCanceledIfActive, promoteFailedToPartial, setRunMandate, // oss-core-managed-ok: run-mandate ledger (nullable; OSS leaves null)
15
- setRunSynthesisOutcome, queryRun, queryActiveRun, queryNonTerminalRunsByAnalysis, queryRunsForInspection, queryRunsByAnalysis, queryRunsByThread, } from "./runs.js";
12
+ export { upsertArtifact, upsertArtifacts, queryInputArtifacts, queryStepArtifactPaths, queryUnsyncedStepArtifacts, updateArtifactId, updateFileIds, queryArtifactPage, countArtifactsByRuns, } from "./artifacts.js";
13
+ export type { RegisterArtifactInput, InputArtifactMeta, StepArtifactRef, ArtifactPage, ArtifactPageEntry, ArtifactPageQuery, ArtifactPageSort, RunArtifactCount, } from "./artifacts.js";
14
+ 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)
15
+ setRunSynthesisOutcome, queryRun, queryActiveRun, queryNonTerminalRunsByAnalysis, queryRunsForInspection, queryRunsByAnalysis, queryRunsByThread, queryRunIdsByPlan, } from "./runs.js";
16
16
  export type { InsertRunInput, RunPage, RunReservation } from "./runs.js";
17
17
  export { insertStepExecution, seedStepExecutions, sweepPendingStepExecutions, updateStepExecution, queryStepsByRun } from "./step-executions.js";
18
18
  export type { InsertStepExecutionInput, SeedStepExecutionRow, UpdateStepExecutionInput } from "./step-executions.js";
19
19
  export { setSandboxRef, clearSandboxRef, reconcileReapedSandbox } from "./active-sandboxes.js";
20
- export { insertPlan, upsertPlan, loadPlan } from "./plans.js";
21
- export type { InsertPlanInput, UpsertPlanInput } from "./plans.js";
20
+ export { insertPlan, upsertPlan, loadPlan, loadPlanRecord } from "./plans.js";
21
+ export type { InsertPlanInput, UpsertPlanInput, PlanRecord } from "./plans.js";
22
22
  export { tryStartDataProfile, tryRetryDataProfile, tryRerunDataProfile, completeDataProfile, failDataProfile, expireStaleDataProfile, reconcileOrphanedDataProfile, clearDataProfile, completeEmptyDataProfile, loadDataProfileStatus, loadSeedInputFileIds, recordDataProfileWorkflowId, } from "./data-profile.js";
23
23
  export type { DataProfileStatus, DataProfileTerminalWrite, DataProfileResult, DataProfileChecked, DataProfileCompanionCompleteness, DataProfileDimension, DataProfileGroup, DataProfileGroupSlot, DataProfileFile, DataProfileMemberAnnotation, DataProfileObservation, DataProfilePartition, DataProfileProbeReport, DataProfileQualityAssessment, DataProfileQuarantine, DataProfileRecipeStep, DataProfileKind, DataProfileAxis, DataProfileInputSignature, DataProfileCoverage, } from "./data-profile.js";
24
- export { queryRunCountsByAnalyses, queryThreadCountsByAnalyses, queryDataProfileStatusByAnalyses } from "./analyses-metrics.js";
24
+ export { queryRunCountsByAnalyses, queryThreadCountsByAnalyses, queryDataProfileStatusByAnalyses, queryLatestRunsByAnalyses, queryRunProgressByRuns, queryRunsEndedSince, } from "./analyses-metrics.js";
25
+ export type { DataProfileSummary, EndedRunSummary, LatestRunSummary, RunProgressSummary } from "./analyses-metrics.js";
25
26
  export { createAnalysisPurge } from "./purge-analysis.js";
26
27
  export type { AnalysisPurge, AnalysisPurgeDeps, AnalysisPurgeOutcome } from "./purge-analysis.js";
27
28
  export { AnalysisStateRowSchema, ArtifactRole, ArtifactRowSchema, RunStatus, StepExecutionStatus, SynthesisStatus, CortexRunRowSchema, StepExecutionRowSchema, CortexPlanRowSchema, } from "./schema.js";
28
- export type { AnalysisStateRow, ArtifactRow, CortexRunRow, StepExecutionRow, CortexPlanRow } from "./schema.js";
29
+ export type { AnalysisStateRow, ArtifactRow, CortexRunRow, StepExecutionRow, CortexPlanRow, TerminalRunStatus } from "./schema.js";
@@ -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, queryRunsByThread, queryRunIdsByPlan, } 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
+ }