@inflexa-ai/harness 0.35.3 → 0.35.4
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/citations/types.d.ts +2 -2
- package/dist/config/environment-stores.d.ts +10 -0
- package/dist/contracts/schemas/chat-parts.d.ts +11 -11
- package/dist/execution/run-canceler.js +20 -4
- package/dist/index.d.ts +3 -1
- package/dist/index.js +5 -0
- package/dist/lib/metrics.d.ts +63 -5
- package/dist/lib/metrics.js +104 -5
- package/dist/lib/otel.d.ts +6 -2
- package/dist/lib/otel.js +6 -2
- package/dist/prompts/planner.js +6 -0
- package/dist/prompts/sandbox-standards.d.ts +1 -1
- package/dist/prompts/sandbox-standards.js +8 -3
- package/dist/sandbox/package-identity.d.ts +208 -0
- package/dist/sandbox/package-identity.js +244 -0
- package/dist/sandbox/types.d.ts +20 -25
- package/dist/schemas/plan-schemas.d.ts +3 -3
- package/dist/schemas/validate-plan.js +32 -15
- package/dist/schemas/workflow-state.js +3 -1
- package/dist/state/data-profile.d.ts +20 -6
- package/dist/state/data-profile.js +16 -8
- package/dist/state/index.d.ts +1 -1
- package/dist/state/schema.d.ts +2 -2
- package/dist/tasks/data-profile.js +26 -12
- package/dist/tools/execute-analysis.js +37 -20
- package/dist/tools/research/inspect-run.d.ts +1 -1
- package/dist/tools/sandbox/link-packages.d.ts +6 -6
- package/dist/tools/sandbox/link-packages.js +45 -14
- package/dist/tools/sandbox/list-available-packages.d.ts +23 -1
- package/dist/tools/sandbox/list-available-packages.js +185 -43
- package/dist/workflows/execute-analysis.d.ts +4 -1
- package/dist/workflows/execute-analysis.js +34 -3
- package/dist/workflows/execute-target-assessment.js +59 -28
- package/dist/workflows/sandbox-step.js +7 -1
- package/package.json +1 -1
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
import { ok } from "neverthrow";
|
|
23
23
|
import { z } from "zod";
|
|
24
24
|
import { defineTool } from "../define-tool.js";
|
|
25
|
+
import { identityAddress, identityKey, identityOf, parseQuery, resolveQuery, } from "../../sandbox/package-identity.js";
|
|
25
26
|
import { capCodePoints, DETAIL_NEEDLE_MAX_LENGTH } from "../../loop/tool-detail.js";
|
|
26
27
|
import { LIBS_CONTAINER_PATH } from "../../sandbox/mount-plan.js";
|
|
27
28
|
import { readFarmLockFile } from "../../sandbox/farm.js";
|
|
@@ -46,8 +47,9 @@ const DEFAULT_IMAGE_PACKAGES_FILE = `${LIBS_CONTAINER_PATH}/${IMAGE_PACKAGES_FIL
|
|
|
46
47
|
/**
|
|
47
48
|
* The two headings that a lock track and the image record can both carry.
|
|
48
49
|
* Declared once, because {@link LANGUAGE_MATCHERS} keys the `language` filter
|
|
49
|
-
* on the heading text, and {@link foldSections} merges two
|
|
50
|
-
* second spelling of either would split one track into two
|
|
50
|
+
* of these two on the heading text, and {@link foldSections} merges two
|
|
51
|
+
* sources by it: a second spelling of either would split one track into two
|
|
52
|
+
* headings.
|
|
51
53
|
*/
|
|
52
54
|
const CLI_TITLE = "System tools (CLI)";
|
|
53
55
|
const NODE_TITLE = "Node (npm)";
|
|
@@ -59,6 +61,20 @@ const TRACK_TITLES = {
|
|
|
59
61
|
github: "R (GitHub)",
|
|
60
62
|
node: NODE_TITLE,
|
|
61
63
|
};
|
|
64
|
+
/**
|
|
65
|
+
* The ecosystem of one lock subtree. `python` gives the Python track, and
|
|
66
|
+
* `cran`, `bioconductor`, and `github` give the R track — three subtrees of
|
|
67
|
+
* one ecosystem, because a repository is a source and not an identity rule.
|
|
68
|
+
*
|
|
69
|
+
* A subtree that names neither, such as `node`, carries no track: its packages
|
|
70
|
+
* are not in the pool of the two ecosystems, and a `names` lookup matches them
|
|
71
|
+
* by their rendered name.
|
|
72
|
+
*/
|
|
73
|
+
export function trackOfLockSubtree(subtree) {
|
|
74
|
+
if (subtree === "python")
|
|
75
|
+
return "python";
|
|
76
|
+
return subtree === "cran" || subtree === "bioconductor" || subtree === "github" ? "r" : undefined;
|
|
77
|
+
}
|
|
62
78
|
/**
|
|
63
79
|
* Group the lock packages into sections, one per track, in a stable order:
|
|
64
80
|
* the known tracks first, then an unknown track at its first occurrence.
|
|
@@ -77,7 +93,12 @@ export function lockSections(lock) {
|
|
|
77
93
|
else
|
|
78
94
|
byTrack.set(pkg.track, [entry]);
|
|
79
95
|
}
|
|
80
|
-
return [...byTrack.entries()]
|
|
96
|
+
return [...byTrack.entries()]
|
|
97
|
+
.filter(([, packages]) => packages.length > 0)
|
|
98
|
+
.map(([subtree, packages]) => {
|
|
99
|
+
const track = trackOfLockSubtree(subtree);
|
|
100
|
+
return { title: TRACK_TITLES[subtree] ?? subtree, packages, ...(track === undefined ? {} : { track }) };
|
|
101
|
+
});
|
|
81
102
|
}
|
|
82
103
|
/**
|
|
83
104
|
* Group the image record into its two sections, in the order of the record.
|
|
@@ -116,18 +137,22 @@ function foldSections(sections) {
|
|
|
116
137
|
for (const section of sections) {
|
|
117
138
|
let open = byTitle.get(section.title);
|
|
118
139
|
if (!open) {
|
|
119
|
-
open = { title: section.title, packages: [], seen: new Set() };
|
|
140
|
+
open = { title: section.title, track: section.track, packages: [], seen: new Set() };
|
|
120
141
|
byTitle.set(section.title, open);
|
|
121
142
|
}
|
|
122
143
|
for (const pkg of section.packages) {
|
|
123
|
-
|
|
124
|
-
|
|
144
|
+
// An untracked section holds an executable or a node module, and
|
|
145
|
+
// the identity rule of a package track does not reach either one.
|
|
146
|
+
// Such a row folds by case alone, thus two sources that spell one
|
|
147
|
+
// executable differently still render one row.
|
|
148
|
+
const seen = section.track === undefined ? pkg.name.toLowerCase() : pkg.name;
|
|
149
|
+
if (open.seen.has(seen))
|
|
125
150
|
continue;
|
|
126
|
-
open.seen.add(
|
|
151
|
+
open.seen.add(seen);
|
|
127
152
|
open.packages.push(pkg);
|
|
128
153
|
}
|
|
129
154
|
}
|
|
130
|
-
return [...byTitle.values()].map(({ title, packages }) => ({ title, packages }));
|
|
155
|
+
return [...byTitle.values()].map(({ title, track, packages }) => ({ title, packages, ...(track === undefined ? {} : { track }) }));
|
|
131
156
|
}
|
|
132
157
|
/**
|
|
133
158
|
* Default cap on a listing — high enough that the real store is never truncated.
|
|
@@ -156,21 +181,59 @@ const UNAVAILABLE_NOTE = "Package list not available — the inventory of the fa
|
|
|
156
181
|
*/
|
|
157
182
|
const POOL_UNAVAILABLE_NOTE = "Package list not available — the package pool could not be read, so what the store holds is UNKNOWN. " +
|
|
158
183
|
"Do not assume a package is present, and do not report a package as absent.";
|
|
159
|
-
/**
|
|
184
|
+
/**
|
|
185
|
+
* The `language` filter of the two sections that carry no track, mapped onto
|
|
186
|
+
* their concrete headings. The `r` and `python` values read `section.track`
|
|
187
|
+
* instead, because a package track is data and a heading is display text.
|
|
188
|
+
*/
|
|
160
189
|
const LANGUAGE_MATCHERS = {
|
|
161
|
-
// The R triple: `R (CRAN)`, `R (Bioconductor)`, `R (GitHub)`.
|
|
162
|
-
r: (t) => /^r\b/i.test(t),
|
|
163
|
-
python: (t) => /^python\b/i.test(t),
|
|
164
190
|
// `CLI_TITLE` — the conda-installed bioinformatics executables.
|
|
165
191
|
cli: (t) => /system tools|\bcli\b/i.test(t),
|
|
166
192
|
node: (t) => /^node\b/i.test(t),
|
|
167
193
|
};
|
|
168
|
-
/**
|
|
169
|
-
function
|
|
170
|
-
|
|
194
|
+
/** Whether one section answers the `language` filter of a call. */
|
|
195
|
+
function matchesLanguage(section, language) {
|
|
196
|
+
if (language === undefined)
|
|
197
|
+
return true;
|
|
198
|
+
if (language === "r" || language === "python")
|
|
199
|
+
return section.track === language;
|
|
200
|
+
return LANGUAGE_MATCHERS[language](section.title);
|
|
201
|
+
}
|
|
202
|
+
/**
|
|
203
|
+
* The identity names that the Python track and the R track both hold.
|
|
204
|
+
*
|
|
205
|
+
* A plan entry of such a name cannot say which package it means, thus the
|
|
206
|
+
* link pass refuses it as a collision. The listing marks each of these rows
|
|
207
|
+
* with the prefixed forms, and the planner writes one of them. A pair of two
|
|
208
|
+
* spellings, such as `decoupler` and `decoupleR`, needs no mark, because the
|
|
209
|
+
* two identities differ and the spelling settles the track.
|
|
210
|
+
*/
|
|
211
|
+
function bothTrackNames(sections) {
|
|
212
|
+
const python = new Set();
|
|
213
|
+
const r = new Set();
|
|
214
|
+
for (const section of sections) {
|
|
215
|
+
if (section.track === undefined)
|
|
216
|
+
continue;
|
|
217
|
+
const holder = section.track === "python" ? python : r;
|
|
218
|
+
for (const pkg of section.packages)
|
|
219
|
+
holder.add(identityOf(section.track, pkg.name).name);
|
|
220
|
+
}
|
|
221
|
+
return new Set([...python].filter((name) => r.has(name)));
|
|
222
|
+
}
|
|
223
|
+
/**
|
|
224
|
+
* One entry as the listing renders it: `name==version` where the source pins
|
|
225
|
+
* a version, the bare name otherwise. A name that both tracks hold carries
|
|
226
|
+
* the two forms a plan writes, because a bare entry of it refuses the launch.
|
|
227
|
+
*/
|
|
228
|
+
function renderPackage(entry, track, bothTracks) {
|
|
229
|
+
const rendered = entry.version === undefined ? entry.name : `${entry.name}==${entry.version}`;
|
|
230
|
+
if (track === undefined)
|
|
231
|
+
return rendered;
|
|
232
|
+
const identity = identityOf(track, entry.name).name;
|
|
233
|
+
return bothTracks.has(identity) ? `${rendered} [both tracks — write python:${identity} or r:${identity}]` : rendered;
|
|
171
234
|
}
|
|
172
235
|
/** Render sections to the agent-facing listing, bounded at `limit` packages. */
|
|
173
|
-
function renderListing(sections, limit) {
|
|
236
|
+
function renderListing(sections, limit, bothTracks) {
|
|
174
237
|
const lines = [];
|
|
175
238
|
let returned = 0;
|
|
176
239
|
for (const section of sections) {
|
|
@@ -183,13 +246,52 @@ function renderListing(sections, limit) {
|
|
|
183
246
|
}
|
|
184
247
|
const shown = section.packages.slice(0, room);
|
|
185
248
|
returned += shown.length;
|
|
186
|
-
lines.push(`\n## ${section.title}`, ` ${shown.map(renderPackage).join(", ")}`);
|
|
249
|
+
lines.push(`\n## ${section.title}`, ` ${shown.map((entry) => renderPackage(entry, section.track, bothTracks)).join(", ")}`);
|
|
187
250
|
const hidden = section.packages.length - shown.length;
|
|
188
251
|
if (hidden > 0)
|
|
189
252
|
lines.push(` … and ${hidden} more in this section (raise \`limit\` or narrow with \`query\`).`);
|
|
190
253
|
}
|
|
191
254
|
return { content: lines.join("\n").trim(), returned };
|
|
192
255
|
}
|
|
256
|
+
/**
|
|
257
|
+
* A {@link PoolIndex} over the rows of the tracked sections. The suggestion of
|
|
258
|
+
* the ladder reads `rIdentitiesFoldingTo`, and the fold of an identity is its
|
|
259
|
+
* store address — thus the census suggests exactly what the store holds.
|
|
260
|
+
*/
|
|
261
|
+
function poolIndexOf(held) {
|
|
262
|
+
const keys = new Set(held.map((row) => identityKey(row.identity)));
|
|
263
|
+
// Keyed by the identity KEY, not by the address: two R sections can list
|
|
264
|
+
// one package, and a suggestion must count that package once. Two R
|
|
265
|
+
// identities of one address stay two entries, and the ladder then gives no
|
|
266
|
+
// suggestion, because a guess between them is a coin flip.
|
|
267
|
+
const rByKey = new Map();
|
|
268
|
+
for (const row of held) {
|
|
269
|
+
if (row.identity.track !== "r")
|
|
270
|
+
continue;
|
|
271
|
+
rByKey.set(identityKey(row.identity), row.identity);
|
|
272
|
+
}
|
|
273
|
+
return {
|
|
274
|
+
has: (identity) => keys.has(identityKey(identity)),
|
|
275
|
+
rIdentitiesFoldingTo: (fold) => [...rByKey.values()].filter((identity) => identityAddress(identity) === fold),
|
|
276
|
+
};
|
|
277
|
+
}
|
|
278
|
+
/**
|
|
279
|
+
* One present answer of the `names` path. `name` echoes the spelling that the
|
|
280
|
+
* source records, because an R name is case-sensitive at `library()` and the
|
|
281
|
+
* caller imports that exact one. The store identity rides only where the
|
|
282
|
+
* source records it.
|
|
283
|
+
*/
|
|
284
|
+
function presentEntry(requested, row) {
|
|
285
|
+
return {
|
|
286
|
+
requested,
|
|
287
|
+
present: true,
|
|
288
|
+
name: row.entry.name,
|
|
289
|
+
section: row.section,
|
|
290
|
+
...(row.entry.version === undefined ? {} : { version: row.entry.version }),
|
|
291
|
+
...(row.entry.storeDir === undefined ? {} : { storeDir: row.entry.storeDir }),
|
|
292
|
+
...(row.entry.hash === undefined ? {} : { hash: row.entry.hash }),
|
|
293
|
+
};
|
|
294
|
+
}
|
|
193
295
|
/**
|
|
194
296
|
* Answer a packages query against a parsed catalog. Pure — the tool's `execute`
|
|
195
297
|
* is only the file read plus this call.
|
|
@@ -199,41 +301,77 @@ export function queryPackages(rawSections, { names, query, language, limit }) {
|
|
|
199
301
|
// Presence check — the cheap, targeted path. Answers "is X available, and in
|
|
200
302
|
// which track" without returning the catalog.
|
|
201
303
|
if (names && names.length > 0) {
|
|
202
|
-
|
|
304
|
+
// A tracked section holds packages of the pool, thus its rows answer
|
|
305
|
+
// through `resolveQuery` — the same ladder the link of the embedder
|
|
306
|
+
// runs, so the census and the link never disagree. An untracked section
|
|
307
|
+
// (system tools, node packages) holds no package of an ecosystem, thus
|
|
308
|
+
// its rows match their rendered name without case: an identity rule
|
|
309
|
+
// covers a distribution, and `BCFtools` must still reach `bcftools`.
|
|
310
|
+
const held = [];
|
|
311
|
+
const untracked = new Map();
|
|
203
312
|
for (const section of sections) {
|
|
204
313
|
for (const pkg of section.packages) {
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
314
|
+
if (section.track === undefined) {
|
|
315
|
+
const lowered = pkg.name.toLowerCase();
|
|
316
|
+
const rows = untracked.get(lowered) ?? [];
|
|
317
|
+
rows.push({ entry: pkg, section: section.title });
|
|
318
|
+
untracked.set(lowered, rows);
|
|
319
|
+
continue;
|
|
320
|
+
}
|
|
321
|
+
held.push({ entry: pkg, section: section.title, identity: identityOf(section.track, pkg.name) });
|
|
209
322
|
}
|
|
210
323
|
}
|
|
211
|
-
const
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
// The
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
324
|
+
const pool = poolIndexOf(held);
|
|
325
|
+
const bySection = new Map();
|
|
326
|
+
for (const row of held)
|
|
327
|
+
if (!bySection.has(identityKey(row.identity)))
|
|
328
|
+
bySection.set(identityKey(row.identity), row);
|
|
329
|
+
const checked = names.flatMap((requested) => {
|
|
330
|
+
// The entry speaks the one grammar, thus `python:igraph` reads as
|
|
331
|
+
// the Python track and not as a package named `python:igraph`. The
|
|
332
|
+
// listing and the planner both write that form.
|
|
333
|
+
const parsed = parseQuery(requested);
|
|
334
|
+
// A census reports a state, it never refuses a call: an entry that
|
|
335
|
+
// is not a query names nothing that the pool holds, thus it answers
|
|
336
|
+
// absent and the caller reads its own entry back.
|
|
337
|
+
if (parsed.isErr())
|
|
338
|
+
return [{ requested, present: false }];
|
|
339
|
+
const query = parsed.value;
|
|
340
|
+
const hits = [];
|
|
341
|
+
const resolution = resolveQuery(query, pool);
|
|
342
|
+
// An ambiguous spelling answers once for each track, because the
|
|
343
|
+
// caller asked about exactly that pair.
|
|
344
|
+
const identities = resolution.kind === "resolved" ? [resolution.identity] : resolution.kind === "ambiguous" ? [resolution.python, resolution.r] : [];
|
|
345
|
+
for (const identity of identities) {
|
|
346
|
+
const row = bySection.get(identityKey(identity));
|
|
347
|
+
if (row !== undefined)
|
|
348
|
+
hits.push(presentEntry(requested, row));
|
|
349
|
+
}
|
|
350
|
+
// A track-qualified entry names one ecosystem, thus it never
|
|
351
|
+
// reaches an executable or a node module.
|
|
352
|
+
if (query.track === undefined) {
|
|
353
|
+
for (const row of untracked.get(query.spelling.toLowerCase()) ?? []) {
|
|
354
|
+
hits.push(presentEntry(requested, { entry: row.entry, section: row.section }));
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
if (hits.length > 0)
|
|
358
|
+
return hits;
|
|
359
|
+
const suggestion = resolution.kind === "unknown" ? resolution.suggestion : undefined;
|
|
360
|
+
return [{ requested, present: false, ...(suggestion === undefined ? {} : { suggestion: suggestion.name }) }];
|
|
227
361
|
});
|
|
228
362
|
return { available: true, checked };
|
|
229
363
|
}
|
|
230
|
-
|
|
364
|
+
// A browse filter, not an identity rule: the lower case here makes a
|
|
365
|
+
// substring search forgiving, and it never decides which package a name
|
|
366
|
+
// means. The `names` path above owns that decision, and `resolveQuery` is
|
|
367
|
+
// the one place that folds.
|
|
231
368
|
const needle = query?.trim().toLowerCase();
|
|
232
369
|
const filtered = sections
|
|
233
|
-
.filter((s) => matchesLanguage(s
|
|
370
|
+
.filter((s) => matchesLanguage(s, language))
|
|
234
371
|
.map((s) => ({
|
|
235
372
|
title: s.title,
|
|
236
373
|
packages: needle ? s.packages.filter((p) => p.name.toLowerCase().includes(needle)) : s.packages,
|
|
374
|
+
...(s.track === undefined ? {} : { track: s.track }),
|
|
237
375
|
}))
|
|
238
376
|
.filter((s) => s.packages.length > 0);
|
|
239
377
|
const total = filtered.reduce((n, s) => n + s.packages.length, 0);
|
|
@@ -247,7 +385,9 @@ export function queryPackages(rawSections, { names, query, language, limit }) {
|
|
|
247
385
|
content: `No packages match this filter${scope ? ` (${scope})` : ""}. Nothing can be installed at runtime.`,
|
|
248
386
|
};
|
|
249
387
|
}
|
|
250
|
-
|
|
388
|
+
// The mark reads every section, not the filtered set: a `language` listing
|
|
389
|
+
// must still say that the other track holds the same name.
|
|
390
|
+
const { content, returned } = renderListing(filtered, limit ?? DEFAULT_LIMIT, bothTrackNames(sections));
|
|
251
391
|
return { available: true, total, returned, hasMore: returned < total, content };
|
|
252
392
|
}
|
|
253
393
|
/**
|
|
@@ -275,7 +415,9 @@ export function createListAvailablePackagesTool(deps = {}) {
|
|
|
275
415
|
id: "list_available_packages",
|
|
276
416
|
description: scopeSentence +
|
|
277
417
|
"A full listing is small (a few hundred packages) and is returned whole by default, so a listing you get back is the complete set unless `hasMore` says otherwise. " +
|
|
278
|
-
'`names`: check specific packages (e.g. ["Seurat", "
|
|
418
|
+
'`names`: check specific packages — each entry is a query in the one grammar, `[python:|r:]<name>` (e.g. ["Seurat", "python:igraph"]) — returns present/absent plus the language track for each; this is the cheapest call and the right one for \'is X available?\'. ' +
|
|
419
|
+
"A Python name matches under PEP 503, thus `scikit_learn` reaches `scikit-learn`. An R name matches its DESCRIPTION spelling exactly, thus `seurat` answers absent and carries `Seurat` as the suggestion. " +
|
|
420
|
+
"A name that the Python track and the R track both hold answers once for each track. " +
|
|
279
421
|
"`query`: case-insensitive substring filter over package names. " +
|
|
280
422
|
"`language`: restrict to one track (r | python | cli | node). " +
|
|
281
423
|
"`limit`: cap the packages listed; the response always carries the true `total` and a `hasMore` flag, so truncation is never silent. " +
|
|
@@ -285,7 +427,7 @@ export function createListAvailablePackagesTool(deps = {}) {
|
|
|
285
427
|
.array(z.string())
|
|
286
428
|
.max(100)
|
|
287
429
|
.optional()
|
|
288
|
-
.describe("Check these
|
|
430
|
+
.describe("Check these packages for presence. Each entry is a query in the one grammar, `[python:|r:]<name>`. Returns one entry for each track that holds the name: present/absent + the language track it lives in. An absent name carries the spelling the pool holds, where there is one."),
|
|
289
431
|
query: z.string().optional().describe("Case-insensitive substring filter over package names."),
|
|
290
432
|
language: z.enum(["r", "python", "cli", "node"]).optional().describe("Restrict results to one language track."),
|
|
291
433
|
limit: z
|
|
@@ -51,7 +51,10 @@
|
|
|
51
51
|
* 5. `synthesizeFindings` (single sequential block, no sandbox)
|
|
52
52
|
* 6. `collectAndComplete` (terminal — runs on ALL paths)
|
|
53
53
|
* - determine final status from completed/cancelled/failed counts
|
|
54
|
-
* - update `cortex_runs.status` + `error
|
|
54
|
+
* - update `cortex_runs.status` + `error`, and inside that same step record
|
|
55
|
+
* the run outcome metric plus the outcome of every settled step whose
|
|
56
|
+
* child never wrote its own terminal row (a cancelled child, or one that
|
|
57
|
+
* died before `failStep`)
|
|
55
58
|
* - close running charge with matching reason
|
|
56
59
|
* - revoke run authorization via the injected `runAuthorizer` seam
|
|
57
60
|
* - emit terminal stream part
|
|
@@ -51,7 +51,10 @@
|
|
|
51
51
|
* 5. `synthesizeFindings` (single sequential block, no sandbox)
|
|
52
52
|
* 6. `collectAndComplete` (terminal — runs on ALL paths)
|
|
53
53
|
* - determine final status from completed/cancelled/failed counts
|
|
54
|
-
* - update `cortex_runs.status` + `error
|
|
54
|
+
* - update `cortex_runs.status` + `error`, and inside that same step record
|
|
55
|
+
* the run outcome metric plus the outcome of every settled step whose
|
|
56
|
+
* child never wrote its own terminal row (a cancelled child, or one that
|
|
57
|
+
* died before `failStep`)
|
|
55
58
|
* - close running charge with matching reason
|
|
56
59
|
* - revoke run authorization via the injected `runAuthorizer` seam
|
|
57
60
|
* - emit terminal stream part
|
|
@@ -64,8 +67,9 @@ import { join } from "node:path";
|
|
|
64
67
|
import { forStep } from "../auth/types.js";
|
|
65
68
|
import { createNoopLogger } from "../lib/console-logger.js";
|
|
66
69
|
import { ATTR_INFLEXA_STEP_ID, stableSpan } from "../lib/otel-spans.js";
|
|
70
|
+
import { recordRunCompleted, recordStepCompleted, stepOutcomeOf } from "../lib/metrics.js";
|
|
67
71
|
import { unwrapOrThrow } from "../lib/result.js";
|
|
68
|
-
import { RunDedupCollisionError, countArtifactsForRun, insertStepExecution, loadDataProfileStatus, queryActiveRun, queryStepArtifactPaths, seedStepExecutions, setRunSynthesisOutcome, suspendAnalysis as suspendAnalysisQuery, sweepPendingStepExecutions, updateRunStatus, updateStepExecution, } from "../state/index.js";
|
|
72
|
+
import { RunDedupCollisionError, countArtifactsForRun, insertStepExecution, loadDataProfileStatus, queryActiveRun, queryStepArtifactPaths, queryStepsByRun, seedStepExecutions, setRunSynthesisOutcome, suspendAnalysis as suspendAnalysisQuery, sweepPendingStepExecutions, updateRunStatus, updateStepExecution, } from "../state/index.js";
|
|
69
73
|
import { isBudgetExceeded } from "../loop/budget-exceeded.js";
|
|
70
74
|
import { addChatUsage, hasReportedUsage } from "../loop/metrics.js";
|
|
71
75
|
import { SYNTHESIS_AGENT_ID, loadStepSummariesFromDisk } from "../execution/run-synthesis.js";
|
|
@@ -523,8 +527,13 @@ export async function runExecuteAnalysisBody(input, deps) {
|
|
|
523
527
|
const rowOutcome = synthesisOutcome;
|
|
524
528
|
try {
|
|
525
529
|
const synthEndedAtMs = await DBOS.now();
|
|
530
|
+
const rowUpdate = synthesisRowUpdate(rowOutcome, synthEndedAtMs - synthStartedAtMs);
|
|
526
531
|
await DBOS.runStep(async () => {
|
|
527
|
-
unwrapOrThrow(await updateStepExecution(deps.pool, runId, SYNTHESIS_STEP_ID,
|
|
532
|
+
unwrapOrThrow(await updateStepExecution(deps.pool, runId, SYNTHESIS_STEP_ID, rowUpdate));
|
|
533
|
+
// Recorded inside the step that writes the row, so a replayed body records nothing again.
|
|
534
|
+
const outcome = stepOutcomeOf(rowUpdate.status);
|
|
535
|
+
if (outcome !== undefined)
|
|
536
|
+
recordStepCompleted({ agentId: SYNTHESIS_AGENT_ID, status: outcome, durationMs: synthEndedAtMs - synthStartedAtMs });
|
|
528
537
|
}, { name: "persist-synthesis-step" });
|
|
529
538
|
}
|
|
530
539
|
catch (err) {
|
|
@@ -1010,6 +1019,28 @@ async function collectAndComplete(args) {
|
|
|
1010
1019
|
try {
|
|
1011
1020
|
await DBOS.runStep(async () => {
|
|
1012
1021
|
unwrapOrThrow(await updateRunStatus(deps.pool, runId, status, failureReason ?? (status === "canceled" && !budgetExceeded ? "external_cancel" : null)));
|
|
1022
|
+
// The outcome metrics ride inside the step that persists the terminal status: DBOS
|
|
1023
|
+
// caches the step, so a recovered body records nothing again. A step's own terminal
|
|
1024
|
+
// row already carried its metric; the residue counted here is every settled step
|
|
1025
|
+
// without one — a child the cancel cut before `mark-canceled` (the cancel check runs
|
|
1026
|
+
// before a step body), or one that died before `failStep`. The ledger read is kept
|
|
1027
|
+
// apart from the status write: a failed read costs the metrics, never the write.
|
|
1028
|
+
let stepRows = [];
|
|
1029
|
+
try {
|
|
1030
|
+
stepRows = unwrapOrThrow(await queryStepsByRun(deps.pool, runId));
|
|
1031
|
+
}
|
|
1032
|
+
catch (err) {
|
|
1033
|
+
logger.warn("run metrics: step ledger read failed, residual step outcomes skipped", logger.errorFields(err));
|
|
1034
|
+
}
|
|
1035
|
+
for (const row of stepRows) {
|
|
1036
|
+
if (row.completedAt !== null)
|
|
1037
|
+
continue;
|
|
1038
|
+
if (canceled.has(row.stepId))
|
|
1039
|
+
recordStepCompleted({ agentId: row.agentId, status: "canceled" });
|
|
1040
|
+
else if (failed.has(row.stepId))
|
|
1041
|
+
recordStepCompleted({ agentId: row.agentId, status: "failed" });
|
|
1042
|
+
}
|
|
1043
|
+
recordRunCompleted({ workflow: "analysis", status, durationMs: terminalAtMs - startedAtMs });
|
|
1013
1044
|
}, { name: "persist-final-status" });
|
|
1014
1045
|
}
|
|
1015
1046
|
catch (err) {
|
|
@@ -53,6 +53,7 @@ import { readBudgetExceededMarker } from "./target-assessment/lib/llm-step.js";
|
|
|
53
53
|
import { assembleSafetyCorroboration } from "./target-assessment/lib/safety-corroboration.js";
|
|
54
54
|
import { investigateClaims, isClaimInvestigationBudgetExceeded } from "./target-assessment/investigation/index.js";
|
|
55
55
|
import { recordTerminalReason } from "./target-assessment/metrics.js";
|
|
56
|
+
import { elapsedSinceIso, recordRunCompleted } from "../lib/metrics.js";
|
|
56
57
|
import { phase4Assemble } from "./target-assessment/phase4-assemble.js";
|
|
57
58
|
import { DossierDerivedInvariantError, DossierSchemaViolationError, phase5Persist } from "./target-assessment/phase5-persist.js";
|
|
58
59
|
import { emitProgress } from "./target-assessment/progress.js";
|
|
@@ -444,9 +445,19 @@ export async function runExecuteTargetAssessmentBody(input, deps) {
|
|
|
444
445
|
}
|
|
445
446
|
// (§6.1, §6.8) Terminal block. Read the row first; if soft-deleted,
|
|
446
447
|
// no-op (the user's delete during the run wins).
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
448
|
+
//
|
|
449
|
+
// Each terminal path records its `cortex.run.completed` outcome INSIDE the
|
|
450
|
+
// DBOS step that persists it, so a recovery replay reads the cached step
|
|
451
|
+
// and records nothing again. The duration runs from the row's `created_at`.
|
|
452
|
+
// The deleted path writes no row, so its record rides the read step itself.
|
|
453
|
+
const row = await DBOS.runStep(async () => {
|
|
454
|
+
const read = unwrapOrThrow(await getAssessment(deps.pool, input.assessmentId, input.organizationId));
|
|
455
|
+
if (read?.status === "deleted") {
|
|
456
|
+
recordRunCompleted({ workflow: "target_assessment", status: "canceled", durationMs: elapsedSinceIso(read.createdAt) });
|
|
457
|
+
}
|
|
458
|
+
return read;
|
|
459
|
+
}, { name: "ta-terminal-read-row" });
|
|
460
|
+
const recordTerminal = (status) => recordRunCompleted({ workflow: "target_assessment", status, durationMs: elapsedSinceIso(row?.createdAt) });
|
|
450
461
|
if (row?.status === "deleted") {
|
|
451
462
|
recordTerminalReason("deleted");
|
|
452
463
|
return {
|
|
@@ -458,9 +469,10 @@ export async function runExecuteTargetAssessmentBody(input, deps) {
|
|
|
458
469
|
// (§6.2) Normal completion — happy path wrote `dossierForPersist`.
|
|
459
470
|
if (!phase0Error && !schemaViolation && !derivedViolation && !unexpectedError && !budgetExceeded && dossierForPersist) {
|
|
460
471
|
try {
|
|
461
|
-
await DBOS.runStep(async () =>
|
|
462
|
-
|
|
463
|
-
|
|
472
|
+
await DBOS.runStep(async () => {
|
|
473
|
+
unwrapOrThrow(await setDossier(deps.pool, input.assessmentId, dossierForPersist));
|
|
474
|
+
recordTerminal("completed");
|
|
475
|
+
}, { name: "ta-terminal-completed" });
|
|
464
476
|
await emitProgress(deps.pool, logger, input.assessmentId, "completed");
|
|
465
477
|
}
|
|
466
478
|
catch (err) {
|
|
@@ -476,10 +488,13 @@ export async function runExecuteTargetAssessmentBody(input, deps) {
|
|
|
476
488
|
}
|
|
477
489
|
// (§6.3) Phase 0 throw — target unresolved.
|
|
478
490
|
if (phase0Error) {
|
|
479
|
-
await DBOS.runStep(async () =>
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
491
|
+
await DBOS.runStep(async () => {
|
|
492
|
+
unwrapOrThrow(await markFailed(deps.pool, input.assessmentId, {
|
|
493
|
+
kind: "target-unresolved",
|
|
494
|
+
message: phase0Error instanceof Error ? phase0Error.message : String(phase0Error),
|
|
495
|
+
}));
|
|
496
|
+
recordTerminal("failed");
|
|
497
|
+
}, { name: "ta-terminal-failed-resolve" });
|
|
483
498
|
await emitProgress(deps.pool, logger, input.assessmentId, "failed");
|
|
484
499
|
await revokeRunAuthorizationSafe(deps, authorization, "target-assessment-failed");
|
|
485
500
|
recordTerminalReason("target-unresolved");
|
|
@@ -491,11 +506,14 @@ export async function runExecuteTargetAssessmentBody(input, deps) {
|
|
|
491
506
|
}
|
|
492
507
|
// (§6.4) Schema violation.
|
|
493
508
|
if (schemaViolation) {
|
|
494
|
-
await DBOS.runStep(async () =>
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
509
|
+
await DBOS.runStep(async () => {
|
|
510
|
+
unwrapOrThrow(await markFailed(deps.pool, input.assessmentId, {
|
|
511
|
+
kind: "schema-invariant-violation",
|
|
512
|
+
message: schemaViolation.message,
|
|
513
|
+
details: schemaViolation.issues,
|
|
514
|
+
}));
|
|
515
|
+
recordTerminal("failed");
|
|
516
|
+
}, { name: "ta-terminal-failed-schema" });
|
|
499
517
|
await emitProgress(deps.pool, logger, input.assessmentId, "failed");
|
|
500
518
|
await revokeRunAuthorizationSafe(deps, authorization, "target-assessment-failed");
|
|
501
519
|
recordTerminalReason("schema-violation");
|
|
@@ -507,10 +525,13 @@ export async function runExecuteTargetAssessmentBody(input, deps) {
|
|
|
507
525
|
}
|
|
508
526
|
// (§6.4 — derived invariant variant — same terminal class, different kind.)
|
|
509
527
|
if (derivedViolation) {
|
|
510
|
-
await DBOS.runStep(async () =>
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
528
|
+
await DBOS.runStep(async () => {
|
|
529
|
+
unwrapOrThrow(await markFailed(deps.pool, input.assessmentId, {
|
|
530
|
+
kind: "derived-invariant-violation",
|
|
531
|
+
message: derivedViolation.message,
|
|
532
|
+
}));
|
|
533
|
+
recordTerminal("failed");
|
|
534
|
+
}, { name: "ta-terminal-failed-derived" });
|
|
514
535
|
await emitProgress(deps.pool, logger, input.assessmentId, "failed");
|
|
515
536
|
await revokeRunAuthorizationSafe(deps, authorization, "target-assessment-failed");
|
|
516
537
|
recordTerminalReason("derived-invariant-violation");
|
|
@@ -529,7 +550,11 @@ export async function runExecuteTargetAssessmentBody(input, deps) {
|
|
|
529
550
|
await DBOS.runStep(() => readBudgetExceededMarker(), {
|
|
530
551
|
name: "ta-terminal-drain-marker",
|
|
531
552
|
}).catch(() => null);
|
|
532
|
-
await DBOS.runStep(async () =>
|
|
553
|
+
await DBOS.runStep(async () => {
|
|
554
|
+
unwrapOrThrow(await markAssessmentSuspended(deps.pool, input.assessmentId));
|
|
555
|
+
// A budget pause records as `canceled`, the same status the analysis run ledger gives it.
|
|
556
|
+
recordTerminal("canceled");
|
|
557
|
+
}, { name: "ta-terminal-suspended" });
|
|
533
558
|
await emitProgress(deps.pool, logger, input.assessmentId, "suspended");
|
|
534
559
|
await revokeRunAuthorizationSafe(deps, authorization, "target-assessment-canceled");
|
|
535
560
|
const workflowId = DBOS.workflowID;
|
|
@@ -552,10 +577,13 @@ export async function runExecuteTargetAssessmentBody(input, deps) {
|
|
|
552
577
|
}
|
|
553
578
|
// (§6.5) Unexpected throw — bug somewhere, coverage envelope failed.
|
|
554
579
|
if (unexpectedError) {
|
|
555
|
-
await DBOS.runStep(async () =>
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
580
|
+
await DBOS.runStep(async () => {
|
|
581
|
+
unwrapOrThrow(await markFailed(deps.pool, input.assessmentId, {
|
|
582
|
+
kind: "unexpected-throw",
|
|
583
|
+
message: unexpectedError instanceof Error ? unexpectedError.message : String(unexpectedError),
|
|
584
|
+
}));
|
|
585
|
+
recordTerminal("failed");
|
|
586
|
+
}, { name: "ta-terminal-failed-unexpected" });
|
|
559
587
|
await emitProgress(deps.pool, logger, input.assessmentId, "failed");
|
|
560
588
|
await revokeRunAuthorizationSafe(deps, authorization, "target-assessment-failed");
|
|
561
589
|
recordTerminalReason("unexpected-throw");
|
|
@@ -568,10 +596,13 @@ export async function runExecuteTargetAssessmentBody(input, deps) {
|
|
|
568
596
|
// Defensive: this branch is unreachable in practice (one of the
|
|
569
597
|
// categories above must have fired). Fall through to a generic failed
|
|
570
598
|
// status so a buggy refactor surfaces visibly.
|
|
571
|
-
await DBOS.runStep(async () =>
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
599
|
+
await DBOS.runStep(async () => {
|
|
600
|
+
unwrapOrThrow(await markFailed(deps.pool, input.assessmentId, {
|
|
601
|
+
kind: "unexpected-throw",
|
|
602
|
+
message: "terminal block reached without classified outcome",
|
|
603
|
+
}));
|
|
604
|
+
recordTerminal("failed");
|
|
605
|
+
}, { name: "ta-terminal-failed-uncategorized" });
|
|
575
606
|
recordTerminalReason("unexpected-throw");
|
|
576
607
|
return {
|
|
577
608
|
assessmentId: input.assessmentId,
|
|
@@ -28,6 +28,7 @@
|
|
|
28
28
|
*/
|
|
29
29
|
import { DBOS, Error as DBOSErrors } from "@dbos-inc/dbos-sdk";
|
|
30
30
|
import { insertStepExecution, updateStepExecution } from "../state/index.js";
|
|
31
|
+
import { recordStepCompleted } from "../lib/metrics.js";
|
|
31
32
|
import { createNoopLogger } from "../lib/console-logger.js";
|
|
32
33
|
import { unwrapOrThrow } from "../lib/result.js";
|
|
33
34
|
import { isBudgetExceeded } from "../loop/budget-exceeded.js";
|
|
@@ -286,6 +287,7 @@ export async function runSandboxStepBody(input, deps) {
|
|
|
286
287
|
attempts: 1,
|
|
287
288
|
lastErrorClass: errorClass,
|
|
288
289
|
}));
|
|
290
|
+
recordStepCompleted({ agentId: input.agentId, status: "failed", durationMs });
|
|
289
291
|
}, {
|
|
290
292
|
name: errorClass === "lineage_attestation" ? "mark-failed-attestation" : "mark-failed",
|
|
291
293
|
});
|
|
@@ -468,6 +470,7 @@ export async function runSandboxStepBody(input, deps) {
|
|
|
468
470
|
finishReason,
|
|
469
471
|
hitMaxSteps,
|
|
470
472
|
}));
|
|
473
|
+
recordStepCompleted({ agentId: input.agentId, status: "failed", durationMs });
|
|
471
474
|
}, { name: "mark-blocked" });
|
|
472
475
|
await safeEmit({
|
|
473
476
|
type: "data-step-blocked",
|
|
@@ -577,7 +580,9 @@ export async function runSandboxStepBody(input, deps) {
|
|
|
577
580
|
await safeRun(logger, () => vectorIndexStepOutputs(deps, postCtx, postArtifacts), "post-step.vector-index");
|
|
578
581
|
// (9) teardown — destroy sandbox + clear sandbox_ref.
|
|
579
582
|
await DBOS.runStep(() => deps.sandboxClient.teardown(sandbox), { name: "sandbox.teardown" });
|
|
580
|
-
// (10) mark-terminal + emit.
|
|
583
|
+
// (10) mark-terminal + emit. The outcome metric is recorded inside the same
|
|
584
|
+
// step as the ledger row: a replayed body reads the cached step and records
|
|
585
|
+
// nothing again.
|
|
581
586
|
const durationMs = (await DBOS.now()) - startedAt;
|
|
582
587
|
await DBOS.runStep(async () => {
|
|
583
588
|
unwrapOrThrow(await updateStepExecution(deps.pool, input.runId, input.stepId, {
|
|
@@ -588,6 +593,7 @@ export async function runSandboxStepBody(input, deps) {
|
|
|
588
593
|
finishReason,
|
|
589
594
|
hitMaxSteps,
|
|
590
595
|
}));
|
|
596
|
+
recordStepCompleted({ agentId: input.agentId, status: "completed", durationMs });
|
|
591
597
|
}, { name: "mark-complete" });
|
|
592
598
|
// Terminal step-detail parts. file-tree + summary + output each get a
|
|
593
599
|
// stable id per step so the run-stream fold + client both treat them as
|
package/package.json
CHANGED