@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
@@ -77,11 +77,33 @@ export declare function queryActiveRun(pool: Querier, analysisId: string, planId
77
77
  * analysis's live concurrency, not by its history, and a run leaves the set permanently when it
78
78
  * finishes.
79
79
  *
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.
80
+ * The status set is the one {@link queryActiveRun} uses — non-terminal means `running`. Keep the two
81
+ * in step: a new non-terminal status added to one and not the other makes a live run invisible to
82
+ * exactly one of its readers.
83
83
  */
84
84
  export declare function queryActiveRunsByAnalysis(pool: Querier, analysisId: string): ResultAsync<CortexRunRow[], DbError>;
85
+ /**
86
+ * How long a reserved run may wait for its workflow before {@link reconcileOrphanedRuns} fails it. A launch writes
87
+ * the workflow row seconds after the reservation, and the margin covers a slow authorization gate of a managed host.
88
+ */
89
+ export declare const ORPHANED_RUN_GRACE_MS: number;
90
+ /**
91
+ * Fail each `running` run that started before the grace and that has no DBOS workflow row, and give the ids of
92
+ * the runs that it failed.
93
+ *
94
+ * A launch reserves the run row, then starts the workflow. A host that dies between the two, or a start that throws
95
+ * while the following status write also fails, leaves a `running` row that no workflow will ever settle. That row
96
+ * holds the active-plan slot, thus each later launch of the plan answers `started: false` for a run that never
97
+ * started. Recovery cannot help, because no workflow row exists to recover.
98
+ *
99
+ * Only a run with no workflow row at all is failed. A run whose workflow exists, also a terminal one, is the
100
+ * workflow's to settle: a failed mark could misname a run that completed. Call it after `DBOS.launch()`, thus a
101
+ * recovered workflow is visible to the predicate.
102
+ */
103
+ export declare function reconcileOrphanedRuns(pool: Querier, opts?: {
104
+ now?: number;
105
+ graceMs?: number;
106
+ }): ResultAsync<string[], DbError>;
85
107
  export declare function updateRunStatus(pool: Querier, runId: string, status: "running" | "completed" | "partial" | "failed" | "canceled" | "suspended_insufficient_funds", error?: string | null): ResultAsync<void, DbError>;
86
108
  /**
87
109
  * Conditionally transition an ACTIVE run to `canceled`, stamping `completed_at`
@@ -99,6 +121,16 @@ export declare function queryRunsByAnalysis(pool: Querier, analysisId: string, {
99
121
  limit?: number;
100
122
  offset?: number;
101
123
  }): ResultAsync<CortexRunRow[], DbError>;
124
+ /**
125
+ * The run ids of one plan of one analysis, newest start first, at most `limit`.
126
+ * The run id breaks a tie of start times, highest first. A plan with no run gives
127
+ * an empty list.
128
+ */
129
+ export declare function queryRunIdsByPlan(pool: Querier, opts: {
130
+ analysisId: string;
131
+ planId: string;
132
+ limit: number;
133
+ }): ResultAsync<string[], DbError>;
102
134
  /**
103
135
  * Read the analysis's non-terminal run activity without coupling it to a
104
136
  * conversation thread. The total is counted before the detail limit so a
@@ -106,8 +138,7 @@ export declare function queryRunsByAnalysis(pool: Querier, analysisId: string, {
106
138
  */
107
139
  export declare function queryNonTerminalRunsByAnalysis(pool: Querier, analysisId: string, limit?: number): ResultAsync<RunPage, DbError>;
108
140
  /**
109
- * Read one stable inspection page: running first, then suspended, then
110
- * terminal history. This is deliberately separate from
141
+ * Read one stable inspection page: running first, then each other run. This is deliberately separate from
111
142
  * {@link queryRunsByAnalysis}, whose chronological order is an embedder/UI
112
143
  * contract.
113
144
  */
@@ -132,7 +132,7 @@ export function queryActiveRun(pool, analysisId, planId) {
132
132
  FROM cortex_runs
133
133
  WHERE analysis_id = $1
134
134
  AND plan_id IS NOT DISTINCT FROM $2
135
- AND status IN ('running','suspended_insufficient_funds')`,
135
+ AND status = 'running'`,
136
136
  values: [analysisId, planId],
137
137
  });
138
138
  const row = result.rows[0];
@@ -152,9 +152,9 @@ export function queryActiveRun(pool, analysisId, planId) {
152
152
  * analysis's live concurrency, not by its history, and a run leaves the set permanently when it
153
153
  * finishes.
154
154
  *
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.
155
+ * The status set is the one {@link queryActiveRun} uses — non-terminal means `running`. Keep the two
156
+ * in step: a new non-terminal status added to one and not the other makes a live run invisible to
157
+ * exactly one of its readers.
158
158
  */
159
159
  export function queryActiveRunsByAnalysis(pool, analysisId) {
160
160
  return tryQuery("runs.queryActiveRunsByAnalysis", async () => {
@@ -164,16 +164,52 @@ export function queryActiveRunsByAnalysis(pool, analysisId) {
164
164
  mandate_jti, mandate_expires_at, plan_id -- oss-core-managed-ok: run-mandate ledger (nullable; OSS leaves null)
165
165
  FROM cortex_runs
166
166
  WHERE analysis_id = $1
167
- AND status IN ('running','suspended_insufficient_funds')
167
+ AND status = 'running'
168
168
  ORDER BY started_at DESC`,
169
169
  values: [analysisId],
170
170
  });
171
171
  return result.rows.map(mapRunRow);
172
172
  });
173
173
  }
174
+ /**
175
+ * How long a reserved run may wait for its workflow before {@link reconcileOrphanedRuns} fails it. A launch writes
176
+ * the workflow row seconds after the reservation, and the margin covers a slow authorization gate of a managed host.
177
+ */
178
+ export const ORPHANED_RUN_GRACE_MS = 5 * 60 * 1000;
179
+ /**
180
+ * Fail each `running` run that started before the grace and that has no DBOS workflow row, and give the ids of
181
+ * the runs that it failed.
182
+ *
183
+ * A launch reserves the run row, then starts the workflow. A host that dies between the two, or a start that throws
184
+ * while the following status write also fails, leaves a `running` row that no workflow will ever settle. That row
185
+ * holds the active-plan slot, thus each later launch of the plan answers `started: false` for a run that never
186
+ * started. Recovery cannot help, because no workflow row exists to recover.
187
+ *
188
+ * Only a run with no workflow row at all is failed. A run whose workflow exists, also a terminal one, is the
189
+ * workflow's to settle: a failed mark could misname a run that completed. Call it after `DBOS.launch()`, thus a
190
+ * recovered workflow is visible to the predicate.
191
+ */
192
+ export function reconcileOrphanedRuns(pool, opts = {}) {
193
+ const now = opts.now ?? Date.now();
194
+ const before = new Date(now - (opts.graceMs ?? ORPHANED_RUN_GRACE_MS)).toISOString();
195
+ return tryMutation("runs.reconcileOrphanedRuns", async () => {
196
+ const result = await pool.query({
197
+ text: `UPDATE cortex_runs r
198
+ SET status = 'failed',
199
+ error = 'The workflow of the run never started.',
200
+ completed_at = $1
201
+ WHERE r.status = 'running'
202
+ AND r.started_at < $2
203
+ AND NOT EXISTS (SELECT 1 FROM dbos.workflow_status ws WHERE ws.workflow_uuid = r.run_id)
204
+ RETURNING r.run_id`,
205
+ values: [new Date(now).toISOString(), before],
206
+ });
207
+ return result.rows.map((row) => row.run_id);
208
+ });
209
+ }
174
210
  export function updateRunStatus(pool, runId, status, error) {
175
211
  const now = new Date().toISOString();
176
- const isTerminal = status === "completed" || status === "partial" || status === "failed" || status === "canceled";
212
+ const isTerminal = status === "completed" || status === "partial" || status === "failed" || status === "canceled" || status === "suspended_insufficient_funds";
177
213
  return tryMutation("runs.updateRunStatus", async () => {
178
214
  await pool.query({
179
215
  text: `UPDATE cortex_runs
@@ -198,7 +234,7 @@ export function markRunCanceledIfActive(pool, runId, reason) {
198
234
  const result = await pool.query({
199
235
  text: `UPDATE cortex_runs
200
236
  SET status = 'canceled', completed_at = $2, error = $3
201
- WHERE run_id = $1 AND status IN ('running','suspended_insufficient_funds')`,
237
+ WHERE run_id = $1 AND status = 'running'`,
202
238
  values: [runId, now, reason],
203
239
  });
204
240
  return (result.rowCount ?? 0) > 0;
@@ -242,6 +278,23 @@ export function queryRunsByAnalysis(pool, analysisId, { limit = 50, offset = 0 }
242
278
  return result.rows.map(mapRunRow);
243
279
  });
244
280
  }
281
+ /**
282
+ * The run ids of one plan of one analysis, newest start first, at most `limit`.
283
+ * The run id breaks a tie of start times, highest first. A plan with no run gives
284
+ * an empty list.
285
+ */
286
+ export function queryRunIdsByPlan(pool, opts) {
287
+ return tryQuery("runs.queryRunIdsByPlan", async () => {
288
+ const result = await pool.query({
289
+ text: `SELECT run_id FROM cortex_runs
290
+ WHERE analysis_id = $1 AND plan_id = $2
291
+ ORDER BY started_at DESC, run_id DESC
292
+ LIMIT $3`,
293
+ values: [opts.analysisId, opts.planId, opts.limit],
294
+ });
295
+ return result.rows.map((row) => row.run_id);
296
+ });
297
+ }
245
298
  /**
246
299
  * Read the analysis's non-terminal run activity without coupling it to a
247
300
  * conversation thread. The total is counted before the detail limit so a
@@ -253,7 +306,7 @@ export function queryNonTerminalRunsByAnalysis(pool, analysisId, limit = 20) {
253
306
  text: `SELECT COUNT(*)::int AS total
254
307
  FROM cortex_runs
255
308
  WHERE analysis_id = $1
256
- AND status IN ('running','suspended_insufficient_funds')`,
309
+ AND status = 'running'`,
257
310
  values: [analysisId],
258
311
  });
259
312
  const rowsResult = await pool.query({
@@ -262,13 +315,8 @@ export function queryNonTerminalRunsByAnalysis(pool, analysisId, limit = 20) {
262
315
  mandate_jti, mandate_expires_at, plan_id
263
316
  FROM cortex_runs
264
317
  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,
318
+ AND status = 'running'
319
+ ORDER BY started_at DESC,
272
320
  run_id DESC
273
321
  LIMIT $2`,
274
322
  values: [analysisId, limit],
@@ -280,8 +328,7 @@ export function queryNonTerminalRunsByAnalysis(pool, analysisId, limit = 20) {
280
328
  });
281
329
  }
282
330
  /**
283
- * Read one stable inspection page: running first, then suspended, then
284
- * terminal history. This is deliberately separate from
331
+ * Read one stable inspection page: running first, then each other run. This is deliberately separate from
285
332
  * {@link queryRunsByAnalysis}, whose chronological order is an embedder/UI
286
333
  * contract.
287
334
  */
@@ -299,8 +346,7 @@ export function queryRunsForInspection(pool, analysisId, { limit = 50, offset =
299
346
  WHERE analysis_id = $1
300
347
  ORDER BY CASE status
301
348
  WHEN 'running' THEN 0
302
- WHEN 'suspended_insufficient_funds' THEN 1
303
- ELSE 2
349
+ ELSE 1
304
350
  END,
305
351
  started_at DESC,
306
352
  run_id DESC
@@ -54,6 +54,8 @@ export declare const RunStatus: z.ZodEnum<{
54
54
  suspended_insufficient_funds: "suspended_insufficient_funds";
55
55
  }>;
56
56
  export type RunStatus = z.infer<typeof RunStatus>;
57
+ /** A status that a run keeps once it ended: every status but `running`. */
58
+ export type TerminalRunStatus = Exclude<RunStatus, "running">;
57
59
  export declare const StepExecutionStatus: z.ZodEnum<{
58
60
  failed: "failed";
59
61
  pending: "pending";
@@ -103,5 +103,11 @@ export interface ProfileActivityFrame {
103
103
  * A failing write is swallowed and logged, never propagated: observation is a
104
104
  * diagnostic channel, and a dropped frame must not fail a profile that is otherwise
105
105
  * succeeding. This mirrors the sandbox-step producer's stance on the same write.
106
+ *
107
+ * Each emission writes the reconciling activity part, so a late subscriber folds to the
108
+ * current activity, and then one `data-activity-entry`, so it also gets each earlier line.
109
+ * The two parts carry one time from `now`, which the body binds to the checkpointed clock of
110
+ * its workflow. The two writes fail alone. The sequence number of the entry advances before
111
+ * the clock read and the writes, thus a failure cannot shift a later id.
106
112
  */
107
- export declare function createProfileActivityEmitter(write: ActivityWrite, frame: ProfileActivityFrame, logger: Logger): ProfileActivityEmitter;
113
+ export declare function createProfileActivityEmitter(write: ActivityWrite, frame: ProfileActivityFrame, logger: Logger, now: () => Promise<number>): ProfileActivityEmitter;
@@ -19,7 +19,7 @@
19
19
  * anything that throws on the way there reaches the failure path having emitted
20
20
  * no terminal at all.
21
21
  */
22
- import { activityForTool, stepPartId } from "../sandbox/sandbox-step-translate.js";
22
+ import { activityEmission, activityForTool } from "../sandbox/sandbox-step-translate.js";
23
23
  /**
24
24
  * Build the profile's activity channel over `write`.
25
25
  *
@@ -30,21 +30,30 @@ import { activityForTool, stepPartId } from "../sandbox/sandbox-step-translate.j
30
30
  * A failing write is swallowed and logged, never propagated: observation is a
31
31
  * diagnostic channel, and a dropped frame must not fail a profile that is otherwise
32
32
  * succeeding. This mirrors the sandbox-step producer's stance on the same write.
33
+ *
34
+ * Each emission writes the reconciling activity part, so a late subscriber folds to the
35
+ * current activity, and then one `data-activity-entry`, so it also gets each earlier line.
36
+ * The two parts carry one time from `now`, which the body binds to the checkpointed clock of
37
+ * its workflow. The two writes fail alone. The sequence number of the entry advances before
38
+ * the clock read and the writes, thus a failure cannot shift a later id.
33
39
  */
34
- export function createProfileActivityEmitter(write, frame, logger) {
35
- // One reconciling id for the profile's single step, so the run-stream fold
36
- // collapses every phase transition latest-wins onto one frame rather than
37
- // accumulating them. Minted through the shared construction because that
38
- // sameness across producers is what makes the fold correct.
39
- const id = stepPartId("step-activity", frame.runId, frame.stepId);
40
- const emit = async (phase, activity) => {
40
+ export function createProfileActivityEmitter(write, frame, logger, now) {
41
+ let seq = 0;
42
+ const writeAlone = async (part, phase) => {
41
43
  try {
42
- await write({ type: "data-step-activity", id, runId: frame.runId, stepId: frame.stepId, phase, activity });
44
+ await write(part);
43
45
  }
44
46
  catch (err) {
45
47
  logger.warn("run-event write failed (non-fatal)", { phase, ...logger.errorFields(err) });
46
48
  }
47
49
  };
50
+ const emit = async (phase, activity) => {
51
+ const position = seq++;
52
+ const at = new Date(await now()).toISOString();
53
+ const parts = activityEmission(frame, position, phase, activity, at);
54
+ await writeAlone(parts.activity, phase);
55
+ await writeAlone(parts.entry, phase);
56
+ };
48
57
  return {
49
58
  sandboxInit: () => emit("sandbox-init", "Starting sandbox"),
50
59
  scanning: () => emit("executing", "Scanning input files"),
@@ -28,7 +28,7 @@ import type { SandboxAgentDeps } from "../agents/sandbox/shared.js";
28
28
  import type { BioToolKeys } from "../tools/bio/keys.js";
29
29
  import type { ToolOutputStore } from "../loop/tool-output.js";
30
30
  import type { DbError } from "../lib/db-result.js";
31
- import { type GateRefusal } from "../lib/hooks.js";
31
+ import { type GateRefusal, type NoticeFailure } from "../lib/hooks.js";
32
32
  import type { Logger } from "../lib/logger.js";
33
33
  import type { UsageRecorder } from "../billing/usage-recorder.js";
34
34
  import type { ChatProvider, EmbeddingProvider } from "../providers/types.js";
@@ -38,6 +38,15 @@ import { type ResolveWorkspaceRoot } from "../workspace/paths.js";
38
38
  import { type ProfileSubmission } from "../schemas/data-profile-schemas.js";
39
39
  import { type ProfileResolution } from "./data-profile-resolve.js";
40
40
  import { type DataProfileResult } from "../state/index.js";
41
+ /** The settle of one profile claim: the ledger row left `running`, and the terminal write stamped it. */
42
+ export interface DataProfileSettlement {
43
+ readonly analysisId: string;
44
+ readonly status: "completed" | "failed";
45
+ /** The `data_profile_completed_at` that the terminal write stamped, as an ISO 8601 string. */
46
+ readonly completedAt: string;
47
+ }
48
+ /** The notice of a profile settle. The workflow body calls it one time for each settled claim. */
49
+ export type NotifyDataProfileSettled = (settlement: DataProfileSettlement) => ResultAsync<void, NoticeFailure>;
41
50
  /** The body's construction-time deps — closed over at registration. */
42
51
  export interface DataProfileDeps extends EnvironmentStorePaths {
43
52
  /** Operational logging seam; omitted falls back to no-op. */
@@ -73,6 +82,11 @@ export interface DataProfileDeps extends EnvironmentStorePaths {
73
82
  * Unbound keeps the current shape: no link tool, and no error.
74
83
  */
75
84
  readonly extendAnalysisFarm?: ExtendAnalysisFarm;
85
+ /**
86
+ * Notice of each profile settle, for the records of the embedder. Unbound, the
87
+ * body calls nothing. It is not a provenance hook.
88
+ */
89
+ readonly notifyDataProfileSettled?: NotifyDataProfileSettled;
76
90
  }
77
91
  /**
78
92
  * Workflow input. JSON-serialisable — DBOS persists it as the workflow's
@@ -296,7 +296,7 @@ export async function runDataProfileBody(input, deps) {
296
296
  // ledger has no suspended state and the retry path takes a `failed` row again.
297
297
  const suspendProfile = async (suspension) => {
298
298
  logger.warn("profile suspended", { reason: suspension.reason });
299
- settleTerminalWrite(logger, analysisId, unwrapOrThrow(await failDataProfile(deps.pool, analysisId, suspension.reason)), "failed");
299
+ await settleTerminalWrite(logger, analysisId, unwrapOrThrow(await failDataProfile(deps.pool, analysisId, suspension.reason)), "failed", deps.notifyDataProfileSettled);
300
300
  await revoke("data-profile-suspended");
301
301
  unwrapOrThrow(await suspendAnalysis(deps.pool, analysisId));
302
302
  await activity.failed(suspension.reason);
@@ -310,7 +310,7 @@ export async function runDataProfileBody(input, deps) {
310
310
  // this capability, and a body that composed its own strings is a body they can drift from.
311
311
  // The frame is the workflow's synthetic one: both values are constants shared by every analysis,
312
312
  // so they identify nothing and consumers are required not to key on them.
313
- const activity = createProfileActivityEmitter((part) => DBOS.writeStream("events", part), { runId: DATA_PROFILE_RUN_LITERAL, stepId: DATA_PROFILE_STEP_LITERAL }, logger);
313
+ const activity = createProfileActivityEmitter((part) => DBOS.writeStream("events", part), { runId: DATA_PROFILE_RUN_LITERAL, stepId: DATA_PROFILE_STEP_LITERAL }, logger, () => DBOS.now());
314
314
  try {
315
315
  // Record which workflow owns this profile before anything else, so a consumer that resolves
316
316
  // the id from the ledger can subscribe from the earliest possible moment. Guarded on a real
@@ -337,7 +337,7 @@ export async function runDataProfileBody(input, deps) {
337
337
  // catalogue's fit is measured, and a completion that emitted none would make the
338
338
  // empty-manifest path invisible to the same monitoring that watches every other.
339
339
  logProfileMonitoring(logger, { dimensions: [], probes: [], repairRounds: 0, absorb: "none" });
340
- settleTerminalWrite(logger, analysisId, unwrapOrThrow(await completeDataProfile(deps.pool, analysisId)), "completed");
340
+ await settleTerminalWrite(logger, analysisId, unwrapOrThrow(await completeDataProfile(deps.pool, analysisId)), "completed", deps.notifyDataProfileSettled);
341
341
  await revoke("data-profile-completed");
342
342
  // This is a terminal completion like any other, so it reports one — a consumer watching
343
343
  // an empty-manifest profile sees it settle rather than seeing the stream simply stop.
@@ -356,10 +356,10 @@ export async function runDataProfileBody(input, deps) {
356
356
  const executionId = generateExecutionId(DATA_PROFILE_AGENT_ID);
357
357
  const workflowId = DBOS.workflowID ?? `${DATA_PROFILE_RUN_LITERAL}:${executionId}`;
358
358
  const childSession = forSubAgent(runSession, DATA_PROFILE_AGENT_ID);
359
- // A resolver throw fails the workflow loudly (workspace-root-resolution
359
+ // A resolver rejection fails the workflow loudly (workspace-root-resolution
360
360
  // contract) — profiling an analysis whose root cannot be resolved is
361
361
  // meaningless, so no softer handling is warranted.
362
- const workspaceRoot = deps.resolveWorkspaceRoot(analysisId);
362
+ const workspaceRoot = await deps.resolveWorkspaceRoot(analysisId);
363
363
  const profileWritePrefix = `${workspaceRoot}/runs/${DATA_PROFILE_RUN_LITERAL}/${DATA_PROFILE_STEP_LITERAL}`;
364
364
  // 3. The deterministic scan, before any container and before the agent's first turn.
365
365
  // It is always needed and its result does not depend on agent judgement, so spending
@@ -390,7 +390,7 @@ export async function runDataProfileBody(input, deps) {
390
390
  await activity.indexing();
391
391
  await indexProfile({ deps, analysisId, session: runSession, record, scan, logger });
392
392
  logProfileMonitoring(logger, { resolution, dimensions: [], probes: [], repairRounds: 0, absorb: "none" });
393
- settleTerminalWrite(logger, analysisId, unwrapOrThrow(await completeDataProfile(deps.pool, analysisId, record)), "completed");
393
+ await settleTerminalWrite(logger, analysisId, unwrapOrThrow(await completeDataProfile(deps.pool, analysisId, record)), "completed", deps.notifyDataProfileSettled);
394
394
  await revoke("data-profile-completed");
395
395
  await activity.complete();
396
396
  return;
@@ -416,7 +416,7 @@ export async function runDataProfileBody(input, deps) {
416
416
  repairRounds: 0,
417
417
  absorb: absorb.kind,
418
418
  });
419
- settleTerminalWrite(logger, analysisId, unwrapOrThrow(await completeDataProfile(deps.pool, analysisId, absorbed)), "completed");
419
+ await settleTerminalWrite(logger, analysisId, unwrapOrThrow(await completeDataProfile(deps.pool, analysisId, absorbed)), "completed", deps.notifyDataProfileSettled);
420
420
  await revoke("data-profile-completed");
421
421
  await activity.complete();
422
422
  return;
@@ -571,7 +571,7 @@ export async function runDataProfileBody(input, deps) {
571
571
  });
572
572
  // 6. Complete — store the FULL profiler finding plus the input signature for
573
573
  // staleness detection. The scratch tree is gone; this row is all that survives.
574
- settleTerminalWrite(logger, analysisId, unwrapOrThrow(await completeDataProfile(deps.pool, analysisId, profileRecord)), "completed");
574
+ await settleTerminalWrite(logger, analysisId, unwrapOrThrow(await completeDataProfile(deps.pool, analysisId, profileRecord)), "completed", deps.notifyDataProfileSettled);
575
575
  await revoke("data-profile-completed");
576
576
  // LAST statement of the success path, and that placement is the guarantee that exactly
577
577
  // one terminal activity is ever emitted. Everything that could still throw — the ledger
@@ -598,7 +598,7 @@ export async function runDataProfileBody(input, deps) {
598
598
  return await suspendProfile(suspension);
599
599
  logger.error("profile failed", logger.errorFields(err));
600
600
  const reason = profileFailureReason(err);
601
- settleTerminalWrite(logger, analysisId, unwrapOrThrow(await failDataProfile(deps.pool, analysisId, reason)), "failed");
601
+ await settleTerminalWrite(logger, analysisId, unwrapOrThrow(await failDataProfile(deps.pool, analysisId, reason)), "failed", deps.notifyDataProfileSettled);
602
602
  await revoke("data-profile-failed");
603
603
  // The same bounded, user-safe line the ledger receives — never the raw error, whose paths and
604
604
  // stack frames stay in the log record above. This is the only other terminal emission, so
@@ -617,18 +617,23 @@ function logTerminalNoop(logger, analysisId, write) {
617
617
  }
618
618
  /**
619
619
  * Settle one terminal write of the profile ledger. A refused stamp is logged
620
- * (see `logTerminalNoop`). An accepted stamp records the profile's run outcome:
621
- * the CAS admits one write per `running` claim, so this is the exactly-once
620
+ * (see `logTerminalNoop`) and sends no notice. An accepted stamp records the
621
+ * profile's run outcome and then delivers the settle notice when the deps bind
622
+ * one: the CAS admits one write per `running` claim, so this is the exactly-once
622
623
  * point of the transition — a recovery replay of the body finds the row no
623
- * longer `running`, and records nothing again. The duration runs from the
624
+ * longer `running`, and does neither again. The duration runs from the
624
625
  * `data_profile_started_at` the claim stamped, never from a body-local clock.
626
+ * A crash between the stamp and the notice loses that one notice.
625
627
  */
626
- function settleTerminalWrite(logger, analysisId, write, status) {
628
+ async function settleTerminalWrite(logger, analysisId, write, status, notify) {
627
629
  if (!write.stamped) {
628
630
  logTerminalNoop(logger, analysisId, status === "completed" ? "completion" : "failure");
629
631
  return;
630
632
  }
631
633
  recordRunCompleted({ workflow: "data_profile", status, durationMs: elapsedSinceIso(write.startedAt) });
634
+ if (notify === undefined)
635
+ return;
636
+ await deliverNotice(logger, "notifyDataProfileSettled", notify({ analysisId, status, completedAt: write.completedAt }));
632
637
  }
633
638
  /** A one-line, user-facing description of a `DataProfileStartError`. */
634
639
  export function describeDataProfileStartError(e) {
@@ -40,8 +40,11 @@ export function createAskGateway(deps) {
40
40
  // A standing grant this user holds for this ask's grant key — the command
41
41
  // unless the tool supplied a broader key — short-circuits the prompt: record
42
42
  // the invocation as `resolved` for the audit trail and return without pausing.
43
+ // The one part it emits is already settled, so no surface prompts, and the
44
+ // transcript still shows that a standing grant approved the call.
43
45
  if (unwrapOrThrow(await selectGrant(pool, ctx.analysisId, ctx.userId, request.grantKey ?? request.command))) {
44
46
  unwrapOrThrow(await insertGrantedAsk(pool, row, now));
47
+ await emitAskPart(ctx.emit, id, request, "resolved", "always");
45
48
  return { kind: "always" };
46
49
  }
47
50
  unwrapOrThrow(await insertPendingAsk(pool, row));
@@ -69,7 +72,7 @@ export function createAskGateway(deps) {
69
72
  throw new AskRejectedError("approval request is no longer available");
70
73
  }
71
74
  if (resolution.status !== "pending") {
72
- await emitAskPart(ctx.emit, id, request, resolution.status);
75
+ await emitAskPart(ctx.emit, id, request, resolution.status, replyKindOf(resolution.status, resolution.reply));
73
76
  return settle(resolution.status, resolution.reply, ctx.signal);
74
77
  }
75
78
  await sleepUntil(POLL_INTERVAL_MS, ctx.signal);
@@ -88,7 +91,7 @@ export function createAskGateway(deps) {
88
91
  return { ask, answer, pending, sweepExpired };
89
92
  }
90
93
  /** Emit (or reconcile, under the same id) the ask's `data-ask` chat part. */
91
- function emitAskPart(emit, id, request, status) {
94
+ function emitAskPart(emit, id, request, status, reply) {
92
95
  return emit({
93
96
  type: "data-ask",
94
97
  data: {
@@ -97,16 +100,36 @@ function emitAskPart(emit, id, request, status) {
97
100
  command: request.command,
98
101
  ...(request.detail !== undefined ? { detail: request.detail } : {}),
99
102
  status,
103
+ ...(reply !== undefined ? { reply } : {}),
100
104
  },
101
105
  });
102
106
  }
107
+ /**
108
+ * The kind of reply the terminal part shows: the kind that `settle` acts on for a `resolved` or `rejected` row,
109
+ * and none for any other status. It never reads the feedback of a rejection, which is for the model.
110
+ */
111
+ function replyKindOf(status, reply) {
112
+ switch (status) {
113
+ case "resolved":
114
+ return approvalOf(reply).kind;
115
+ case "rejected":
116
+ return "reject";
117
+ case "expired":
118
+ case "aborted":
119
+ return undefined;
120
+ }
121
+ }
122
+ /** The approval that a `resolved` row grants. */
123
+ function approvalOf(reply) {
124
+ // Only `once` / `always` reach `resolved`; guard the JSONB round-trip and
125
+ // default to the narrowest approval if a malformed reply ever slipped in.
126
+ return reply && (reply.kind === "once" || reply.kind === "always") ? reply : { kind: "once" };
127
+ }
103
128
  /** Resolve an approval or throw the terminal denial the ledger recorded. */
104
129
  function settle(status, reply, signal) {
105
130
  switch (status) {
106
131
  case "resolved":
107
- // Only `once` / `always` reach `resolved`; guard the JSONB round-trip and
108
- // default to the narrowest approval if a malformed reply ever slipped in.
109
- return reply && (reply.kind === "once" || reply.kind === "always") ? reply : { kind: "once" };
132
+ return approvalOf(reply);
110
133
  case "rejected":
111
134
  throw new AskRejectedError(reply && reply.kind === "reject" ? reply.feedback : undefined);
112
135
  case "expired":
@@ -26,7 +26,7 @@ type ShowUserOutput = {
26
26
  id: string;
27
27
  };
28
28
  export declare const showUserTool: import("../define-tool.js").Tool<{
29
- kind: "code" | "markdown" | "echart" | "svg" | "table" | "structure";
29
+ kind: "code" | "echart" | "markdown" | "svg" | "table" | "structure";
30
30
  title?: string | undefined;
31
31
  spec?: Record<string, unknown> | undefined;
32
32
  dataPath?: string | undefined;
@@ -2,7 +2,9 @@
2
2
  * Unified async edge for approved plans and explicitly requested one-step ad
3
3
  * hoc analyses. Both modes launch the ordinary executeAnalysis workflow.
4
4
  */
5
+ import { ResultAsync } from "neverthrow";
5
6
  import type { Pool } from "pg";
7
+ import type { AgentSession } from "../auth/types.js";
6
8
  import type { UsageRecorder } from "../billing/usage-recorder.js";
7
9
  import type { EnvironmentStorePaths } from "../config/environment-stores.js";
8
10
  import type { ResourcePolicy } from "../config/resource-limits.js";
@@ -54,7 +56,56 @@ export declare class PlanPackagesMissingError extends Error {
54
56
  readonly unavailable?: string | undefined;
55
57
  constructor(missing: string[], collisions: string[], unavailable?: string | undefined);
56
58
  }
59
+ /** The deps that start the run of a stored plan. */
60
+ export type LaunchPlanRunDeps = Pick<ExecuteAnalysisToolDeps, "pool" | "executeAnalysisWorkflow" | "runAuthorizer" | "runLauncher" | "resourcePolicy" | "extendAnalysisFarm" | "logger">;
61
+ /** The run of a stored plan. `started` is false when a run of the plan was already active. */
62
+ export type PlanLaunch = {
63
+ runId: string;
64
+ started: boolean;
65
+ };
66
+ /**
67
+ * Why a stored plan did not start a run. `store_failed.cause` and `launch_failed.cause` are the
68
+ * values as the failing call gave them, thus a caller that must throw them keeps their class.
69
+ */
70
+ export type LaunchPlanRunRefusal = {
71
+ type: "plan_not_found";
72
+ planId: string;
73
+ } | {
74
+ type: "plan_invalid";
75
+ planId: string;
76
+ malformed: boolean;
77
+ errors: string[];
78
+ } | {
79
+ type: "packages_missing";
80
+ missing: string[];
81
+ collisions: string[];
82
+ unavailable?: string;
83
+ } | {
84
+ type: "not_authorized";
85
+ reason: string;
86
+ } | {
87
+ type: "suspended";
88
+ reason: string;
89
+ } | {
90
+ type: "store_failed";
91
+ cause: unknown;
92
+ } | {
93
+ type: "launch_failed";
94
+ cause: unknown;
95
+ };
57
96
  export declare function buildAdHocPlan(request: string, route: AdHocRoute, createdAt?: string): AnalysisPlan;
97
+ /**
98
+ * Start the run of a stored plan. It reads the plan under the analysis of the session, validates it,
99
+ * links its packages, reserves a run, authorizes it, and starts the workflow. It gives the run id, or
100
+ * the refusal, and it emits no chat part. `started` is false when a run of the plan was already active.
101
+ *
102
+ * This is an API boundary: a host calls it outside a tool body, thus no outcome above rejects the
103
+ * promise.
104
+ */
105
+ export declare function launchPlanRun(deps: LaunchPlanRunDeps, input: {
106
+ session: AgentSession;
107
+ planId: string;
108
+ }): ResultAsync<PlanLaunch, LaunchPlanRunRefusal>;
58
109
  export declare function createExecuteAnalysisTool(deps: ExecuteAnalysisToolDeps): import("./define-tool.js").Tool<{
59
110
  mode: "plan" | "adhoc";
60
111
  planId?: string | undefined;