@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
@@ -3,7 +3,7 @@
3
3
  * hoc analyses. Both modes launch the ordinary executeAnalysis workflow.
4
4
  */
5
5
  import { randomUUID } from "node:crypto";
6
- import { err, ok } from "neverthrow";
6
+ import { err, ok, ResultAsync } from "neverthrow";
7
7
  import { z } from "zod";
8
8
  import { SANDBOX_AGENT_META } from "../agents/sandbox/index.js";
9
9
  import { DEFAULT_SANDBOX_MAX_STEPS } from "../config/resource-limits.js";
@@ -103,9 +103,9 @@ export class PlanPackagesMissingError extends Error {
103
103
  * is what the launch record must carry to tell a checked launch from an
104
104
  * unchecked one.
105
105
  */
106
- async function linkPlanPackages(extendAnalysisFarm, analysisId, plan) {
106
+ async function linkPlanPackages(extendAnalysisFarm, analysisId, planId, plan) {
107
107
  if (!extendAnalysisFarm)
108
- return 0;
108
+ return ok(0);
109
109
  // The union dedupes EQUAL queries only: two entries are one ask when their
110
110
  // spelling, their track, and their version are equal. A bare entry beside a
111
111
  // qualified entry of one spelling stays two asks, because the bare one
@@ -118,7 +118,12 @@ async function linkPlanPackages(extendAnalysisFarm, analysisId, plan) {
118
118
  if (parsed.isErr()) {
119
119
  // `validatePlan` parsed every entry of this plan already, thus a
120
120
  // refusal here means that the two readers disagree.
121
- throw new Error(`Step "${step.id}" carries the package entry "${entry}", which the plan validation should have refused`);
121
+ return err({
122
+ type: "plan_invalid",
123
+ planId,
124
+ malformed: false,
125
+ errors: [`Step "${step.id}" carries the package entry "${entry}", which the plan validation should have refused`],
126
+ });
122
127
  }
123
128
  const query = parsed.value;
124
129
  const key = formatQuery(query);
@@ -128,12 +133,12 @@ async function linkPlanPackages(extendAnalysisFarm, analysisId, plan) {
128
133
  }
129
134
  const queries = [...union.values()];
130
135
  if (queries.length === 0)
131
- return 0;
136
+ return ok(0);
132
137
  const linked = await callExtendAnalysisFarm(extendAnalysisFarm, analysisId, queries);
133
138
  if (linked.isErr()) {
134
139
  // A call that gave no answer is an `unavailable` answer, not a driver
135
140
  // error: the planner can act on "the store cannot answer".
136
- throw new PlanPackagesMissingError([], [], linked.error);
141
+ return err({ type: "packages_missing", missing: [], collisions: [], unavailable: linked.error });
137
142
  }
138
143
  const outcomes = linked.value;
139
144
  // An `unavailable` outcome preempts everything: the link pass could not
@@ -141,7 +146,7 @@ async function linkPlanPackages(extendAnalysisFarm, analysisId, plan) {
141
146
  // absence list would be a fabrication. One reason covers the batch.
142
147
  const unavailable = outcomes.find((o) => o.kind === "unavailable");
143
148
  if (unavailable !== undefined) {
144
- throw new PlanPackagesMissingError([], [], unavailable.reason);
149
+ return err({ type: "packages_missing", missing: [], collisions: [], unavailable: unavailable.reason });
145
150
  }
146
151
  // A seam-supplied detail classifies the miss in host terms (in flight,
147
152
  // failed with its reason, never requested), and it rides beside the name
@@ -165,13 +170,32 @@ async function linkPlanPackages(extendAnalysisFarm, analysisId, plan) {
165
170
  `; when the two directories are one spelling in two tracks, name the track in the plan, as ` +
166
171
  `${formatQuery({ spelling: o.spelling, track: "python" })} or ${formatQuery({ spelling: o.spelling, track: "r" })}`);
167
172
  if (missing.length > 0 || collisions.length > 0) {
168
- throw new PlanPackagesMissingError(missing, collisions);
173
+ return err({ type: "packages_missing", missing, collisions });
169
174
  }
170
- return queries.length;
175
+ return ok(queries.length);
171
176
  }
172
177
  function planSummary(plan) {
173
178
  return plan.title?.trim() || plan.analytical_narrative.trim().slice(0, 280);
174
179
  }
180
+ /**
181
+ * The resources of each step. `validatePlan` refuses a step without them, thus
182
+ * a missing entry here means that the two readers disagree.
183
+ */
184
+ function stepResources(planId, plan) {
185
+ const byStepId = {};
186
+ for (const step of plan.steps) {
187
+ if (!step.resources) {
188
+ return err({
189
+ type: "plan_invalid",
190
+ planId,
191
+ malformed: false,
192
+ errors: [`Step "${step.id}" has no resources — validation should have rejected it`],
193
+ });
194
+ }
195
+ byStepId[step.id] = step.resources;
196
+ }
197
+ return ok(byStepId);
198
+ }
175
199
  function workflowInput(planId, plan, args) {
176
200
  return {
177
201
  analysisId: args.analysisId,
@@ -181,11 +205,7 @@ function workflowInput(planId, plan, args) {
181
205
  steps: plan.steps.map((step) => ({ id: step.id, depends_on: step.depends_on ?? [] })),
182
206
  planStepById: Object.fromEntries(plan.steps.map((step) => [step.id, step])),
183
207
  agentByStepId: Object.fromEntries(plan.steps.map((step) => [step.id, step.agent ?? "unknown"])),
184
- resourcesByStepId: Object.fromEntries(plan.steps.map((step) => {
185
- if (!step.resources)
186
- throw new Error(`Step "${step.id}" has no resources — validation should have rejected it`);
187
- return [step.id, step.resources];
188
- })),
208
+ resourcesByStepId: args.resourcesByStepId,
189
209
  timeoutByStepId: Object.fromEntries(plan.steps.filter((step) => step.timeout !== undefined).map((step) => [step.id, step.timeout])),
190
210
  budget: args.budget,
191
211
  runSession: args.runSession,
@@ -193,14 +213,21 @@ function workflowInput(planId, plan, args) {
193
213
  synthesisEnabled: args.synthesisEnabled,
194
214
  };
195
215
  }
196
- function validateStoredPlan(raw, planId) {
216
+ function parseStoredPlan(raw, planId) {
197
217
  const parsed = AnalysisPlanSchema.safeParse(raw);
198
218
  if (!parsed.success)
199
- throw new PlanMalformedError(planId);
219
+ return err({ type: "plan_invalid", planId, malformed: true, errors: [] });
200
220
  const result = validatePlan(parsed.data);
201
221
  if (!result.valid)
202
- throw new PlanValidationError(planId, result.errors);
203
- return parsed.data;
222
+ return err({ type: "plan_invalid", planId, malformed: false, errors: result.errors });
223
+ return ok(parsed.data);
224
+ }
225
+ /** The error that a tool body throws for a stored plan that the parse refused. */
226
+ function validateStoredPlan(raw, planId) {
227
+ const parsed = parseStoredPlan(raw, planId);
228
+ if (parsed.isErr())
229
+ return throwLaunchRefusal(parsed.error);
230
+ return parsed.value;
204
231
  }
205
232
  function adHocTitle(request) {
206
233
  const compact = request.trim().replace(/\s+/g, " ");
@@ -320,8 +347,181 @@ async function persistedAdHocPlan(deps, args) {
320
347
  throw new Error(`Internal ad hoc plan ${args.planId} could not be reloaded`);
321
348
  return validateStoredPlan(stored, args.planId);
322
349
  }
323
- export function createExecuteAnalysisTool(deps) {
350
+ /**
351
+ * The half of a launch that the plan mode and the ad hoc mode share: the link pass, the reservation,
352
+ * the authorization, and the workflow start. It gives each outcome as a `Result`.
353
+ */
354
+ async function launchReservedRun(deps, args) {
324
355
  const logger = (deps.logger ?? createNoopLogger()).named("execute-analysis-tool");
356
+ const { session, planId, plan } = args;
357
+ const analysisId = session.scope.analysisId;
358
+ const threadId = session.scope.threadId ?? null;
359
+ const resources = stepResources(planId, plan);
360
+ if (resources.isErr())
361
+ return err(resources.error);
362
+ // The link pass comes before the run reserves anything, thus a pool
363
+ // miss leaves no run row and no revoked authorization behind.
364
+ const linked = await linkPlanPackages(deps.extendAnalysisFarm, analysisId, planId, plan);
365
+ if (linked.isErr())
366
+ return err(linked.error);
367
+ const packageQueries = linked.value;
368
+ const reservation = await args.reserve({ analysisId, threadId });
369
+ if (reservation.isErr())
370
+ return err(reservation.error);
371
+ if (!reservation.value.started)
372
+ return reservation;
373
+ const runId = reservation.value.runId;
374
+ const authorized = await passGate("RunAuthorizer.authorize", deps.runAuthorizer.authorize({
375
+ auth: session.auth,
376
+ scope: session.scope,
377
+ provenance: session.provenance,
378
+ frame: { runId },
379
+ }));
380
+ if (authorized.isErr()) {
381
+ // The refusal ends the run before a workflow exists, thus the
382
+ // reserved row must not stay `running`.
383
+ const { kind, reason } = authorized.error;
384
+ if (kind === "suspended") {
385
+ await updateRunStatus(deps.pool, runId, "suspended_insufficient_funds", reason).match(() => { }, () => { });
386
+ const suspension = await suspendAnalysis(deps.pool, analysisId);
387
+ if (suspension.isErr())
388
+ return err({ type: "store_failed", cause: suspension.error });
389
+ return err({ type: "suspended", reason });
390
+ }
391
+ await updateRunStatus(deps.pool, runId, "failed", reason).match(() => { }, () => { });
392
+ return err({ type: "not_authorized", reason });
393
+ }
394
+ const authorization = authorized.value;
395
+ const wfInput = workflowInput(planId, plan, {
396
+ analysisId,
397
+ threadId,
398
+ runSession: authorization.runSession,
399
+ ownsMandate: authorization.ownsMandate,
400
+ budget: deps.resourcePolicy?.budget,
401
+ synthesisEnabled: args.mode === "plan",
402
+ resourcesByStepId: resources.value,
403
+ });
404
+ try {
405
+ await deps.runLauncher.launch(deps.executeAnalysisWorkflow, { workflowId: runId }, wfInput);
406
+ }
407
+ catch (cause) {
408
+ await deliverNotice(logger, "RunAuthorizer.revoke", deps.runAuthorizer.revoke(authorization, "workflow-start-failed"));
409
+ // A failed write leaves the row `running` with no workflow. The boot repair (`reconcileOrphanedRuns`) fails it.
410
+ await updateRunStatus(deps.pool, runId, "failed", "workflow start failed").match(() => { }, (error) => logger.warn("the failed status of a run whose start failed was not written", { runId, ...logger.errorFields(error) }));
411
+ return err({ type: "launch_failed", cause });
412
+ }
413
+ logger.info("analysis launched", { analysisId, runId, planId, mode: args.mode, packageQueries });
414
+ return ok({ runId, started: true });
415
+ }
416
+ /**
417
+ * Reserve a fresh run for a plan. A concurrent launch of the same plan holds the active-plan slot,
418
+ * and its run is the answer.
419
+ */
420
+ async function reservePlanRun(deps, args) {
421
+ const runId = randomUUID();
422
+ try {
423
+ const inserted = await insertRun(deps.pool, {
424
+ runId,
425
+ analysisId: args.analysisId,
426
+ threadId: args.threadId,
427
+ workflowName: "executeAnalysis",
428
+ planId: args.planId,
429
+ });
430
+ if (inserted.isErr())
431
+ return err({ type: "store_failed", cause: inserted.error });
432
+ return ok({ runId, started: true });
433
+ }
434
+ catch (cause) {
435
+ // `insertRun` rejects its promise on a collision of the active-plan index, and on nothing else.
436
+ if (!isDedupCollision(cause))
437
+ return err({ type: "store_failed", cause });
438
+ const active = await queryActiveRun(deps.pool, args.analysisId, args.planId);
439
+ if (active.isErr())
440
+ return err({ type: "store_failed", cause: active.error });
441
+ // The slot was free again when the read ran, so no run holds it and the collision stays the cause.
442
+ if (!active.value)
443
+ return err({ type: "store_failed", cause });
444
+ return ok({ runId: active.value.runId, started: false });
445
+ }
446
+ }
447
+ /**
448
+ * Reserve the run of an ad hoc call under its deterministic id, and treat a reservation that exists
449
+ * as a redelivery of the same call.
450
+ */
451
+ async function reserveAdHocRun(deps, args) {
452
+ try {
453
+ const reservation = await reserveRunById(deps.pool, {
454
+ runId: args.runId,
455
+ analysisId: args.analysisId,
456
+ threadId: args.threadId,
457
+ workflowName: "executeAnalysis",
458
+ planId: args.planId,
459
+ });
460
+ if (reservation.isErr())
461
+ return err({ type: "store_failed", cause: reservation.error });
462
+ return ok({ runId: reservation.value.row.runId, started: reservation.value.inserted });
463
+ }
464
+ catch (cause) {
465
+ // `reserveRunById` rejects its promise when the run id belongs to another analysis.
466
+ return err({ type: "store_failed", cause });
467
+ }
468
+ }
469
+ async function launchStoredPlan(deps, input) {
470
+ const { session, planId } = input;
471
+ const analysisId = session.scope.analysisId;
472
+ const raw = await loadPlan(deps.pool, planId, { analysisId });
473
+ if (raw.isErr())
474
+ return err({ type: "store_failed", cause: raw.error });
475
+ if (!raw.value)
476
+ return err({ type: "plan_not_found", planId });
477
+ const plan = parseStoredPlan(raw.value, planId);
478
+ if (plan.isErr())
479
+ return err(plan.error);
480
+ const active = await queryActiveRun(deps.pool, analysisId, planId);
481
+ if (active.isErr())
482
+ return err({ type: "store_failed", cause: active.error });
483
+ if (active.value)
484
+ return ok({ runId: active.value.runId, started: false });
485
+ return launchReservedRun(deps, {
486
+ session,
487
+ planId,
488
+ plan: plan.value,
489
+ mode: "plan",
490
+ reserve: (frame) => reservePlanRun(deps, { ...frame, planId }),
491
+ });
492
+ }
493
+ /**
494
+ * Start the run of a stored plan. It reads the plan under the analysis of the session, validates it,
495
+ * links its packages, reserves a run, authorizes it, and starts the workflow. It gives the run id, or
496
+ * the refusal, and it emits no chat part. `started` is false when a run of the plan was already active.
497
+ *
498
+ * This is an API boundary: a host calls it outside a tool body, thus no outcome above rejects the
499
+ * promise.
500
+ */
501
+ export function launchPlanRun(deps, input) {
502
+ return new ResultAsync(launchStoredPlan(deps, input));
503
+ }
504
+ /** Throw the error that the tool body threw before the launch gave a `Result`. */
505
+ function throwLaunchRefusal(refusal) {
506
+ switch (refusal.type) {
507
+ case "plan_not_found":
508
+ throw new PlanNotFoundError(refusal.planId);
509
+ case "plan_invalid":
510
+ throw refusal.malformed ? new PlanMalformedError(refusal.planId) : new PlanValidationError(refusal.planId, refusal.errors);
511
+ case "packages_missing":
512
+ throw new PlanPackagesMissingError(refusal.missing, refusal.collisions, refusal.unavailable);
513
+ case "store_failed":
514
+ return unwrapOrThrow(err(refusal.cause));
515
+ case "launch_failed":
516
+ // The cause is the value that the launcher threw, and the tool rethrows it unchanged.
517
+ throw refusal.cause;
518
+ default: {
519
+ const unreachable = refusal;
520
+ return unreachable;
521
+ }
522
+ }
523
+ }
524
+ export function createExecuteAnalysisTool(deps) {
325
525
  return defineTool({
326
526
  id: "execute_analysis",
327
527
  description: "Launch analysis computation asynchronously. Use mode=plan with the planId only after the user approved that stored plan. " +
@@ -340,104 +540,36 @@ export function createExecuteAnalysisTool(deps) {
340
540
  if (!ctx.session.auth)
341
541
  throw new Error("execute_analysis: session is missing its auth capability");
342
542
  const analysisId = ctx.session.scope.analysisId;
343
- const threadId = ctx.session.scope.threadId ?? null;
344
543
  const planId = input.mode === "plan" ? input.planId : adHocPlanId(analysisId, ctx.invocationId);
345
544
  const emitRunCard = async (runId) => {
346
545
  const card = await buildRunCardData(deps.pool, { planId, analysisId, runId }).unwrapOr(null);
347
546
  if (card)
348
547
  await ctx.emit({ type: "data-run-card", source: ctx.session.provenance, data: card });
349
548
  };
350
- let plan;
351
- if (input.mode === "plan") {
352
- const raw = unwrapOrThrow(await loadPlan(deps.pool, planId, { analysisId }));
353
- if (!raw)
354
- throw new PlanNotFoundError(planId);
355
- plan = validateStoredPlan(raw, planId);
356
- const active = unwrapOrThrow(await queryActiveRun(deps.pool, analysisId, planId));
357
- if (active) {
358
- await emitRunCard(active.runId);
359
- return ok({ runId: active.runId, status: "in_progress" });
360
- }
361
- }
362
- else {
363
- plan = await persistedAdHocPlan(deps, { analysisId, request: input.request, ctx, planId });
364
- }
365
- // The link pass comes before the run reserves anything, thus a pool
366
- // miss leaves no run row and no revoked authorization behind.
367
- const packageQueries = await linkPlanPackages(deps.extendAnalysisFarm, analysisId, plan);
368
- const runId = input.mode === "plan" ? randomUUID() : adHocRunId(analysisId, ctx.invocationId);
369
- if (input.mode === "plan") {
370
- try {
371
- unwrapOrThrow(await insertRun(deps.pool, {
372
- runId,
373
- analysisId,
374
- threadId,
375
- workflowName: "executeAnalysis",
376
- planId,
377
- }));
378
- }
379
- catch (error) {
380
- if (isDedupCollision(error)) {
381
- const active = unwrapOrThrow(await queryActiveRun(deps.pool, analysisId, planId));
382
- if (active) {
383
- await emitRunCard(active.runId);
384
- return ok({ runId: active.runId, status: "in_progress" });
385
- }
386
- }
387
- throw error;
388
- }
389
- }
390
- else {
391
- const reservation = unwrapOrThrow(await reserveRunById(deps.pool, {
392
- runId,
393
- analysisId,
394
- threadId,
395
- workflowName: "executeAnalysis",
549
+ const launch = async () => {
550
+ if (input.mode === "plan")
551
+ return launchPlanRun(deps, { session: ctx.session, planId });
552
+ const plan = await persistedAdHocPlan(deps, { analysisId, request: input.request, ctx, planId });
553
+ const runId = adHocRunId(analysisId, ctx.invocationId);
554
+ return launchReservedRun(deps, {
555
+ session: ctx.session,
396
556
  planId,
397
- }));
398
- if (!reservation.inserted) {
399
- await emitRunCard(reservation.row.runId);
400
- return ok({ runId: reservation.row.runId, status: "in_progress" });
401
- }
402
- }
403
- const authorized = await passGate("RunAuthorizer.authorize", deps.runAuthorizer.authorize({
404
- auth: ctx.session.auth,
405
- scope: ctx.session.scope,
406
- provenance: ctx.session.provenance,
407
- frame: { runId },
408
- }));
409
- if (authorized.isErr()) {
410
- // The refusal ends the run before a workflow exists, thus the
411
- // reserved row must not stay `running`.
412
- const { kind, reason } = authorized.error;
413
- if (kind === "suspended") {
414
- await updateRunStatus(deps.pool, runId, "canceled", reason).match(() => { }, () => { });
415
- unwrapOrThrow(await suspendAnalysis(deps.pool, analysisId));
416
- return err({ error: `The analysis is suspended: ${reason}`, retryable: false });
417
- }
418
- await updateRunStatus(deps.pool, runId, "failed", reason).match(() => { }, () => { });
419
- return err({ error: `The run was not authorized: ${reason}`, retryable: false });
420
- }
421
- const authorization = authorized.value;
422
- const wfInput = workflowInput(planId, plan, {
423
- analysisId,
424
- threadId,
425
- runSession: authorization.runSession,
426
- ownsMandate: authorization.ownsMandate,
427
- budget: deps.resourcePolicy?.budget,
428
- synthesisEnabled: input.mode === "plan",
429
- });
430
- try {
431
- await deps.runLauncher.launch(deps.executeAnalysisWorkflow, { workflowId: runId }, wfInput);
432
- }
433
- catch (error) {
434
- await deliverNotice(logger, "RunAuthorizer.revoke", deps.runAuthorizer.revoke(authorization, "workflow-start-failed"));
435
- await updateRunStatus(deps.pool, runId, "failed", "workflow start failed").match(() => { }, () => { });
436
- throw error;
557
+ plan,
558
+ mode: "adhoc",
559
+ reserve: (frame) => reserveAdHocRun(deps, { ...frame, planId, runId }),
560
+ });
561
+ };
562
+ const launched = await launch();
563
+ if (launched.isErr()) {
564
+ const refusal = launched.error;
565
+ if (refusal.type === "suspended")
566
+ return err({ error: `The analysis is suspended: ${refusal.reason}`, retryable: false });
567
+ if (refusal.type === "not_authorized")
568
+ return err({ error: `The run was not authorized: ${refusal.reason}`, retryable: false });
569
+ return throwLaunchRefusal(refusal);
437
570
  }
438
- logger.info("analysis launched", { analysisId, runId, planId, mode: input.mode, packageQueries });
439
- await emitRunCard(runId);
440
- return ok({ runId, status: "in_progress" });
571
+ await emitRunCard(launched.value.runId);
572
+ return ok({ runId: launched.value.runId, status: "in_progress" });
441
573
  },
442
574
  });
443
575
  }
@@ -221,7 +221,7 @@ export function createDeriveTableTool(deps) {
221
221
  }
222
222
  let root;
223
223
  try {
224
- root = deps.resolveWorkspaceRoot(analysisId);
224
+ root = await deps.resolveWorkspaceRoot(analysisId);
225
225
  }
226
226
  catch (cause) {
227
227
  logger.warn("the workspace root did not resolve", { threadId, analysisId, ...defaultErrorFields(cause) });
@@ -276,7 +276,7 @@ export function createExaminePageTool(deps) {
276
276
  const { threadId, analysisId } = opened.value;
277
277
  let root;
278
278
  try {
279
- root = deps.resolveWorkspaceRoot(analysisId);
279
+ root = await deps.resolveWorkspaceRoot(analysisId);
280
280
  }
281
281
  catch (cause) {
282
282
  logger.warn("the workspace root did not resolve", { threadId, analysisId, ...defaultErrorFields(cause) });
@@ -170,11 +170,11 @@ export function createListPinnedArtifactsTool(deps) {
170
170
  return ok({ outcome: "refused", refusal: opened.error });
171
171
  }
172
172
  const { threadId, analysisId, state } = opened.value;
173
- // The seam signals an unresolvable resource by a throw. The listing still serves the path and
173
+ // The seam signals an unresolvable resource by a rejection. The listing still serves the path and
174
174
  // the file type of each entry, thus the fault costs the columns alone and never the whole call.
175
175
  let root;
176
176
  try {
177
- root = deps.resolveWorkspaceRoot(analysisId);
177
+ root = await deps.resolveWorkspaceRoot(analysisId);
178
178
  }
179
179
  catch (cause) {
180
180
  logger.warn("the workspace root did not resolve", { threadId, analysisId, ...defaultErrorFields(cause) });
@@ -139,7 +139,7 @@ export interface PreviewReportToolDeps {
139
139
  readonly makeResolver?: (scope: {
140
140
  analysisId: string;
141
141
  auth: AuthContext;
142
- }) => ReferenceResolver;
142
+ }) => Promise<ReferenceResolver>;
143
143
  readonly resolveWorkspaceRoot: ResolveWorkspaceRoot;
144
144
  readonly resolvePageAsset?: ResolvePageAsset;
145
145
  readonly makeSessionPages?: MakeSessionPagePublisher;
@@ -165,8 +165,8 @@ async function resolveDocument(document, resolver, snapshot) {
165
165
  /**
166
166
  * Resolve the page root, stage each bound image and each manifest entry, and write the page.
167
167
  *
168
- * The workspace-root seam signals an unresolvable resource by a throw, thus the resolution sits inside
169
- * the protection and its throw becomes a value. Each `fs` call runs through `tryFsWrite`, the one
168
+ * The workspace-root seam signals an unresolvable resource by a rejection, thus the resolution sits inside
169
+ * the protection and its rejection becomes a value. Each `fs` call runs through `tryFsWrite`, the one
170
170
  * sanctioned `fs` guard, thus a genuine I/O fault becomes a value and a control-flow exception such as a
171
171
  * cancellation propagates.
172
172
  *
@@ -186,7 +186,7 @@ async function resolveDocument(document, resolver, snapshot) {
186
186
  async function renderToWorkspace(args) {
187
187
  let root;
188
188
  try {
189
- root = args.resolveWorkspaceRoot(args.analysisId);
189
+ root = await args.resolveWorkspaceRoot(args.analysisId);
190
190
  }
191
191
  catch (cause) {
192
192
  return err({ kind: "fs", error: { type: "read_failed", op: "preview.resolveWorkspaceRoot", path: args.analysisId, cause } });
@@ -382,12 +382,12 @@ export function createPreviewReportTool(deps) {
382
382
  if (deps.makeResolver === undefined) {
383
383
  return ok({ outcome: "resolver-unavailable" });
384
384
  }
385
- // The resolver construction resolves the workspace root inside, and that seam throws on an
386
- // unresolvable root. The guard turns the throw into a typed outcome that names the fault, thus a
385
+ // The resolver construction resolves the workspace root inside, and that seam rejects on an
386
+ // unresolvable root. The guard turns the rejection into a typed outcome that names the fault, thus a
387
387
  // control-flow exception propagates and the unresolvable root does not.
388
388
  let resolver;
389
389
  try {
390
- resolver = deps.makeResolver({ analysisId, auth: ctx.session.auth });
390
+ resolver = await deps.makeResolver({ analysisId, auth: ctx.session.auth });
391
391
  }
392
392
  catch (cause) {
393
393
  logger.warn("the workspace root did not resolve", { threadId, analysisId, ...defaultErrorFields(cause) });
@@ -105,7 +105,7 @@ export interface RecordVersionToolDeps {
105
105
  readonly makeResolver?: (scope: {
106
106
  analysisId: string;
107
107
  auth: AuthContext;
108
- }) => ReferenceResolver;
108
+ }) => Promise<ReferenceResolver>;
109
109
  /** The provenance seam; an unbound session emit emits nothing and the record runs the same. */
110
110
  readonly provenance?: ProvenanceSeam;
111
111
  readonly logger?: Logger;
@@ -86,7 +86,7 @@ async function pruneUnusedDerivations(args) {
86
86
  let root;
87
87
  let derivedDir;
88
88
  try {
89
- root = args.resolveWorkspaceRoot(args.analysisId);
89
+ root = await args.resolveWorkspaceRoot(args.analysisId);
90
90
  derivedDir = resolvePath(root, reportSessionDerivedDir(args.threadId));
91
91
  }
92
92
  catch (cause) {
@@ -169,12 +169,12 @@ export function createRecordVersionTool(deps) {
169
169
  if (deps.makeResolver === undefined) {
170
170
  return ok({ outcome: "resolver-unavailable" });
171
171
  }
172
- // The resolver construction resolves the workspace root inside, and that seam throws on an
173
- // unresolvable root. The guard turns the throw into a typed outcome that names the fault, thus a
172
+ // The resolver construction resolves the workspace root inside, and that seam rejects on an
173
+ // unresolvable root. The guard turns the rejection into a typed outcome that names the fault, thus a
174
174
  // control-flow exception propagates and the unresolvable root does not.
175
175
  let resolver;
176
176
  try {
177
- resolver = deps.makeResolver({ analysisId, auth: ctx.session.auth });
177
+ resolver = await deps.makeResolver({ analysisId, auth: ctx.session.auth });
178
178
  }
179
179
  catch (cause) {
180
180
  logger.warn("the workspace root did not resolve", { threadId, analysisId, ...logger.errorFields(cause) });
@@ -161,7 +161,7 @@ interface FailedOutput {
161
161
  }
162
162
  export type InspectDataProfileOutput = OverviewOutput | GroupsOutput | FilesOutput | AbsentOutput | PendingOutput | FailedOutput;
163
163
  export declare function createInspectDataProfileTool(pool: Pool): import("../define-tool.js").Tool<{
164
- scope?: "overview" | "groups" | "files" | undefined;
164
+ scope?: "groups" | "files" | "overview" | undefined;
165
165
  page?: number | undefined;
166
166
  pageSize?: number | undefined;
167
167
  }, InspectDataProfileOutput>;
@@ -2,7 +2,7 @@
2
2
  * inspectRun — inspect workflow runs for the current analysis.
3
3
  *
4
4
  * Results are pull-only: workflows never append completion messages to a
5
- * conversation. List mode is active-first and paged. Targeted mode makes
5
+ * conversation. List mode puts the running runs first and is paged. Targeted mode makes
6
6
  * readiness explicit and can perform one bounded, abort-aware wait.
7
7
  */
8
8
  import type { Pool } from "pg";
@@ -2,7 +2,7 @@
2
2
  * inspectRun — inspect workflow runs for the current analysis.
3
3
  *
4
4
  * Results are pull-only: workflows never append completion messages to a
5
- * conversation. List mode is active-first and paged. Targeted mode makes
5
+ * conversation. List mode puts the running runs first and is paged. Targeted mode makes
6
6
  * readiness explicit and can perform one bounded, abort-aware wait.
7
7
  */
8
8
  import { setTimeout as sleep } from "node:timers/promises";
@@ -20,7 +20,7 @@ const systemClock = {
20
20
  wait: (ms, signal) => sleep(ms, undefined, { signal }),
21
21
  };
22
22
  function isTerminal(status) {
23
- return status === "completed" || status === "partial" || status === "failed" || status === "canceled";
23
+ return status === "completed" || status === "partial" || status === "failed" || status === "canceled" || status === "suspended_insufficient_funds";
24
24
  }
25
25
  function formatRun(r, verbose, exposePaths = isTerminal(r.status)) {
26
26
  return {
@@ -98,10 +98,10 @@ export function createInspectRunTool(pool, clock = systemClock) {
98
98
  return defineTool({
99
99
  id: "inspect_run",
100
100
  description: "Inspect workflow runs for this analysis. A run never posts its completion into the conversation; this tool is how you read it. " +
101
- "Without a runId, returns an active-first paged list (running, suspended, then terminal history) with total and hasMore. " +
102
- "With a runId, returns the run, its steps, and an inspectionState: not_found; in_progress; suspended (the run does not progress until it is resumed); " +
101
+ "Without a runId, returns a paged list (running runs first, then each other run) with total and hasMore. " +
102
+ "With a runId, returns the run, its steps, and an inspectionState: not_found; in_progress; suspended (the host suspended the run, it does not progress, and a new run of the plan can start); " +
103
103
  "or terminal (run.status completed, partial, failed, or canceled). " +
104
- "Output paths appear only on a terminal run: run.synthesisPath when synthesisStatus is produced, and the summaryPath of each step that ran. " +
104
+ "Output paths appear only on a suspended or terminal run: run.synthesisPath when synthesisStatus is produced, and the summaryPath of each step that ran. " +
105
105
  "For a user-directed check, waitForTerminalSeconds can wait once for 1-30 seconds; if the cutoff returns in_progress, stop polling for that turn. " +
106
106
  "A wait on the run that the caller itself runs inside returns at once with selfWaitPrevented. " +
107
107
  "Default response is lightweight — pass includeDiagnostics:true to add error/duration/retry telemetry.",
@@ -179,8 +179,8 @@ export function createInspectRunTool(pool, clock = systemClock) {
179
179
  if (run.status === "suspended_insufficient_funds") {
180
180
  return ok({
181
181
  inspectionState: "suspended",
182
- message: "This run is suspended and will not progress until it is resumed.",
183
- run: formatRun(run, verbose, false),
182
+ message: "The host suspended this run. The run does not progress. A new run of the plan can start.",
183
+ run: formatRun(run, verbose, true),
184
184
  steps,
185
185
  ...wait,
186
186
  });
@@ -201,9 +201,9 @@ export function createSessionWorkspaceMutator(deps) {
201
201
  const scope = session.scope;
202
202
  if (scope.kind !== "analysis")
203
203
  return { status: "out_of_scope", path };
204
- // Throws on an unknown analysis — the loop's dispatch catch maps
204
+ // Rejects on an unknown analysis — the loop's dispatch catch maps
205
205
  // it to an error tool result, like any unexpected host failure.
206
- const workspaceRoot = deps.resolveWorkspaceRoot(scope.analysisId);
206
+ const workspaceRoot = await deps.resolveWorkspaceRoot(scope.analysisId);
207
207
  const landed = await confinedWrite({
208
208
  workspaceRoot,
209
209
  analysisId: scope.analysisId,