@inflexa-ai/harness 0.44.0 → 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.
- package/dist/app/approve-plan.d.ts +48 -0
- package/dist/app/approve-plan.js +84 -0
- package/dist/app/data-profile-view.d.ts +1 -1
- package/dist/app/run-activity.js +4 -6
- package/dist/contracts/chat-events.d.ts +2 -0
- package/dist/contracts/chat-frame.d.ts +7 -0
- package/dist/contracts/chat-frame.js +18 -4
- package/dist/contracts/chat-parts.d.ts +73 -2
- package/dist/contracts/chat-parts.js +3 -1
- package/dist/contracts/index.d.ts +3 -1
- package/dist/contracts/index.js +1 -0
- package/dist/contracts/part-registry.js +3 -0
- package/dist/contracts/schemas/chat-events.d.ts +13 -0
- package/dist/contracts/schemas/chat-events.js +1 -0
- package/dist/contracts/schemas/chat-parts.d.ts +200 -15
- package/dist/contracts/schemas/chat-parts.js +63 -12
- package/dist/contracts/schemas/index.d.ts +1 -1
- package/dist/contracts/schemas/index.js +1 -1
- package/dist/contracts/subagent-activity.d.ts +12 -0
- package/dist/contracts/subagent-activity.js +76 -0
- package/dist/execution/artifact-metadata.js +1 -3
- package/dist/execution/run-canceler.js +1 -1
- package/dist/execution/run-event-parts.d.ts +6 -5
- package/dist/execution/run-event-parts.js +6 -5
- package/dist/execution/run-event-stream.d.ts +18 -6
- package/dist/execution/run-event-stream.js +67 -17
- package/dist/execution/run-synthesis.d.ts +21 -0
- package/dist/execution/run-synthesis.js +77 -3
- package/dist/index.d.ts +22 -8
- package/dist/index.js +16 -2
- package/dist/lib/hooks.d.ts +1 -1
- package/dist/lib/metrics.d.ts +2 -2
- package/dist/loop/run-agent.js +1 -0
- package/dist/loop/types.d.ts +2 -0
- package/dist/memory/card-builders.d.ts +11 -0
- package/dist/memory/card-builders.js +35 -21
- package/dist/memory/conversation-display-recorder.d.ts +2 -0
- package/dist/memory/conversation-display-recorder.js +10 -1
- package/dist/memory/conversation-display-storage.d.ts +9 -2
- package/dist/memory/conversation-display-storage.js +18 -3
- package/dist/memory/thread-history.d.ts +8 -1
- package/dist/memory/thread-history.js +13 -3
- package/dist/memory/thread-store.d.ts +13 -0
- package/dist/memory/thread-store.js +15 -1
- package/dist/prompts/conversation.d.ts +1 -1
- package/dist/prompts/conversation.js +3 -1
- package/dist/provenance/seam.d.ts +16 -5
- package/dist/providers/ai-sdk.js +6 -4
- package/dist/report-model/pin-snapshot.d.ts +1 -1
- package/dist/report-model/pin-snapshot.js +2 -50
- package/dist/runtime/assemble.d.ts +8 -1
- package/dist/runtime/assemble.js +11 -0
- package/dist/runtime/boot.d.ts +3 -0
- package/dist/runtime/boot.js +10 -1
- package/dist/sandbox/sandbox-step-translate.d.ts +23 -0
- package/dist/sandbox/sandbox-step-translate.js +24 -0
- package/dist/schemas/artifact-manifest.d.ts +1 -1
- package/dist/schemas/artifact-manifest.js +11 -9
- package/dist/schemas/plan-schemas.d.ts +18 -18
- package/dist/schemas/run-synthesis.d.ts +2 -2
- package/dist/state/analyses-metrics.d.ts +79 -7
- package/dist/state/analyses-metrics.js +141 -6
- package/dist/state/artifacts.d.ts +50 -3
- package/dist/state/artifacts.js +67 -7
- package/dist/state/data-profile.d.ts +4 -2
- package/dist/state/data-profile.js +7 -5
- package/dist/state/index.d.ts +9 -8
- package/dist/state/index.js +5 -5
- package/dist/state/migrations/20261012120000_index_the_runs_by_start.d.ts +8 -0
- package/dist/state/migrations/20261012120000_index_the_runs_by_start.js +12 -0
- package/dist/state/migrations/20261013120000_index_the_ended_runs_and_the_recent_threads.d.ts +8 -0
- package/dist/state/migrations/20261013120000_index_the_ended_runs_and_the_recent_threads.js +13 -0
- package/dist/state/migrations/20261013180000_narrow_the_active_run_index.d.ts +8 -0
- package/dist/state/migrations/20261013180000_narrow_the_active_run_index.js +13 -0
- package/dist/state/migrations/20261014120000_index_the_plans_by_parent_and_the_runs_by_plan.d.ts +8 -0
- package/dist/state/migrations/20261014120000_index_the_plans_by_parent_and_the_runs_by_plan.js +13 -0
- package/dist/state/migrations/index.js +8 -0
- package/dist/state/plans.d.ts +21 -0
- package/dist/state/plans.js +25 -0
- package/dist/state/runs.d.ts +36 -5
- package/dist/state/runs.js +65 -19
- package/dist/state/schema.d.ts +2 -0
- package/dist/tasks/data-profile-activity.d.ts +7 -1
- package/dist/tasks/data-profile-activity.js +18 -9
- package/dist/tasks/data-profile.d.ts +15 -1
- package/dist/tasks/data-profile.js +16 -11
- package/dist/tools/approval/gateway.js +28 -5
- package/dist/tools/display/show-user.d.ts +1 -1
- package/dist/tools/execute-analysis.d.ts +51 -0
- package/dist/tools/execute-analysis.js +241 -109
- package/dist/tools/research/inspect-data-profile.d.ts +1 -1
- package/dist/tools/research/inspect-run.d.ts +1 -1
- package/dist/tools/research/inspect-run.js +7 -7
- package/dist/workflows/execute-analysis.d.ts +4 -3
- package/dist/workflows/execute-analysis.js +35 -22
- package/dist/workflows/plan/graph.d.ts +16 -0
- package/dist/workflows/plan/graph.js +36 -0
- package/dist/workflows/sandbox-step.js +14 -12
- package/package.json +1 -1
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
|
216
|
+
function parseStoredPlan(raw, planId) {
|
|
197
217
|
const parsed = AnalysisPlanSchema.safeParse(raw);
|
|
198
218
|
if (!parsed.success)
|
|
199
|
-
|
|
219
|
+
return err({ type: "plan_invalid", planId, malformed: true, errors: [] });
|
|
200
220
|
const result = validatePlan(parsed.data);
|
|
201
221
|
if (!result.valid)
|
|
202
|
-
|
|
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
|
-
|
|
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
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
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
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
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
|
-
|
|
439
|
-
|
|
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
|
}
|
|
@@ -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?: "
|
|
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
|
|
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
|
|
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
|
|
102
|
-
"With a runId, returns the run, its steps, and an inspectionState: not_found; in_progress; suspended (the run does not progress
|
|
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: "
|
|
183
|
-
run: formatRun(run, verbose,
|
|
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
|
});
|
|
@@ -50,8 +50,9 @@
|
|
|
50
50
|
* the run before any step starts
|
|
51
51
|
* - a `suspend` error of a synthesis model request suspends the run in
|
|
52
52
|
* place of the synthesis failure
|
|
53
|
-
* - the analysis
|
|
54
|
-
* `collectAndComplete`. The
|
|
53
|
+
* - the run row and the analysis flip to `suspended_insufficient_funds` in
|
|
54
|
+
* `collectAndComplete`. The run status is terminal and does not block a
|
|
55
|
+
* new run of the plan. The paused parent lands in `CANCELLED`, a
|
|
55
56
|
* durably reschedulable state; re-driving it is a future enhancement,
|
|
56
57
|
* not yet wired here. The reason of the host rides to the run row, the
|
|
57
58
|
* charge close, the metric, and the terminal part, unread.
|
|
@@ -160,7 +161,7 @@ export interface ExecuteAnalysisInput {
|
|
|
160
161
|
* path returns — the caller joined an in-flight run, no new workflow was
|
|
161
162
|
* started. Every other variant is a terminal status the parent reached.
|
|
162
163
|
*/
|
|
163
|
-
export type ExecuteAnalysisFinalStatus = "running" | "completed" | "partial" | "failed" | "canceled";
|
|
164
|
+
export type ExecuteAnalysisFinalStatus = "running" | "completed" | "partial" | "failed" | "canceled" | "suspended_insufficient_funds";
|
|
164
165
|
export interface ExecuteAnalysisResult {
|
|
165
166
|
readonly runId: string;
|
|
166
167
|
readonly workflowId: string;
|
|
@@ -50,8 +50,9 @@
|
|
|
50
50
|
* the run before any step starts
|
|
51
51
|
* - a `suspend` error of a synthesis model request suspends the run in
|
|
52
52
|
* place of the synthesis failure
|
|
53
|
-
* - the analysis
|
|
54
|
-
* `collectAndComplete`. The
|
|
53
|
+
* - the run row and the analysis flip to `suspended_insufficient_funds` in
|
|
54
|
+
* `collectAndComplete`. The run status is terminal and does not block a
|
|
55
|
+
* new run of the plan. The paused parent lands in `CANCELLED`, a
|
|
55
56
|
* durably reschedulable state; re-driving it is a future enhancement,
|
|
56
57
|
* not yet wired here. The reason of the host rides to the run row, the
|
|
57
58
|
* charge close, the metric, and the terminal part, unread.
|
|
@@ -79,12 +80,13 @@ import { ATTR_INFLEXA_STEP_ID, stableSpan } from "../lib/otel-spans.js";
|
|
|
79
80
|
import { recordRunCompleted, recordStepCompleted, stepOutcomeOf } from "../lib/metrics.js";
|
|
80
81
|
import { describeDbError } from "../lib/db-result.js";
|
|
81
82
|
import { unwrapOrThrow } from "../lib/result.js";
|
|
82
|
-
import {
|
|
83
|
+
import { countArtifactsByRuns, insertStepExecution, loadDataProfileStatus, queryActiveRun, queryStepArtifactPaths, queryStepsByRun, seedStepExecutions, setRunSynthesisOutcome, suspendAnalysis as suspendAnalysisQuery, sweepPendingStepExecutions, updateRunStatus, updateStepExecution, } from "../state/index.js";
|
|
83
84
|
import { addChatUsage, hasReportedUsage } from "../loop/metrics.js";
|
|
85
|
+
import { flattenDataPart } from "../contracts/chat-frame.js";
|
|
84
86
|
import { SYNTHESIS_AGENT_ID, loadStepSummariesFromDisk } from "../execution/run-synthesis.js";
|
|
85
87
|
import { MAX_UPSTREAM_ARTIFACTS, composeStepBriefing, emptyStepMemory } from "../prompts/briefing.js";
|
|
86
88
|
import { SYNTHESIS_STEP_ID, runDir, runStepDir, stepSubdir, stepWritePrefix, toSandboxPath } from "../workspace/paths.js";
|
|
87
|
-
import { isChatDataPart } from "../sandbox/sandbox-step-translate.js";
|
|
89
|
+
import { isChatDataPart, synthesisEmission } from "../sandbox/sandbox-step-translate.js";
|
|
88
90
|
import { synthesizeRun } from "../app/synthesize-run.js";
|
|
89
91
|
import { computeTopologicalLevels, scheduleReady, validatePlanDag } from "./execute-analysis-scheduler.js";
|
|
90
92
|
import { recordCancelledChild } from "./metrics.js";
|
|
@@ -359,9 +361,20 @@ export function synthesisRowUpdate(outcome, durationMs) {
|
|
|
359
361
|
*/
|
|
360
362
|
export function synthesizeFindings(args) {
|
|
361
363
|
const { analysisId, runId, completedSteps, session, deps } = args;
|
|
364
|
+
const logger = (deps.logger ?? createNoopLogger()).named("executeAnalysis.synthesis");
|
|
365
|
+
// Observation is a diagnostic channel: a failed progress write is logged and never fails the synthesis.
|
|
366
|
+
let progressSeq = 0;
|
|
367
|
+
const writeProgressAlone = async (part, phase) => {
|
|
368
|
+
try {
|
|
369
|
+
await DBOS.writeStream("events", part);
|
|
370
|
+
}
|
|
371
|
+
catch (err) {
|
|
372
|
+
logger.warn("synthesis progress write failed (non-fatal)", { phase, ...logger.errorFields(err) });
|
|
373
|
+
}
|
|
374
|
+
};
|
|
362
375
|
const emit = (event) => {
|
|
363
376
|
const part = isChatDataPart(event)
|
|
364
|
-
? event
|
|
377
|
+
? flattenDataPart(event)
|
|
365
378
|
: {
|
|
366
379
|
type: "data-loop-event",
|
|
367
380
|
data: { stepId: "synthesis", event },
|
|
@@ -395,14 +408,15 @@ export function synthesizeFindings(args) {
|
|
|
395
408
|
// call path.
|
|
396
409
|
session: forStep(session, SYNTHESIS_STEP_ID),
|
|
397
410
|
emit,
|
|
398
|
-
onProgress: (phase, activity, extra = {}) =>
|
|
399
|
-
|
|
400
|
-
id
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
activity,
|
|
404
|
-
|
|
405
|
-
|
|
411
|
+
onProgress: async (phase, activity, extra = {}) => {
|
|
412
|
+
// Synthesis runs inside one `DBOS.runStep`, thus `DBOS.now()` reads the wall clock here, and a rerun of
|
|
413
|
+
// the step after a crash writes its entries again under the same ids. A reader dedups an entry by id.
|
|
414
|
+
const seq = progressSeq++;
|
|
415
|
+
const at = new Date(await DBOS.now()).toISOString();
|
|
416
|
+
const parts = synthesisEmission(runId, SYNTHESIS_STEP_ID, seq, phase, activity, at, extra);
|
|
417
|
+
await writeProgressAlone(parts.progress, phase);
|
|
418
|
+
await writeProgressAlone(parts.entry, phase);
|
|
419
|
+
},
|
|
406
420
|
});
|
|
407
421
|
}
|
|
408
422
|
// ── Workflow registration ────────────────────────────────────────────
|
|
@@ -1086,7 +1100,7 @@ async function collectAndComplete(args) {
|
|
|
1086
1100
|
durationMs: terminalAtMs - startedAtMs,
|
|
1087
1101
|
}, input.runSession);
|
|
1088
1102
|
await ledgerStep(logger.with({ status }), "persist-final-status", async () => {
|
|
1089
|
-
const written = await updateRunStatus(deps.pool, runId, status, failureReason ?? (status === "canceled"
|
|
1103
|
+
const written = await updateRunStatus(deps.pool, runId, status, failureReason ?? (status === "canceled" ? "external_cancel" : null));
|
|
1090
1104
|
if (written.isErr())
|
|
1091
1105
|
return written;
|
|
1092
1106
|
// The outcome metrics ride inside the step that persists the terminal status: DBOS
|
|
@@ -1119,10 +1133,9 @@ async function collectAndComplete(args) {
|
|
|
1119
1133
|
await ledgerStep(logger.with({ synthesisStatus: outcome.status }), "persist-synthesis-outcome", () => setRunSynthesisOutcome(deps.pool, runId, outcome.status, outcome.reason));
|
|
1120
1134
|
}
|
|
1121
1135
|
// Sweep never-started steps to `skipped` — but ONLY on genuinely-terminal
|
|
1122
|
-
// paths. The gate is the BRANCH, not the written run status: the
|
|
1123
|
-
//
|
|
1124
|
-
//
|
|
1125
|
-
// finalisation step here.
|
|
1136
|
+
// paths. The gate is the BRANCH, not the written run status: the pending rows
|
|
1137
|
+
// of a suspended run must survive for a later resume. Log-don't-rollback, like
|
|
1138
|
+
// every other finalisation step here.
|
|
1126
1139
|
if (suspended === null) {
|
|
1127
1140
|
await ledgerStep(logger, "sweep-pending-steps", () => sweepPendingStepExecutions(deps.pool, runId));
|
|
1128
1141
|
}
|
|
@@ -1149,9 +1162,9 @@ async function collectAndComplete(args) {
|
|
|
1149
1162
|
// beat the CLI's poll-and-shutdown). These branches only fan out the UI stream part, whose
|
|
1150
1163
|
// completed/failed shapes genuinely differ — the provenance record does not.
|
|
1151
1164
|
if (status === "completed" || status === "partial") {
|
|
1152
|
-
const artifactCount = await DBOS.runStep(async () => await
|
|
1153
|
-
|
|
1154
|
-
|
|
1165
|
+
const artifactCount = await DBOS.runStep(async () => await countArtifactsByRuns(deps.pool, input.analysisId, [runId])
|
|
1166
|
+
.map((counts) => counts[0]?.count ?? 0)
|
|
1167
|
+
.unwrapOr(0), { name: "count-run-artifacts" });
|
|
1155
1168
|
await emitStreamPart({
|
|
1156
1169
|
type: "data-run-completed",
|
|
1157
1170
|
runId,
|
|
@@ -1197,7 +1210,7 @@ async function collectAndComplete(args) {
|
|
|
1197
1210
|
function deriveFinalStatus(args) {
|
|
1198
1211
|
const { totalSteps, completed, failed, canceled, suspended } = args;
|
|
1199
1212
|
if (suspended)
|
|
1200
|
-
return "
|
|
1213
|
+
return "suspended_insufficient_funds";
|
|
1201
1214
|
if (failed.size > 0) {
|
|
1202
1215
|
return completed.size > 0 ? "partial" : "failed";
|
|
1203
1216
|
}
|