@inflexa-ai/harness 0.28.1 → 0.29.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/data-profile-orientation.d.ts +52 -27
- package/dist/app/data-profile-orientation.js +331 -92
- package/dist/app/data-profile-view.d.ts +70 -0
- package/dist/app/data-profile-view.js +123 -0
- package/dist/app/synthesize-run.js +1 -1
- package/dist/contracts/data-profile.d.ts +276 -25
- package/dist/contracts/index.d.ts +3 -1
- package/dist/contracts/index.js +1 -0
- package/dist/contracts/profile-vocabulary.d.ts +103 -0
- package/dist/contracts/profile-vocabulary.js +449 -0
- package/dist/contracts/target-dossier.d.ts +21 -21
- package/dist/execution/input-signature.d.ts +16 -3
- package/dist/execution/input-signature.js +0 -0
- package/dist/execution/post-step-pipeline.js +1 -1
- package/dist/execution/staged-input.d.ts +4 -4
- package/dist/index.d.ts +5 -1
- package/dist/index.js +7 -0
- package/dist/input-scan/clustering.d.ts +63 -0
- package/dist/input-scan/clustering.js +208 -0
- package/dist/input-scan/companions.d.ts +30 -0
- package/dist/input-scan/companions.js +138 -0
- package/dist/input-scan/decoder/container_readout.py +171 -0
- package/dist/input-scan/detect-sets.d.ts +24 -0
- package/dist/input-scan/detect-sets.js +279 -0
- package/dist/input-scan/enrich.d.ts +62 -19
- package/dist/input-scan/enrich.js +132 -187
- package/dist/input-scan/formats.d.ts +13 -5
- package/dist/input-scan/formats.js +13 -5
- package/dist/input-scan/index.d.ts +10 -4
- package/dist/input-scan/index.js +7 -4
- package/dist/input-scan/markers.d.ts +21 -0
- package/dist/input-scan/markers.js +89 -0
- package/dist/input-scan/menu.d.ts +127 -0
- package/dist/input-scan/menu.js +0 -0
- package/dist/input-scan/quarantine.d.ts +17 -0
- package/dist/input-scan/quarantine.js +69 -0
- package/dist/input-scan/readout-budget.d.ts +32 -0
- package/dist/input-scan/readout-budget.js +61 -0
- package/dist/input-scan/readout.d.ts +33 -0
- package/dist/input-scan/readout.js +336 -0
- package/dist/input-scan/scan.d.ts +5 -4
- package/dist/input-scan/scan.js +5 -5
- package/dist/input-scan/set-types.d.ts +209 -0
- package/dist/input-scan/set-types.js +15 -0
- package/dist/input-scan/templates.d.ts +93 -0
- package/dist/input-scan/templates.js +369 -0
- package/dist/input-scan/tokens.d.ts +94 -0
- package/dist/input-scan/tokens.js +267 -0
- package/dist/input-scan/tuning.d.ts +55 -0
- package/dist/input-scan/tuning.js +55 -0
- package/dist/input-scan/types.d.ts +2 -2
- package/dist/prompts/conversation.d.ts +1 -1
- package/dist/prompts/conversation.js +8 -6
- package/dist/prompts/report-session.d.ts +1 -1
- package/dist/prompts/report-session.js +3 -2
- package/dist/prompts/sandbox/data-profiler.d.ts +10 -1
- package/dist/prompts/sandbox/data-profiler.js +197 -53
- package/dist/prompts/sandbox-standards.d.ts +1 -1
- package/dist/prompts/sandbox-standards.js +10 -7
- package/dist/schemas/data-profile-schemas.d.ts +943 -80
- package/dist/schemas/data-profile-schemas.js +300 -115
- package/dist/state/data-profile.d.ts +1 -1
- package/dist/state/index.d.ts +1 -1
- package/dist/state/vector-store.d.ts +13 -0
- package/dist/state/vector-store.js +8 -0
- package/dist/tasks/data-profile-absorb.d.ts +78 -0
- package/dist/tasks/data-profile-absorb.js +285 -0
- package/dist/tasks/data-profile-index.d.ts +34 -25
- package/dist/tasks/data-profile-index.js +160 -59
- package/dist/tasks/data-profile-resolve.d.ts +72 -0
- package/dist/tasks/data-profile-resolve.js +639 -0
- package/dist/tasks/data-profile.d.ts +16 -5
- package/dist/tasks/data-profile.js +270 -131
- package/dist/tools/research/inspect-data-profile.d.ts +48 -22
- package/dist/tools/research/inspect-data-profile.js +73 -54
- package/dist/tools/workspace/scan-inputs.d.ts +4 -4
- package/dist/tools/workspace/scan-inputs.js +13 -14
- package/dist/tools/workspace/workspace-search.d.ts +11 -2
- package/dist/tools/workspace/workspace-search.js +44 -16
- package/dist/workflows/target-assessment/phase4-assemble.d.ts +21 -21
- package/package.json +5 -3
- package/dist/input-scan/coverage.d.ts +0 -22
- package/dist/input-scan/coverage.js +0 -85
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Data-profile DBOS workflow — scans the already-staged input tree, runs the
|
|
3
3
|
* data-profiler sandbox agent over the resulting manifest, registers each staged
|
|
4
|
-
* file in cortex_artifacts, and indexes the profile's
|
|
5
|
-
* pgvector store. Inputs are staged under `data/inputs/` by the embedder before the
|
|
4
|
+
* file in cortex_artifacts, and indexes the profile's groups, dimensions, and annotated
|
|
5
|
+
* members into the pgvector store. Inputs are staged under `data/inputs/` by the embedder before the
|
|
6
6
|
* run (see the data-profile-init spec); the body assumes a populated tree and never
|
|
7
7
|
* downloads.
|
|
8
8
|
*
|
|
@@ -34,12 +34,16 @@ import { estimateDataProfileResources } from "../sandbox/estimate-data-profile-r
|
|
|
34
34
|
import { generateExecutionId } from "../sandbox/execution-id.js";
|
|
35
35
|
import { mintSandboxIdentity } from "../sandbox/identity.js";
|
|
36
36
|
import { createVectorStore } from "../state/vector-store.js";
|
|
37
|
-
import {
|
|
37
|
+
import { ProfileSubmissionSchema } from "../schemas/data-profile-schemas.js";
|
|
38
38
|
import { computeInputSignature } from "../execution/input-signature.js";
|
|
39
|
-
import {
|
|
40
|
-
import {
|
|
41
|
-
import {
|
|
42
|
-
import {
|
|
39
|
+
import { readHeaders } from "../input-scan/enrich.js";
|
|
40
|
+
import { detectSets } from "../input-scan/detect-sets.js";
|
|
41
|
+
import { buildSetMenu, renderSetMenu } from "../input-scan/menu.js";
|
|
42
|
+
import { readoutTargets } from "../input-scan/readout-budget.js";
|
|
43
|
+
import { scanInputTree } from "../input-scan/scan.js";
|
|
44
|
+
import { PROFILE_INDEX_TYPES, buildProfileIndexEntries } from "./data-profile-index.js";
|
|
45
|
+
import { absorbRecipe, renderAbsorbDelta } from "./data-profile-absorb.js";
|
|
46
|
+
import { formatResolutionErrors, resolveProfileSubmission } from "./data-profile-resolve.js";
|
|
43
47
|
import { completeDataProfile, failDataProfile, loadDataProfileStatus, loadSeedInputFileIds, recordDataProfileWorkflowId, tryRerunDataProfile, tryStartDataProfile, upsertArtifacts, } from "../state/index.js";
|
|
44
48
|
import { createProfileActivityEmitter } from "./data-profile-activity.js";
|
|
45
49
|
import { ensureSearchIndex, searchIndexName } from "../workspace/search-config.js";
|
|
@@ -56,6 +60,15 @@ const DATA_PROFILE_AGENT_ID = "data-profiler";
|
|
|
56
60
|
const DEFAULT_DEADLINE_MS = 1_200_000;
|
|
57
61
|
/** Index entries embedded and upserted per round trip. */
|
|
58
62
|
const INDEX_BATCH_SIZE = 256;
|
|
63
|
+
/**
|
|
64
|
+
* Submissions the tool accepts: the first, plus one repair.
|
|
65
|
+
*
|
|
66
|
+
* The repair is a FULL resubmit — the agent replaces the whole operation list and nothing
|
|
67
|
+
* is merged into the prior attempt. Past it the resolution stands as it resolved: an
|
|
68
|
+
* operation that still does not resolve produces no group, and its members land in the
|
|
69
|
+
* visible `unclassified` group rather than blocking a profile that gates planning.
|
|
70
|
+
*/
|
|
71
|
+
const MAX_SUBMIT_ROUNDS = 2;
|
|
59
72
|
/** Where the embedder stages this analysis's inputs (see the data-profile-init spec). */
|
|
60
73
|
const STAGED_INPUT_ROOT = "data/inputs";
|
|
61
74
|
/** Build the canonical artifact path for a staged input file. */
|
|
@@ -78,66 +91,44 @@ function inputArtifactPath(f) {
|
|
|
78
91
|
* indistinguishable from a legacy snapshot that predates the field, which is exactly
|
|
79
92
|
* the reading a consumer must already tolerate.
|
|
80
93
|
*/
|
|
81
|
-
export function buildDataProfileResult(
|
|
94
|
+
export function buildDataProfileResult(submission, resolution, stagedInputs, profiledAt) {
|
|
82
95
|
return {
|
|
83
|
-
summary:
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
cols: f.cols,
|
|
91
|
-
tags: f.tags,
|
|
92
|
-
warnings: f.warnings,
|
|
93
|
-
metrics: f.metrics,
|
|
94
|
-
})),
|
|
95
|
-
kinds: profile.kinds.map((k) => ({
|
|
96
|
-
name: k.name,
|
|
97
|
-
memberRepresents: k.memberRepresents,
|
|
98
|
-
description: k.description,
|
|
99
|
-
count: k.count,
|
|
100
|
-
pathPattern: k.pathPattern,
|
|
101
|
-
format: k.format,
|
|
102
|
-
axisLabels: k.axisLabels,
|
|
103
|
-
})),
|
|
104
|
-
axes: profile.axes?.map((a) => ({
|
|
105
|
-
label: a.label,
|
|
106
|
-
cardinality: a.cardinality,
|
|
107
|
-
exampleValues: a.exampleValues,
|
|
108
|
-
description: a.description,
|
|
109
|
-
})),
|
|
96
|
+
summary: submission.analysisSummary,
|
|
97
|
+
groups: withoutMembers(resolution.groups),
|
|
98
|
+
dimensions: [...resolution.dimensions],
|
|
99
|
+
probes: [...resolution.probes],
|
|
100
|
+
partition: resolution.partition,
|
|
101
|
+
recipe: [...resolution.recipe],
|
|
102
|
+
caveats: submission.caveats,
|
|
110
103
|
inputSignature: computeInputSignature(stagedInputs),
|
|
111
|
-
coverage,
|
|
112
104
|
profiledAt,
|
|
113
|
-
domain:
|
|
114
|
-
subtype:
|
|
115
|
-
organism:
|
|
116
|
-
tissue:
|
|
117
|
-
cellType:
|
|
118
|
-
condition:
|
|
119
|
-
accessions:
|
|
120
|
-
experimentalDesign:
|
|
121
|
-
qualityAssessment: profile.qualityAssessment,
|
|
105
|
+
domain: submission.domain,
|
|
106
|
+
subtype: submission.subtype,
|
|
107
|
+
organism: submission.organism,
|
|
108
|
+
tissue: submission.tissue,
|
|
109
|
+
cellType: submission.cellType,
|
|
110
|
+
condition: submission.condition,
|
|
111
|
+
accessions: submission.accessions,
|
|
112
|
+
experimentalDesign: submission.experimentalDesign,
|
|
122
113
|
};
|
|
123
114
|
}
|
|
124
115
|
/**
|
|
125
|
-
*
|
|
116
|
+
* Read one header per set, once a container exists.
|
|
126
117
|
*
|
|
127
|
-
* The walk and the
|
|
128
|
-
*
|
|
129
|
-
*
|
|
130
|
-
*
|
|
131
|
-
*
|
|
118
|
+
* The walk and the set detection run before any sandbox — a profile that absorbs a prior
|
|
119
|
+
* recipe never provisions one. Every prefix-sufficient readout also runs in this process
|
|
120
|
+
* over the workspace read seam; only a footer-indexed container reaches into the sandbox,
|
|
121
|
+
* and only once per set. A readout that fails is logged and dropped: it is enrichment,
|
|
122
|
+
* and a menu without it still carries every structural observation the agent's grouping
|
|
123
|
+
* rests on, so failing the profile over it would trade the whole capability for a nicety.
|
|
132
124
|
*/
|
|
133
|
-
async function
|
|
134
|
-
const { session, deps, analysisId, sandbox, execId, deadlineMs, logger } = args;
|
|
135
|
-
const scan = await scanInputTree({ session, fs: deps.workspaceFs, root: STAGED_INPUT_ROOT });
|
|
136
|
-
if (scan.manifest.shapes.length === 0)
|
|
137
|
-
return scan;
|
|
125
|
+
async function readSetHeaders(args) {
|
|
126
|
+
const { session, deps, analysisId, detected, sandbox, execId, deadlineMs, logger } = args;
|
|
138
127
|
try {
|
|
139
|
-
|
|
140
|
-
|
|
128
|
+
return await readHeaders({
|
|
129
|
+
targets: readoutTargets(detected),
|
|
130
|
+
session,
|
|
131
|
+
fs: deps.workspaceFs,
|
|
141
132
|
sandboxClient: deps.sandboxClient,
|
|
142
133
|
sandbox,
|
|
143
134
|
mountRoot: `/${analysisId}`,
|
|
@@ -145,12 +136,112 @@ async function runInputScan(args) {
|
|
|
145
136
|
deadlineMs,
|
|
146
137
|
emit: async () => { },
|
|
147
138
|
});
|
|
148
|
-
return { ...scan, manifest: { ...scan.manifest, shapes } };
|
|
149
139
|
}
|
|
150
140
|
catch (err) {
|
|
151
141
|
logger.warn("input-scan header readout failed (non-fatal)", logger.errorFields(err));
|
|
152
|
-
return
|
|
142
|
+
return new Map();
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* The submission a tree with nothing to group would have produced.
|
|
147
|
+
*
|
|
148
|
+
* Authored here rather than by a model: with no kept file there is no judgement to make,
|
|
149
|
+
* and the fields below say exactly that. Resolution turns it into the census — zero
|
|
150
|
+
* groups against a full quarantine accounting — which is the honest record of such a tree
|
|
151
|
+
* and is what keeps `inspect_data_profile` from reporting the analysis unprofiled.
|
|
152
|
+
*/
|
|
153
|
+
function emptySubmission() {
|
|
154
|
+
return {
|
|
155
|
+
operations: [],
|
|
156
|
+
analysisSummary: "The input scan kept no files: every staged file was quarantined as OS junk, an editor temp, or a partial download. " +
|
|
157
|
+
"There is no dataset structure to describe. See the quarantine accounting for what was set aside and why.",
|
|
158
|
+
domain: "",
|
|
159
|
+
organism: null,
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
/** The membership the persisted record deliberately does not carry. */
|
|
163
|
+
function withoutMembers(groups) {
|
|
164
|
+
return groups.map(({ memberPaths, ...group }) => {
|
|
165
|
+
void memberPaths;
|
|
166
|
+
return group;
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
/**
|
|
170
|
+
* The prior profile re-stamped over a deterministic absorb.
|
|
171
|
+
*
|
|
172
|
+
* Membership, counts, the partition, the recipe, and the signature come from the replay;
|
|
173
|
+
* everything the agent authored — the summary, the identity fields, the dimensions, the
|
|
174
|
+
* probes, the caveats — is the profile's existing finding and is carried verbatim. An
|
|
175
|
+
* absorb re-derives arithmetic, and re-deciding judgement without a model is not something
|
|
176
|
+
* this path is entitled to do.
|
|
177
|
+
*/
|
|
178
|
+
export function buildAbsorbedResult(prior, resolution, stagedInputs, profiledAt) {
|
|
179
|
+
return {
|
|
180
|
+
...prior,
|
|
181
|
+
groups: withoutMembers(resolution.groups),
|
|
182
|
+
// Re-resolved, not carried: a slot observation's cardinality and values are computed
|
|
183
|
+
// from the scan, and the scan just changed. The labels and the reasoning are the
|
|
184
|
+
// profile's existing finding; the numbers under them are this replay's.
|
|
185
|
+
dimensions: [...resolution.dimensions],
|
|
186
|
+
partition: resolution.partition,
|
|
187
|
+
recipe: [...resolution.recipe],
|
|
188
|
+
inputSignature: computeInputSignature(stagedInputs),
|
|
189
|
+
profiledAt,
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
/**
|
|
193
|
+
* Build and upsert the profile's vector index — one entry per group, one per dimension,
|
|
194
|
+
* one per annotated member, none per unannotated file.
|
|
195
|
+
*
|
|
196
|
+
* A pure projection of the persisted profile crossed with the scan, so an absorb rebuilds
|
|
197
|
+
* it from the same two things the agent path does. Embedding is not a model judgement:
|
|
198
|
+
* leaving the index behind after an absorb would make the newly absorbed members
|
|
199
|
+
* unsearchable while the record said they were profiled.
|
|
200
|
+
*/
|
|
201
|
+
async function indexProfile(args) {
|
|
202
|
+
const { deps, analysisId, session, record, scan, logger } = args;
|
|
203
|
+
await ensureSearchIndex(deps.pool, analysisId, deps.embedding.dimensions);
|
|
204
|
+
const vectorStore = createVectorStore(deps.pool);
|
|
205
|
+
const indexName = searchIndexName(analysisId);
|
|
206
|
+
const entries = buildProfileIndexEntries({ analysisId, result: record, scan });
|
|
207
|
+
// Replace, don't merge. The tiers are a projection of THIS profile, and an upsert keyed
|
|
208
|
+
// by entry id leaves a renamed group, a dropped dimension, and a de-annotated member
|
|
209
|
+
// behind — searchable, and describing a profile that no longer exists.
|
|
210
|
+
unwrapOrThrow(await vectorStore.deleteByType({ indexName, types: [...PROFILE_INDEX_TYPES] }));
|
|
211
|
+
// Batched: both interfaces already take arrays, and one round trip per entry is
|
|
212
|
+
// what made indexing 12 of the 39 minutes the motivating profile took.
|
|
213
|
+
for (let start = 0; start < entries.length; start += INDEX_BATCH_SIZE) {
|
|
214
|
+
const batch = entries.slice(start, start + INDEX_BATCH_SIZE);
|
|
215
|
+
const vectors = unwrapOrThrow(await deps.embedding.embed(batch.map((entry) => entry.text), session));
|
|
216
|
+
if (vectors.length !== batch.length)
|
|
217
|
+
throw new Error(`data-profile: embedding returned ${vectors.length} vectors for ${batch.length} entries`);
|
|
218
|
+
unwrapOrThrow(await vectorStore.upsert({
|
|
219
|
+
indexName,
|
|
220
|
+
vectors,
|
|
221
|
+
metadata: batch.map((entry) => ({ ...entry.metadata, text: entry.text })),
|
|
222
|
+
ids: batch.map((entry) => entry.id),
|
|
223
|
+
}));
|
|
153
224
|
}
|
|
225
|
+
logger.info("indexed profile", { entries: entries.length, groups: record.groups?.length ?? 0 });
|
|
226
|
+
}
|
|
227
|
+
/**
|
|
228
|
+
* The counters the catalogue's fit against real use is measured from — one structured
|
|
229
|
+
* event per completed profile, emitted whether or not anything went wrong.
|
|
230
|
+
*/
|
|
231
|
+
function logProfileMonitoring(logger, args) {
|
|
232
|
+
const { resolution, dimensions, probes, repairRounds, absorb } = args;
|
|
233
|
+
logger.info("profile monitoring", {
|
|
234
|
+
absorb,
|
|
235
|
+
repairRounds,
|
|
236
|
+
unresolvedOperations: resolution?.errors.length ?? 0,
|
|
237
|
+
contestedFiles: resolution?.contested.length ?? 0,
|
|
238
|
+
unclassifiedFiles: resolution?.partition.unclassifiedFiles ?? 0,
|
|
239
|
+
keptFiles: resolution?.partition.keptFiles ?? 0,
|
|
240
|
+
scanTruncated: resolution?.partition.scanTruncated ?? false,
|
|
241
|
+
otherGroupCategories: (resolution?.groups ?? []).filter((group) => group.category === "other" && !group.unclassified).length,
|
|
242
|
+
otherDimensionCategories: dimensions.filter((dimension) => dimension.category === "other").length,
|
|
243
|
+
probeNotFound: probes.filter((probe) => probe.outcome === "not-found").length,
|
|
244
|
+
});
|
|
154
245
|
}
|
|
155
246
|
/**
|
|
156
247
|
* Register the data-profile workflow with DBOS. Returns the registered
|
|
@@ -199,6 +290,10 @@ export async function runDataProfileBody(input, deps) {
|
|
|
199
290
|
// input. The body never downloads.
|
|
200
291
|
if (stagedInputs.length === 0) {
|
|
201
292
|
logger.warn("no input files staged");
|
|
293
|
+
// One event per completed profile, this one included: the counters are how the
|
|
294
|
+
// catalogue's fit is measured, and a completion that emitted none would make the
|
|
295
|
+
// empty-manifest path invisible to the same monitoring that watches every other.
|
|
296
|
+
logProfileMonitoring(logger, { dimensions: [], probes: [], repairRounds: 0, absorb: "none" });
|
|
202
297
|
if (!unwrapOrThrow(await completeDataProfile(deps.pool, analysisId)))
|
|
203
298
|
logTerminalNoop(logger, analysisId, "completion");
|
|
204
299
|
await deps.runAuthorizer.revoke(authorization, "data-profile-completed");
|
|
@@ -216,7 +311,6 @@ export async function runDataProfileBody(input, deps) {
|
|
|
216
311
|
role: "input",
|
|
217
312
|
fileId: f.fileId,
|
|
218
313
|
})));
|
|
219
|
-
// 3. Run data-profiler sandbox agent
|
|
220
314
|
const executionId = generateExecutionId(DATA_PROFILE_AGENT_ID);
|
|
221
315
|
const workflowId = DBOS.workflowID ?? `${DATA_PROFILE_RUN_LITERAL}:${executionId}`;
|
|
222
316
|
const childSession = forSubAgent(runSession, DATA_PROFILE_AGENT_ID);
|
|
@@ -225,6 +319,69 @@ export async function runDataProfileBody(input, deps) {
|
|
|
225
319
|
// meaningless, so no softer handling is warranted.
|
|
226
320
|
const workspaceRoot = deps.resolveWorkspaceRoot(analysisId);
|
|
227
321
|
const profileWritePrefix = `${workspaceRoot}/runs/${DATA_PROFILE_RUN_LITERAL}/${DATA_PROFILE_STEP_LITERAL}`;
|
|
322
|
+
// 3. The deterministic scan, before any container and before the agent's first turn.
|
|
323
|
+
// It is always needed and its result does not depend on agent judgement, so spending
|
|
324
|
+
// an agent turn to request it would be waste — and a briefing carrying one line per
|
|
325
|
+
// input file consumes context that carries no structure. It runs BEFORE the sandbox
|
|
326
|
+
// because the absorb below may complete the profile without ever needing one.
|
|
327
|
+
await activity.scanning();
|
|
328
|
+
const scan = await scanInputTree({ session: childSession, fs: deps.workspaceFs, root: STAGED_INPUT_ROOT });
|
|
329
|
+
const detected = detectSets(scan.files);
|
|
330
|
+
const menu = buildSetMenu(detected, { truncated: scan.manifest.truncated });
|
|
331
|
+
logger.info("input scan complete", {
|
|
332
|
+
files: detected.fileCount,
|
|
333
|
+
kept: detected.keptFileCount,
|
|
334
|
+
sets: detected.sets.length,
|
|
335
|
+
listedSets: menu.sets.length,
|
|
336
|
+
leftovers: detected.leftovers.memberCount,
|
|
337
|
+
quarantined: detected.quarantine.count,
|
|
338
|
+
truncated: scan.manifest.truncated,
|
|
339
|
+
});
|
|
340
|
+
// 3a. A tree the scan kept nothing of. There is no menu to author against and no file
|
|
341
|
+
// to describe, so a sandbox and a model pass would produce an empty submission at the
|
|
342
|
+
// cost of a container. The quarantine accounting is the whole finding, and it is a
|
|
343
|
+
// real one: it names every file and why each was set aside.
|
|
344
|
+
if (detected.keptFileCount === 0) {
|
|
345
|
+
logger.warn("every scanned file was quarantined", { scanned: detected.fileCount, quarantined: detected.quarantine.count });
|
|
346
|
+
const resolution = resolveProfileSubmission(emptySubmission(), detected, menu, { finalRound: true });
|
|
347
|
+
const record = buildDataProfileResult(emptySubmission(), resolution, stagedInputs, new Date().toISOString());
|
|
348
|
+
await activity.indexing();
|
|
349
|
+
await indexProfile({ deps, analysisId, session: runSession, record, scan, logger });
|
|
350
|
+
logProfileMonitoring(logger, { resolution, dimensions: [], probes: [], repairRounds: 0, absorb: "none" });
|
|
351
|
+
if (!unwrapOrThrow(await completeDataProfile(deps.pool, analysisId, record)))
|
|
352
|
+
logTerminalNoop(logger, analysisId, "completion");
|
|
353
|
+
await deps.runAuthorizer.revoke(authorization, "data-profile-completed");
|
|
354
|
+
await activity.complete();
|
|
355
|
+
return;
|
|
356
|
+
}
|
|
357
|
+
// 3b. Absorption — replay a prior profile's recipe against the fresh scan. A recipe
|
|
358
|
+
// covering every kept file completes the profile here: an added directory of files
|
|
359
|
+
// that instantiate templates the profile already describes is arithmetic, and paying
|
|
360
|
+
// a container and a model pass for it is what this pre-step exists to remove.
|
|
361
|
+
const prior = unwrapOrThrow(await loadDataProfileStatus(deps.pool, analysisId))?.result ?? null;
|
|
362
|
+
const absorb = absorbRecipe(prior, detected, menu);
|
|
363
|
+
if (absorb.kind === "stranded") {
|
|
364
|
+
logger.warn("prior recipe stranded — re-profiling from scratch", { reason: absorb.reason });
|
|
365
|
+
}
|
|
366
|
+
if (absorb.kind === "full" && prior) {
|
|
367
|
+
logger.info("absorbed the prior recipe", { groups: absorb.resolution.groups.length, keptFiles: absorb.resolution.partition.keptFiles });
|
|
368
|
+
await activity.indexing();
|
|
369
|
+
const absorbed = buildAbsorbedResult(prior, absorb.resolution, stagedInputs, new Date().toISOString());
|
|
370
|
+
await indexProfile({ deps, analysisId, session: runSession, record: absorbed, scan, logger });
|
|
371
|
+
logProfileMonitoring(logger, {
|
|
372
|
+
resolution: absorb.resolution,
|
|
373
|
+
dimensions: absorbed.dimensions ?? [],
|
|
374
|
+
probes: absorbed.probes ?? [],
|
|
375
|
+
repairRounds: 0,
|
|
376
|
+
absorb: absorb.kind,
|
|
377
|
+
});
|
|
378
|
+
if (!unwrapOrThrow(await completeDataProfile(deps.pool, analysisId, absorbed)))
|
|
379
|
+
logTerminalNoop(logger, analysisId, "completion");
|
|
380
|
+
await deps.runAuthorizer.revoke(authorization, "data-profile-completed");
|
|
381
|
+
await activity.complete();
|
|
382
|
+
return;
|
|
383
|
+
}
|
|
384
|
+
// 4. Run the data-profiler sandbox agent.
|
|
228
385
|
logger.info("starting sandbox", { executionId });
|
|
229
386
|
// Reported BEFORE the container exists, deliberately: provisioning is the longest single
|
|
230
387
|
// operation in a profile and it precedes the agent entirely, so a body that reported only
|
|
@@ -260,27 +417,17 @@ export async function runDataProfileBody(input, deps) {
|
|
|
260
417
|
// which captures its step deadline the same way).
|
|
261
418
|
const deadlineAbs = (await DBOS.now()) + DEFAULT_DEADLINE_MS;
|
|
262
419
|
const nextFunctionId = makeNextFunctionId();
|
|
263
|
-
|
|
264
|
-
// and its result does not depend on agent judgement, so spending an agent turn
|
|
265
|
-
// to request it would be waste — and a briefing carrying one line per input
|
|
266
|
-
// file consumes context that carries no structure (~270 KB of bare paths for
|
|
267
|
-
// the tree that motivated this, against ~2 KB of shapes).
|
|
268
|
-
await activity.scanning();
|
|
269
|
-
const scan = await runInputScan({
|
|
420
|
+
const headers = await readSetHeaders({
|
|
270
421
|
session: childSession,
|
|
271
422
|
deps,
|
|
272
423
|
analysisId,
|
|
424
|
+
detected,
|
|
273
425
|
sandbox,
|
|
274
426
|
execId: `${workflowId}:${DATA_PROFILE_STEP_LITERAL}:${nextFunctionId()}`,
|
|
275
427
|
deadlineMs: deadlineAbs,
|
|
276
428
|
logger,
|
|
277
429
|
});
|
|
278
|
-
|
|
279
|
-
files: scan.manifest.fileCount,
|
|
280
|
-
shapes: scan.manifest.shapes.length,
|
|
281
|
-
unstructured: scan.manifest.unstructured.count,
|
|
282
|
-
truncated: scan.manifest.truncated,
|
|
283
|
-
});
|
|
430
|
+
const briefingMenu = { ...menu, headers };
|
|
284
431
|
// The profiler runs outside `executeAnalysis`, so no scheduler composes a
|
|
285
432
|
// briefing for it — this prompt IS its briefing, and the sandbox system
|
|
286
433
|
// prompt names no paths (it is static per agent type, for the prompt cache).
|
|
@@ -296,11 +443,16 @@ export async function runDataProfileBody(input, deps) {
|
|
|
296
443
|
``,
|
|
297
444
|
`CRITICAL: File and directory paths may contain spaces. You MUST always double-quote paths in shell commands (e.g. head "/${analysisId}/data/inputs/My Folder/file.csv"). Unquoted paths with spaces will silently break commands.`,
|
|
298
445
|
``,
|
|
299
|
-
|
|
446
|
+
renderSetMenu(briefingMenu, STAGED_INPUT_ROOT),
|
|
300
447
|
``,
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
448
|
+
...(absorb.kind === "partial"
|
|
449
|
+
? [renderAbsorbDelta(absorb), ``]
|
|
450
|
+
: [
|
|
451
|
+
`Author the dataset's groups as operations on this menu: \`use\` a set, \`split\` one by a slot or by a`,
|
|
452
|
+
`value mapping, \`merge\` several, or \`group\` explicit paths the scan left over. Membership and counts`,
|
|
453
|
+
`are computed from your operations — you do not state them. Inspect ONE example file per group where a`,
|
|
454
|
+
`description needs content the scan did not capture, then call \`submit_profile\` once.`,
|
|
455
|
+
]),
|
|
304
456
|
].join("\n");
|
|
305
457
|
const sandboxAgentDeps = {
|
|
306
458
|
provider: deps.provider,
|
|
@@ -328,15 +480,34 @@ export async function runDataProfileBody(input, deps) {
|
|
|
328
480
|
deadlineMs: () => deadlineAbs,
|
|
329
481
|
},
|
|
330
482
|
};
|
|
331
|
-
let
|
|
483
|
+
let accepted = null;
|
|
484
|
+
let rounds = 0;
|
|
332
485
|
const submitProfileTool = defineTool({
|
|
333
486
|
id: "submit_profile",
|
|
334
|
-
description: "Submit the profiling results. Call this tool once after completing
|
|
335
|
-
|
|
487
|
+
description: "Submit the profiling results. Call this tool once after completing all profiling work — it resolves your " +
|
|
488
|
+
"operations against the scan, computes every group's membership and counts, and records the profile. " +
|
|
489
|
+
"A submission whose operations do not resolve, or that leaves kept files unclaimed, comes back once with " +
|
|
490
|
+
"the errors; the repair is a FULL resubmit of the whole operation list.",
|
|
491
|
+
inputSchema: ProfileSubmissionSchema,
|
|
336
492
|
describeCall: "none",
|
|
337
493
|
execute: async (input) => {
|
|
338
|
-
|
|
339
|
-
|
|
494
|
+
rounds++;
|
|
495
|
+
// The last round resolves as final: nothing that follows can repair an
|
|
496
|
+
// overlap, so a contested file sweeps rather than being awarded to whichever
|
|
497
|
+
// operation the agent happened to write first.
|
|
498
|
+
const finalRound = rounds >= MAX_SUBMIT_ROUNDS;
|
|
499
|
+
const resolution = resolveProfileSubmission(input, detected, briefingMenu, { finalRound });
|
|
500
|
+
if (!finalRound && (resolution.errors.length > 0 || resolution.unclaimed.length > 0)) {
|
|
501
|
+
return ok({ status: "needs_repair", detail: formatResolutionErrors(resolution) });
|
|
502
|
+
}
|
|
503
|
+
accepted = { submission: input, resolution };
|
|
504
|
+
return ok({
|
|
505
|
+
status: "accepted",
|
|
506
|
+
groups: resolution.groups.length,
|
|
507
|
+
keptFiles: resolution.partition.keptFiles,
|
|
508
|
+
unclassifiedFiles: resolution.partition.unclassifiedFiles,
|
|
509
|
+
unresolvedOperations: resolution.errors.length,
|
|
510
|
+
});
|
|
340
511
|
},
|
|
341
512
|
});
|
|
342
513
|
const baseAgent = createDataProfilerAgent(sandboxAgentDeps);
|
|
@@ -364,67 +535,35 @@ export async function runDataProfileBody(input, deps) {
|
|
|
364
535
|
await activity.forTool(event.name, event.input, resolveDetail);
|
|
365
536
|
},
|
|
366
537
|
runStep: durableStep,
|
|
367
|
-
resolved: () =>
|
|
538
|
+
resolved: () => accepted !== null,
|
|
368
539
|
usageRecorder: deps.usageRecorder,
|
|
369
540
|
}, {
|
|
370
541
|
tools: [submitProfileTool],
|
|
371
542
|
nudge: "You stopped without calling submit_profile, so no profile was " +
|
|
372
|
-
"recorded. Call submit_profile now with
|
|
373
|
-
"
|
|
374
|
-
"
|
|
543
|
+
"recorded. Call submit_profile now with your operations on the menu, " +
|
|
544
|
+
"what each resulting group means, and the dimensions you saw with " +
|
|
545
|
+
"their observations — base it on the scan and the work you already did.",
|
|
375
546
|
});
|
|
376
|
-
if (!
|
|
547
|
+
if (!accepted) {
|
|
377
548
|
throw new Error("Data profiling failed: agent did not call submit_profile");
|
|
378
549
|
}
|
|
379
|
-
const
|
|
380
|
-
//
|
|
550
|
+
const { submission, resolution } = accepted;
|
|
551
|
+
// 5. Index into vector store — one entry per group, one per dimension, one per annotated member.
|
|
381
552
|
//
|
|
382
553
|
// Reported as `indexing`, not `persisting`: the contract defines `persisting` as step
|
|
383
554
|
// bytes uploading to an artifact store, and a profile uploads nothing — its durable
|
|
384
555
|
// products are this vector index and the ledger row.
|
|
385
556
|
await activity.indexing();
|
|
386
|
-
const profileRecord = buildDataProfileResult(
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
const entries = buildProfileIndexEntries({
|
|
395
|
-
analysisId,
|
|
396
|
-
kinds: profileRecord.kinds ?? [],
|
|
397
|
-
...(profileRecord.axes ? { axes: profileRecord.axes } : {}),
|
|
398
|
-
scan,
|
|
399
|
-
files: profileRecord.files,
|
|
557
|
+
const profileRecord = buildDataProfileResult(submission, resolution, stagedInputs, new Date().toISOString());
|
|
558
|
+
await indexProfile({ deps, analysisId, session: runSession, record: profileRecord, scan, logger });
|
|
559
|
+
logProfileMonitoring(logger, {
|
|
560
|
+
resolution,
|
|
561
|
+
dimensions: resolution.dimensions,
|
|
562
|
+
probes: resolution.probes,
|
|
563
|
+
repairRounds: rounds - 1,
|
|
564
|
+
absorb: absorb.kind,
|
|
400
565
|
});
|
|
401
|
-
//
|
|
402
|
-
// what made indexing 12 of the 39 minutes the motivating profile took.
|
|
403
|
-
for (let start = 0; start < entries.length; start += INDEX_BATCH_SIZE) {
|
|
404
|
-
const batch = entries.slice(start, start + INDEX_BATCH_SIZE);
|
|
405
|
-
const vectors = unwrapOrThrow(await deps.embedding.embed(batch.map((entry) => entry.text), runSession));
|
|
406
|
-
if (vectors.length !== batch.length)
|
|
407
|
-
throw new Error(`data-profile: embedding returned ${vectors.length} vectors for ${batch.length} entries`);
|
|
408
|
-
unwrapOrThrow(await vectorStore.upsert({
|
|
409
|
-
indexName,
|
|
410
|
-
vectors,
|
|
411
|
-
metadata: batch.map((entry) => ({ ...entry.metadata, text: entry.text })),
|
|
412
|
-
ids: batch.map((entry) => entry.id),
|
|
413
|
-
}));
|
|
414
|
-
}
|
|
415
|
-
logger.info("indexed profile", {
|
|
416
|
-
entries: entries.length,
|
|
417
|
-
kinds: profileRecord.kinds?.length ?? 0,
|
|
418
|
-
stagedCount: stagedInputs.length,
|
|
419
|
-
});
|
|
420
|
-
if (profileRecord.coverage && profileRecord.coverage.unmatched > 0) {
|
|
421
|
-
logger.warn("profile kinds do not cover the scanned tree", {
|
|
422
|
-
matched: profileRecord.coverage.matched,
|
|
423
|
-
unmatched: profileRecord.coverage.unmatched,
|
|
424
|
-
total: profileRecord.coverage.total,
|
|
425
|
-
});
|
|
426
|
-
}
|
|
427
|
-
// 5. Complete — store the FULL profiler finding plus the input signature for
|
|
566
|
+
// 6. Complete — store the FULL profiler finding plus the input signature for
|
|
428
567
|
// staleness detection. The scratch tree is gone; this row is all that survives.
|
|
429
568
|
if (!unwrapOrThrow(await completeDataProfile(deps.pool, analysisId, profileRecord))) {
|
|
430
569
|
logTerminalNoop(logger, analysisId, "completion");
|
|
@@ -8,13 +8,16 @@
|
|
|
8
8
|
* **There is no data-profile file.** The profiler's `runs/data-profile/` scratch tree
|
|
9
9
|
* is deleted when profiling completes; the profile's only durable home is the
|
|
10
10
|
* `cortex_analysis_state` row this tool reads. That makes this the single authoritative
|
|
11
|
-
* source for dataset facts
|
|
12
|
-
*
|
|
13
|
-
*
|
|
11
|
+
* source for dataset facts — the identity fields, the resolved groups with their slots,
|
|
12
|
+
* the dimensions with their observations, and the partition census — and it is why the
|
|
13
|
+
* record is served in full rather than summarized: an agent that cannot pull a fact here
|
|
14
|
+
* has no fallback but to re-derive it from the raw bytes.
|
|
14
15
|
*
|
|
15
|
-
* Bounded by construction. Like `inspect_run`, the
|
|
16
|
-
*
|
|
17
|
-
*
|
|
16
|
+
* Bounded by construction. Like `inspect_run`, the member scope is explicitly paged and
|
|
17
|
+
* always reports `total` and `hasMore`: an elided tail is a fact the model can see and
|
|
18
|
+
* act on, never a silent truncation. The annotated-member count and the dataset's file
|
|
19
|
+
* count are reported as two distinct figures, because a profile annotating eight members
|
|
20
|
+
* of several thousand files is correct and a single number would read as a tiny dataset.
|
|
18
21
|
*
|
|
19
22
|
* Lifecycle states are data variants, not errors (the `defineTool` contract): a missing,
|
|
20
23
|
* in-flight, failed, or stale profile is an ordinary, expected outcome the model must be
|
|
@@ -50,45 +53,68 @@ interface OverviewOutput extends ProfileEnvelope {
|
|
|
50
53
|
readonly cellType?: string | null;
|
|
51
54
|
readonly condition?: string | null;
|
|
52
55
|
readonly experimentalDesign?: string;
|
|
53
|
-
|
|
56
|
+
/** Dataset-wide findings the agent wrote, from whichever field the snapshot carries them in. */
|
|
57
|
+
readonly caveats?: string[];
|
|
54
58
|
readonly accessions?: string[];
|
|
55
|
-
/** How many
|
|
59
|
+
/** How many member records `scope:"files"` would page through — NOT the dataset's size. */
|
|
56
60
|
readonly describedFileCount: number;
|
|
57
61
|
/**
|
|
58
62
|
* How many files the dataset holds, or `null` when the snapshot cannot say (one
|
|
59
|
-
* written before
|
|
60
|
-
* `describedFileCount` because a profile describing eight
|
|
61
|
-
* is correct under this capability, and a single count would read as a dataset
|
|
62
|
-
* eight files.
|
|
63
|
+
* written before any structure record existed). Reported separately from
|
|
64
|
+
* `describedFileCount` because a profile describing eight members of several thousand
|
|
65
|
+
* files is correct under this capability, and a single count would read as a dataset
|
|
66
|
+
* of eight files.
|
|
63
67
|
*/
|
|
64
68
|
readonly datasetFileCount: number | null;
|
|
65
|
-
/** How many
|
|
66
|
-
readonly
|
|
69
|
+
/** How many groups `scope:"groups"` would return; `null` on a snapshot that carries no structure. */
|
|
70
|
+
readonly groupCount: number | null;
|
|
67
71
|
/** Present when the two counts differ — says where the rest of the dataset is described. */
|
|
68
72
|
readonly structureNote?: string;
|
|
73
|
+
/**
|
|
74
|
+
* The census, on a snapshot resolved under the partition: kept files, per-group
|
|
75
|
+
* counts, the unclassified sweep, and the quarantine with its reasons.
|
|
76
|
+
*/
|
|
77
|
+
readonly partition?: DataProfileResult["partition"];
|
|
69
78
|
readonly coverage?: DataProfileResult["coverage"];
|
|
70
79
|
}
|
|
71
80
|
/**
|
|
72
|
-
* The dataset's structure. A snapshot
|
|
73
|
-
*
|
|
74
|
-
*
|
|
81
|
+
* The dataset's structure. A snapshot carrying none reports `available: false` rather
|
|
82
|
+
* than an empty list: "this profile predates the structure" and "this dataset has no
|
|
83
|
+
* structure" are different facts, and only one of them is true.
|
|
84
|
+
*
|
|
85
|
+
* A snapshot written under the previous model is served through `kinds`/`axes`, labelled
|
|
86
|
+
* — the structure exists, and an agent must not be told the dataset has none.
|
|
75
87
|
*/
|
|
76
|
-
interface
|
|
77
|
-
readonly scope: "
|
|
88
|
+
interface GroupsOutput extends ProfileEnvelope {
|
|
89
|
+
readonly scope: "groups";
|
|
78
90
|
readonly available: boolean;
|
|
91
|
+
/** Authored under the previous model — `kinds`/`axes` rather than resolved groups. */
|
|
92
|
+
readonly legacy?: boolean;
|
|
93
|
+
readonly groups?: DataProfileResult["groups"];
|
|
94
|
+
readonly dimensions?: DataProfileResult["dimensions"];
|
|
95
|
+
readonly probes?: DataProfileResult["probes"];
|
|
96
|
+
readonly partition?: DataProfileResult["partition"];
|
|
97
|
+
readonly recipe?: DataProfileResult["recipe"];
|
|
79
98
|
readonly kinds?: DataProfileResult["kinds"];
|
|
80
99
|
readonly axes?: DataProfileResult["axes"];
|
|
81
100
|
readonly coverage?: DataProfileResult["coverage"];
|
|
82
101
|
readonly message?: string;
|
|
83
102
|
}
|
|
103
|
+
/**
|
|
104
|
+
* The members the profile describes individually — annotated members on a resolved
|
|
105
|
+
* snapshot, the notable-file records on an older one. Never the dataset: `datasetFileCount`
|
|
106
|
+
* on the overview is what says how big that is.
|
|
107
|
+
*/
|
|
84
108
|
interface FilesOutput extends ProfileEnvelope {
|
|
85
109
|
readonly scope: "files";
|
|
86
110
|
readonly page: number;
|
|
87
111
|
readonly pageSize: number;
|
|
88
|
-
/** Every
|
|
112
|
+
/** Every member record the profile holds — not just this page. */
|
|
89
113
|
readonly total: number;
|
|
90
114
|
/** True when records remain past this page. Truncation is always stated, never silent. */
|
|
91
115
|
readonly hasMore: boolean;
|
|
116
|
+
/** How many files the dataset holds, so a page of 20 never reads as the whole of it. */
|
|
117
|
+
readonly datasetFileCount: number | null;
|
|
92
118
|
readonly files: DataProfileFile[];
|
|
93
119
|
}
|
|
94
120
|
/**
|
|
@@ -133,9 +159,9 @@ interface FailedOutput {
|
|
|
133
159
|
readonly failedAt: string | null;
|
|
134
160
|
readonly message: string;
|
|
135
161
|
}
|
|
136
|
-
export type InspectDataProfileOutput = OverviewOutput |
|
|
162
|
+
export type InspectDataProfileOutput = OverviewOutput | GroupsOutput | FilesOutput | AbsentOutput | PendingOutput | FailedOutput;
|
|
137
163
|
export declare function createInspectDataProfileTool(pool: Pool): import("../define-tool.js").Tool<{
|
|
138
|
-
scope?: "overview" | "
|
|
164
|
+
scope?: "overview" | "groups" | "files" | undefined;
|
|
139
165
|
page?: number | undefined;
|
|
140
166
|
pageSize?: number | undefined;
|
|
141
167
|
}, InspectDataProfileOutput>;
|