@inflexa-ai/harness 0.24.0 → 0.25.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/dist/agents/sandbox/__fixtures__/deps.js +6 -0
  2. package/dist/agents/sandbox/data-profiler.js +5 -4
  3. package/dist/agents/sandbox/shared.js +11 -0
  4. package/dist/agents/sandbox/types.d.ts +1 -1
  5. package/dist/app/data-profile-orientation.js +30 -1
  6. package/dist/app/data-profile-policy.d.ts +33 -7
  7. package/dist/app/data-profile-policy.js +31 -9
  8. package/dist/contracts/data-profile.d.ts +166 -0
  9. package/dist/contracts/index.d.ts +1 -0
  10. package/dist/execution/input-signature.d.ts +33 -0
  11. package/dist/execution/input-signature.js +0 -0
  12. package/dist/index.d.ts +4 -1
  13. package/dist/index.js +5 -0
  14. package/dist/input-scan/coverage.d.ts +22 -0
  15. package/dist/input-scan/coverage.js +85 -0
  16. package/dist/input-scan/enrich.d.ts +41 -0
  17. package/dist/input-scan/enrich.js +227 -0
  18. package/dist/input-scan/formats.d.ts +41 -0
  19. package/dist/input-scan/formats.js +279 -0
  20. package/dist/input-scan/index.d.ts +12 -0
  21. package/dist/input-scan/index.js +11 -0
  22. package/dist/input-scan/scan.d.ts +50 -0
  23. package/dist/input-scan/scan.js +182 -0
  24. package/dist/input-scan/shapes.d.ts +46 -0
  25. package/dist/input-scan/shapes.js +0 -0
  26. package/dist/input-scan/types.d.ts +172 -0
  27. package/dist/input-scan/types.js +12 -0
  28. package/dist/prompts/sandbox/data-profiler.d.ts +1 -1
  29. package/dist/prompts/sandbox/data-profiler.js +159 -275
  30. package/dist/provenance/exec-frame.js +9 -3
  31. package/dist/sandbox/client.d.ts +7 -0
  32. package/dist/sandbox/create-sandbox.js +1 -0
  33. package/dist/sandbox/docker-client.d.ts +1 -0
  34. package/dist/sandbox/docker-client.js +20 -15
  35. package/dist/sandbox/k8s-client.d.ts +6 -5
  36. package/dist/sandbox/k8s-client.js +55 -39
  37. package/dist/sandbox/reaper.d.ts +24 -13
  38. package/dist/sandbox/reaper.js +47 -14
  39. package/dist/sandbox/types.d.ts +6 -5
  40. package/dist/schemas/data-profile-schemas.d.ts +62 -0
  41. package/dist/schemas/data-profile-schemas.js +68 -2
  42. package/dist/state/data-profile.d.ts +7 -88
  43. package/dist/state/index.d.ts +1 -1
  44. package/dist/tasks/data-profile-activity.d.ts +8 -0
  45. package/dist/tasks/data-profile-activity.js +1 -0
  46. package/dist/tasks/data-profile-index.d.ts +45 -0
  47. package/dist/tasks/data-profile-index.js +134 -0
  48. package/dist/tasks/data-profile.d.ts +8 -23
  49. package/dist/tasks/data-profile.js +150 -117
  50. package/dist/tools/research/generate-plan.js +1 -1
  51. package/dist/tools/research/inspect-data-profile.d.ts +30 -4
  52. package/dist/tools/research/inspect-data-profile.js +66 -10
  53. package/dist/tools/workspace/scan-inputs.d.ts +38 -0
  54. package/dist/tools/workspace/scan-inputs.js +61 -0
  55. package/dist/tools/workspace/workspace-search.d.ts +1 -1
  56. package/dist/tools/workspace/workspace-search.js +12 -9
  57. package/dist/workspace/filesystem.d.ts +27 -0
  58. package/dist/workspace/filesystem.js +27 -0
  59. package/package.json +1 -1
@@ -30,6 +30,9 @@ export function makeFakeSandboxClient() {
30
30
  async isAlive() {
31
31
  return { alive: true, oomKilled: false };
32
32
  },
33
+ async isAliveById() {
34
+ return { alive: true, oomKilled: false };
35
+ },
33
36
  async teardown() { },
34
37
  async teardownById() { },
35
38
  async listManagedSandboxes() {
@@ -42,6 +45,9 @@ export function makeFakeWorkspaceFs() {
42
45
  readFile() {
43
46
  return okAsync({ kind: "ok", content: Buffer.alloc(0), truncated: false });
44
47
  },
48
+ readBytes() {
49
+ return okAsync({ kind: "ok", bytes: Buffer.alloc(0) });
50
+ },
45
51
  list() {
46
52
  return okAsync({ kind: "ok", entries: [] });
47
53
  },
@@ -10,10 +10,11 @@ export const meta = {
10
10
  capabilities: ["data profiling", "literature review", "methodology research", "analysis planning", "experimental design analysis"],
11
11
  suitableFor: ["bulk-rna-seq", "single-cell", "proteomics", "metabolomics", "genomics", "transcriptomics", "chemical-structures", "compound-screening"],
12
12
  skills: [],
13
- tools: [...BASE_SANDBOX_TOOLS],
14
- // Profiling fans out one programmatic pass per input file (head/wc preview +
15
- // a Python script + read-back) before the single submit_profile, so a
16
- // many-file analysis needs more headroom than the sandbox default.
13
+ // `scanInputs` is the profiler's own instrument: the workflow injects one scan into
14
+ // its briefing, and the tool is how it re-scans a subtree it wants to group
15
+ // differently. Declared rather than bolted on, so the resolved roster stays
16
+ // `meta.tools` plus the always-on set.
17
+ tools: [...BASE_SANDBOX_TOOLS, "scanInputs"],
17
18
  defaultMaxSteps: 85,
18
19
  plannable: false,
19
20
  };
@@ -30,6 +30,7 @@ import { chemblTool, genePreclinicalProfileTool, lookupAnnotationTool, openTarge
30
30
  import { createNcbiTools, createChemDbTools } from "../../tools/bio/keys.js";
31
31
  // Workspace read surface.
32
32
  import { createFileStatTool, createGrepTool, createListFilesTool, createReadFileTool, createWorkspaceSearchTool } from "../../tools/workspace/index.js";
33
+ import { createScanInputsTool } from "../../tools/workspace/scan-inputs.js";
33
34
  // Workspace mutate surface.
34
35
  import { createEditFileTool, createExecuteCommandTool, createWorkspaceMutator, createWriteFileTool } from "../../tools/workspace/index.js";
35
36
  // Skills (declared per agent via meta.skills).
@@ -60,6 +61,16 @@ function resolveSandboxTools(deps, tools) {
60
61
  const registry = {
61
62
  listAvailablePackages: createListAvailablePackagesTool({ ...(deps.packagesFile ? { packagesFile: deps.packagesFile } : {}) }),
62
63
  listAvailableRefs: createListAvailableRefsTool({ ...(deps.refStorePath ? { refStorePath: deps.refStorePath } : {}) }),
64
+ scanInputs: createScanInputsTool({
65
+ workspaceFs: deps.workspaceFs,
66
+ analysisId: deps.step.analysisId,
67
+ sandboxClient: deps.sandboxClient,
68
+ sandbox: deps.step.sandbox,
69
+ workflowId: deps.step.workflowId,
70
+ stepId: deps.step.stepId,
71
+ nextFunctionId: deps.step.nextFunctionId,
72
+ deadlineMs: deps.step.deadlineMs,
73
+ }),
63
74
  resolveLibraryId: resolveLibraryIdTool,
64
75
  queryDocs: queryDocsTool,
65
76
  inspectRun: createInspectRunTool(deps.pool),
@@ -12,7 +12,7 @@
12
12
  * in the tool's `perSource` report; the rest of the call still answers.
13
13
  */
14
14
  /** Closed allowlist of tools any sandbox agent may declare. */
15
- export type SandboxToolName = "listAvailablePackages" | "listAvailableRefs" | "resolveLibraryId" | "queryDocs" | "inspectRun" | "pubmed" | "resolve_citation" | "searchGene" | "lookupAnnotation" | "searchInteractions" | "chembl" | "pubchem" | "opentargets" | "geneDiseaseEvidence" | "drugGeneInteractions" | "genePreclinicalProfile" | "searchFaers" | "searchClinicalTrials" | "searchGeoDatasets" | "targetSafety" | "comptox";
15
+ export type SandboxToolName = "listAvailablePackages" | "listAvailableRefs" | "scanInputs" | "resolveLibraryId" | "queryDocs" | "inspectRun" | "pubmed" | "resolve_citation" | "searchGene" | "lookupAnnotation" | "searchInteractions" | "chembl" | "pubchem" | "opentargets" | "geneDiseaseEvidence" | "drugGeneInteractions" | "genePreclinicalProfile" | "searchFaers" | "searchClinicalTrials" | "searchGeoDatasets" | "targetSafety" | "comptox";
16
16
  /** Planner-facing metadata + tool allowlist for one sandbox agent. */
17
17
  export interface AgentMeta {
18
18
  /** Stable agent id — also the key in the catalog and the `AgentDefinition.id`. */
@@ -18,6 +18,9 @@
18
18
  export const DATA_PROFILE_ORIENTATION_MAX_CHARS = 1200;
19
19
  /** Files listed before the tail is elided (the count still reports the total). */
20
20
  const MAX_FILES = 8;
21
+ /** Kinds listed before the tail is elided. A dataset with more is described by its first few. */
22
+ const MAX_KINDS = 6;
23
+ const MAX_KIND_DESCRIPTION_CHARS = 90;
21
24
  /** Quality concerns listed — the profiler orders them, so these are the top ones. */
22
25
  const MAX_CONCERNS = 3;
23
26
  const MAX_DESIGN_CHARS = 200;
@@ -105,9 +108,35 @@ export function buildDataProfileOrientation(result, maxChars = DATA_PROFILE_ORIE
105
108
  const more = concerns.length > shown.length ? ` (+${concerns.length - shown.length} more)` : "";
106
109
  lines.push(`Concerns: ${shown.join("; ")}${more}`);
107
110
  }
111
+ // Kinds come first because they are what the dataset IS at this budget: four lines
112
+ // describing 3513 files, where the per-file list could only show the first eight of
113
+ // them and imply the rest away.
114
+ const kinds = result.kinds ?? [];
115
+ if (kinds.length > 0) {
116
+ const shown = kinds.slice(0, MAX_KINDS);
117
+ const datasetFiles = kinds.reduce((sum, kind) => sum + kind.count, 0);
118
+ const header = shown.length < kinds.length
119
+ ? `Kinds (${shown.length} of ${kinds.length}, ${datasetFiles} files):`
120
+ : `Kinds (${kinds.length}, ${datasetFiles} files):`;
121
+ lines.push(header);
122
+ for (const kind of shown) {
123
+ const facts = [`${kind.count}x`, present(kind.format)].filter((f) => f !== undefined).join(", ");
124
+ lines.push(`- ${kind.name} (${facts}) — ${clip(kind.memberRepresents, MAX_KIND_DESCRIPTION_CHARS)}`);
125
+ }
126
+ const axes = result.axes ?? [];
127
+ if (axes.length > 0) {
128
+ lines.push(`Axes: ${axes.map((axis) => `${axis.label} (${axis.cardinality})`).join(", ")}`);
129
+ }
130
+ if (result.coverage && result.coverage.unmatched > 0) {
131
+ lines.push(`Coverage: ${result.coverage.matched} of ${result.coverage.total} files match a kind`);
132
+ }
133
+ }
108
134
  if (result.files.length > 0) {
109
135
  const shown = result.files.slice(0, MAX_FILES);
110
- const header = shown.length < result.files.length ? `Files (${shown.length} of ${result.files.length}):` : `Files (${result.files.length}):`;
136
+ // With kinds present these are the individually notable files — the metadata
137
+ // sheet, the README, the outlier — not a sample of the dataset.
138
+ const label = kinds.length > 0 ? "Notable files" : "Files";
139
+ const header = shown.length < result.files.length ? `${label} (${shown.length} of ${result.files.length}):` : `${label} (${result.files.length}):`;
111
140
  lines.push(header);
112
141
  for (const file of shown) {
113
142
  lines.push(`- ${file.path} — ${clip(file.description, MAX_FILE_DESCRIPTION_CHARS)}${formatFileFacts(file)}`);
@@ -5,13 +5,37 @@
5
5
  * — staging stays embedder-side per the data-profile-init spec, and the atomic running→expired
6
6
  * flip stays an SQL guard (`expireStaleDataProfile`), not part of this decision.
7
7
  */
8
- export type DataProfileLifecycleStatus = "pending" | "running" | "completed" | "failed";
8
+ import type { DataProfileInputSignature, DataProfileLifecycleStatus } from "../contracts/data-profile.js";
9
+ export type { DataProfileLifecycleStatus };
9
10
  /**
10
- * A completed profile is stale when the analysis's seed input set no longer
11
- * matches the set the profile actually covered — a file was added or swapped
12
- * since profiling. An empty seed set is never stale (nothing to compare).
11
+ * The analysis's inputs as they are NOW. A caller holding sizes and mtimes supplies
12
+ * the signature; one holding only the ledger's seeded ids supplies just those, and the
13
+ * comparison degrades honestly to what those ids can settle.
13
14
  */
14
- export declare function isDataProfileStale(seedInputFileIds: readonly string[], profiledInputFileIds: readonly string[]): boolean;
15
+ export interface CurrentInputSet {
16
+ readonly fileIds: readonly string[];
17
+ readonly signature?: DataProfileInputSignature;
18
+ }
19
+ /** The comparand a profile snapshot carries. Which one is present depends on its era. */
20
+ export interface ProfiledInputSnapshot {
21
+ readonly inputSignature?: DataProfileInputSignature;
22
+ readonly inputFileIds?: readonly string[];
23
+ }
24
+ /**
25
+ * A completed profile is stale when the analysis's current input set no longer matches
26
+ * the set the profile actually covered.
27
+ *
28
+ * The signature is preferred where both sides carry one: it covers size and mtime as
29
+ * well as identity, so an in-place edit is drift too. A snapshot predating the
30
+ * signature falls back to its identity list rather than being treated as drift, since
31
+ * that list answers the same question for the set it covered. A snapshot carrying
32
+ * NEITHER is drift: nothing establishes what it covered, and one re-profile heals it —
33
+ * exactly how a wholly absent `result` is already treated.
34
+ *
35
+ * An empty current set is never stale: there is nothing to compare, and an analysis
36
+ * whose inputs were removed is handled by clearing the profile, not by re-profiling it.
37
+ */
38
+ export declare function isDataProfileStale(current: CurrentInputSet, profiled: ProfiledInputSnapshot | null | undefined): boolean;
15
39
  export type DataProfileAction = {
16
40
  readonly kind: "none";
17
41
  } | {
@@ -21,8 +45,10 @@ export type DataProfileAction = {
21
45
  };
22
46
  export interface DataProfileDecisionInput {
23
47
  readonly status: DataProfileLifecycleStatus;
24
- readonly seedInputFileIds: readonly string[];
25
- readonly profiledInputFileIds: readonly string[];
48
+ /** The analysis's inputs as they are now — the seeded ids, plus a signature where the caller has one. */
49
+ readonly current: CurrentInputSet;
50
+ /** The comparand the stored profile carries, or null when it holds none. */
51
+ readonly profiled: ProfiledInputSnapshot | null;
26
52
  }
27
53
  /**
28
54
  * Decide the next profiling action from a (post-expire) status:
@@ -6,17 +6,39 @@
6
6
  * flip stays an SQL guard (`expireStaleDataProfile`), not part of this decision.
7
7
  */
8
8
  /**
9
- * A completed profile is stale when the analysis's seed input set no longer
10
- * matches the set the profile actually covered — a file was added or swapped
11
- * since profiling. An empty seed set is never stale (nothing to compare).
9
+ * A completed profile is stale when the analysis's current input set no longer matches
10
+ * the set the profile actually covered.
11
+ *
12
+ * The signature is preferred where both sides carry one: it covers size and mtime as
13
+ * well as identity, so an in-place edit is drift too. A snapshot predating the
14
+ * signature falls back to its identity list rather than being treated as drift, since
15
+ * that list answers the same question for the set it covered. A snapshot carrying
16
+ * NEITHER is drift: nothing establishes what it covered, and one re-profile heals it —
17
+ * exactly how a wholly absent `result` is already treated.
18
+ *
19
+ * An empty current set is never stale: there is nothing to compare, and an analysis
20
+ * whose inputs were removed is handled by clearing the profile, not by re-profiling it.
12
21
  */
13
- export function isDataProfileStale(seedInputFileIds, profiledInputFileIds) {
14
- if (seedInputFileIds.length === 0)
22
+ export function isDataProfileStale(current, profiled) {
23
+ if (current.fileIds.length === 0 && current.signature === undefined)
15
24
  return false;
16
- if (seedInputFileIds.length !== profiledInputFileIds.length)
25
+ if (!profiled)
17
26
  return true;
18
- const profiled = new Set(profiledInputFileIds);
19
- return seedInputFileIds.some((id) => !profiled.has(id));
27
+ if (profiled.inputSignature) {
28
+ // A caller with no signature of its own can still compare counts, which catches
29
+ // an added or removed file. It cannot catch a same-count swap, and claiming
30
+ // otherwise would be a verdict the comparand does not support.
31
+ if (!current.signature)
32
+ return profiled.inputSignature.count !== current.fileIds.length;
33
+ return profiled.inputSignature.digest !== current.signature.digest || profiled.inputSignature.count !== current.signature.count;
34
+ }
35
+ if (profiled.inputFileIds) {
36
+ if (current.fileIds.length !== profiled.inputFileIds.length)
37
+ return true;
38
+ const covered = new Set(profiled.inputFileIds);
39
+ return current.fileIds.some((id) => !covered.has(id));
40
+ }
41
+ return true;
20
42
  }
21
43
  /**
22
44
  * Decide the next profiling action from a (post-expire) status:
@@ -30,7 +52,7 @@ export function isDataProfileStale(seedInputFileIds, profiledInputFileIds) {
30
52
  export function decideDataProfileAction(input) {
31
53
  if (input.status === "pending")
32
54
  return { kind: "trigger" };
33
- if (input.status === "completed" && isDataProfileStale(input.seedInputFileIds, input.profiledInputFileIds)) {
55
+ if (input.status === "completed" && isDataProfileStale(input.current, input.profiled)) {
34
56
  return { kind: "retrigger" };
35
57
  }
36
58
  return { kind: "none" };
@@ -24,3 +24,169 @@
24
24
  * reporting the profile as an unnamed run — a failure with no red test anywhere.
25
25
  */
26
26
  export declare const DATA_PROFILE_RUN_LITERAL: "data-profile";
27
+ /**
28
+ * Where an extracted subject fact came from, so a consumer can judge how much to trust
29
+ * it. Ordered most-direct first in the profiler's own guidance.
30
+ */
31
+ export type DataProfileSubjectSource = "metadata" | "document" | "filename" | "user-context" | "inferred";
32
+ /** The subject organism, with its provenance and the profiler's confidence in it. */
33
+ export interface DataProfileOrganism {
34
+ scientificName: string;
35
+ /** NCBI Taxonomy ID as a string, e.g. `"9606"`. */
36
+ taxonId: string;
37
+ source: DataProfileSubjectSource;
38
+ confidence: "high" | "medium" | "low";
39
+ notes?: string;
40
+ }
41
+ /**
42
+ * One profiled file's persisted record.
43
+ *
44
+ * `path` + `description` are the original pair every snapshot carries; everything below
45
+ * them is optional on read, because a snapshot written before the record was widened
46
+ * carries only the pair.
47
+ */
48
+ export interface DataProfileFile {
49
+ path: string;
50
+ description: string;
51
+ /** Semantic data type (count-matrix, variants, clinical-metadata, document, …). */
52
+ dataType?: string;
53
+ /** File format (CSV, h5ad, VCF, BAM, …). */
54
+ format?: string;
55
+ rows?: number | null;
56
+ cols?: number | null;
57
+ /** Searchable labels the profiler attached. */
58
+ tags?: string[];
59
+ /** Quality issues specific to this file. */
60
+ warnings?: string[];
61
+ /** Flat, identity-establishing facts (sparsity, delimiter, normalizationState, …). */
62
+ metrics?: Record<string, string | number | boolean>;
63
+ }
64
+ /** Dataset-wide quality findings the profiler recorded. */
65
+ export interface DataProfileQualityAssessment {
66
+ concerns: string[];
67
+ strengths: string[];
68
+ }
69
+ /**
70
+ * One repeating set of files — the dataset's structure, as opposed to its contents.
71
+ *
72
+ * A kind is a claim about MEANING, made by the profiler agent: the deterministic input
73
+ * scan establishes that filenames differ only at one position, and cannot establish that
74
+ * the files are the same sort of thing. A singleton is a kind of `count` 1, so a
75
+ * two-file analysis is two kinds and needs no special case.
76
+ */
77
+ export interface DataProfileKind {
78
+ name: string;
79
+ /** What ONE member represents. The grouping decision, stated apart from the description. */
80
+ memberRepresents: string;
81
+ description: string;
82
+ count: number;
83
+ /** Glob the members match, relative to the analysis root. Coverage is computed from it. */
84
+ pathPattern: string;
85
+ format?: string;
86
+ /** Labels of the `axes` that vary across this set's members. */
87
+ axisLabels?: string[];
88
+ }
89
+ /**
90
+ * What varies across a kind's members — the experimental design, as far as it is
91
+ * observable. The scan reports that a filename position varies and which values it
92
+ * takes; what the variation IS is the agent's label.
93
+ */
94
+ export interface DataProfileAxis {
95
+ label: string;
96
+ cardinality: number;
97
+ exampleValues?: string[];
98
+ description?: string;
99
+ }
100
+ /**
101
+ * The comparand identifying WHICH files a profile covered and whether the SAME BYTES
102
+ * were profiled.
103
+ *
104
+ * `digest` is a stable hash over the staged inputs' identities, sizes, and mtimes in a
105
+ * canonical order, so it depends on the set and not on enumeration order. It excludes
106
+ * content hashes deliberately: enumerating those would mean reading every input in full
107
+ * on every parity check. An edit preserving both byte length and mtime is therefore not
108
+ * detected — bounded, and documented.
109
+ */
110
+ export interface DataProfileInputSignature {
111
+ count: number;
112
+ digest: string;
113
+ }
114
+ /**
115
+ * How much of the scanned tree the profile's kinds describe.
116
+ *
117
+ * Computed deterministically by matching the submitted kind patterns against the scanned
118
+ * file set — never self-reported. Low coverage is a distinct failure from drift: a
119
+ * profile that classified a fraction of its inputs used to carry the same comparand as
120
+ * one that classified all of them, so it read as complete and fresh. Coverage does NOT by
121
+ * itself drive a re-profile, because some input sets legitimately resist classification.
122
+ */
123
+ export interface DataProfileCoverage {
124
+ matched: number;
125
+ unmatched: number;
126
+ total: number;
127
+ unmatchedSample?: string[];
128
+ }
129
+ /**
130
+ * One profiled input file's legacy drift signature. `fileId` is a path identity (two
131
+ * files at the same path share it regardless of content), so `size` + `mtimeMs` are what
132
+ * let a consumer notice the bytes behind a path changed. Superseded by
133
+ * {@link DataProfileInputSignature}, and still readable on older rows.
134
+ */
135
+ export interface DataProfileInputFile {
136
+ fileId: string;
137
+ size: number;
138
+ mtimeMs: number;
139
+ }
140
+ /**
141
+ * The profile snapshot stored in `cortex_analysis_state.data_profile_result`.
142
+ *
143
+ * It carries the profiler's full finding rather than a summary of it: this row is the
144
+ * profile's only durable home, so a field dropped on the way in is not "summarized
145
+ * away", it is destroyed, and the next reader can recover it only by re-reading the raw
146
+ * inputs.
147
+ *
148
+ * It does NOT carry a record per input file. The workspace filesystem is the
149
+ * authoritative file list — listing, grep, and the vector index all read the live tree —
150
+ * so a copy here would be a stale duplicate on a row detoasted by every reader,
151
+ * including the planner's, which reads a few hundred characters of it. `files` holds the
152
+ * individually notable inputs; `kinds` holds the dataset's structure.
153
+ */
154
+ export interface DataProfileResult {
155
+ summary: string;
156
+ /** Individually described files — notable singletons, not the dataset's contents. */
157
+ files: DataProfileFile[];
158
+ /** The repeating sets the tree was grouped into. Absent on a pre-kinds snapshot. */
159
+ kinds?: DataProfileKind[];
160
+ /** What varies across those sets. Absent for the same reason as `kinds`. */
161
+ axes?: DataProfileAxis[];
162
+ /** The drift comparand. Preferred over `inputFileIds`, which it replaces. */
163
+ inputSignature?: DataProfileInputSignature;
164
+ /** How much of the scanned tree the kinds describe. Absent on pre-coverage snapshots. */
165
+ coverage?: DataProfileCoverage;
166
+ /** The legacy identity-list comparand, on rows written before the signature existed. */
167
+ inputFileIds?: string[];
168
+ /** The legacy per-file drift signature, on rows of the same era. */
169
+ inputFiles?: DataProfileInputFile[];
170
+ /** ISO 8601 timestamp of profile completion. */
171
+ profiledAt: string;
172
+ /** Scientific domain (transcriptomics, cheminformatics, clinical, …). */
173
+ domain?: string;
174
+ /** Specific subtype within the domain (bulk-rna-seq, single-cell, LC-MS/MS, …). */
175
+ subtype?: string;
176
+ /**
177
+ * Explicit `null` is a real, distinct value — the profiler looked and no input
178
+ * identified an organism — as opposed to absent, which means the snapshot predates
179
+ * the field.
180
+ */
181
+ organism?: DataProfileOrganism | null;
182
+ tissue?: string | null;
183
+ cellType?: string | null;
184
+ condition?: string | null;
185
+ /** Public dataset accessions found in any input (GSE/SRP/PRJNA/E-MTAB/phs/EGAS). */
186
+ accessions?: string[];
187
+ /** Conditions, groups, comparisons, replicates, pairing. */
188
+ experimentalDesign?: string;
189
+ qualityAssessment?: DataProfileQualityAssessment;
190
+ }
191
+ /** The profile's lifecycle, as the ledger records it. */
192
+ export type DataProfileLifecycleStatus = "pending" | "running" | "completed" | "failed";
@@ -6,6 +6,7 @@ export type { PlanStep, PresentationContent, PresentationPart, PlanPart, RunCard
6
6
  export type { EventSource, TextDeltaEvent, ToolStartedEvent, ToolFinishedEvent, FinishEvent, ChatErrorEvent, CortexChatEvent } from "./chat-events.js";
7
7
  export type { TokenUsageRollup } from "./usage.js";
8
8
  export { DATA_PROFILE_RUN_LITERAL } from "./data-profile.js";
9
+ export type { DataProfileAxis, DataProfileCoverage, DataProfileFile, DataProfileInputFile, DataProfileInputSignature, DataProfileKind, DataProfileLifecycleStatus, DataProfileOrganism, DataProfileQualityAssessment, DataProfileResult, DataProfileSubjectSource, } from "./data-profile.js";
9
10
  export type { TextPart, ToolCallPart, CortexPart, CortexMessage } from "./message.js";
10
11
  export { PART_REGISTRY, isTransient, isReconciling, isSidebarPart } from "./part-registry.js";
11
12
  export type { CortexChatPartType, PartDescriptor, PartEmitter, PartConsumer } from "./part-registry.js";
@@ -0,0 +1,33 @@
1
+ /**
2
+ * The input signature — the fixed-width comparand a completed profile persists in
3
+ * place of a per-file identity list.
4
+ *
5
+ * `inputFileIds` had exactly one consumer, the staleness comparison, so a set of
6
+ * several thousand identifiers was persisted, detoasted on every read of the row, and
7
+ * compared element-wise to answer a question 40 bytes answer. The signature covers
8
+ * `size` and `mtimeMs` as well, so it also notices that the bytes behind a path
9
+ * changed — a `fileId` is a path identity and survives an in-place edit unchanged.
10
+ *
11
+ * Order independence is a property of the digest, not of the caller: entries are
12
+ * sorted canonically before hashing, so two enumerations of the same set agree.
13
+ */
14
+ import type { DataProfileInputSignature } from "../state/data-profile.js";
15
+ import type { StagedInput } from "./staged-input.js";
16
+ /** The fields a signature covers. A manifest entry satisfies it structurally. */
17
+ export interface SignableInput {
18
+ readonly fileId: string;
19
+ readonly size: number;
20
+ readonly mtimeMs?: number;
21
+ }
22
+ /**
23
+ * Digest a staged input set.
24
+ *
25
+ * A workflow input persisted before `StagedInput.mtimeMs` existed deserializes without
26
+ * it despite the compile-time type; such an entry contributes `-` in the mtime field
27
+ * rather than omitting the signature entirely. The comparison still notices an added,
28
+ * removed, or resized file, and one re-profile under the current code restores the full
29
+ * signature.
30
+ */
31
+ export declare function computeInputSignature(inputs: readonly SignableInput[]): DataProfileInputSignature;
32
+ /** Convenience over the workflow's own manifest type. */
33
+ export declare function signatureOfStagedInputs(inputs: readonly StagedInput[]): DataProfileInputSignature;
package/dist/index.d.ts CHANGED
@@ -115,10 +115,13 @@ export { DATA_PROFILE_RUN_LITERAL } from "./contracts/data-profile.js";
115
115
  export { initCortexState } from "./state/init.js";
116
116
  export { upsertAnalysis } from "./state/analyses.js";
117
117
  export { clearDataProfile, loadDataProfileStatus, tryRetryDataProfile, reconcileOrphanedDataProfile } from "./state/data-profile.js";
118
- export type { DataProfileInputFile, DataProfileResult, DataProfileStatus } from "./state/data-profile.js";
118
+ export type { DataProfileAxis, DataProfileCoverage, DataProfileFile, DataProfileInputFile, DataProfileInputSignature, DataProfileKind, DataProfileLifecycleStatus, DataProfileOrganism, DataProfileQualityAssessment, DataProfileResult, DataProfileSubjectSource, } from "./contracts/data-profile.js";
119
+ export type { DataProfileStatus } from "./state/data-profile.js";
119
120
  export { createAnalysisPurge } from "./state/purge-analysis.js";
120
121
  export type { AnalysisPurge, AnalysisPurgeDeps, AnalysisPurgeOutcome } from "./state/purge-analysis.js";
121
122
  export type { StagedInput } from "./execution/staged-input.js";
123
+ export { computeInputSignature } from "./execution/input-signature.js";
124
+ export type { SignableInput } from "./execution/input-signature.js";
122
125
  export type { ResolveWorkspaceRoot } from "./workspace/paths.js";
123
126
  export { createSandboxClient } from "./sandbox/create-sandbox.js";
124
127
  export type { CreateSandboxClientConfig, SandboxBackendConfig } from "./sandbox/create-sandbox.js";
package/dist/index.js CHANGED
@@ -205,6 +205,11 @@ export { clearDataProfile, loadDataProfileStatus, tryRetryDataProfile, reconcile
205
205
  // Analysis reclamation — `purgeAnalysis` reclaims an analysis's whole Postgres
206
206
  // footprint from its id alone; workspace files stay the embedder's to dispose of.
207
207
  export { createAnalysisPurge } from "./state/purge-analysis.js";
208
+ // The input signature a completed profile persists. Exported because the embedder owns
209
+ // the OTHER side of the comparison — it enumerates the current input set — and a second
210
+ // implementation of the digest would silently disagree with the one on the row, which is
211
+ // the failure a fixed-width comparand exists to prevent.
212
+ export { computeInputSignature } from "./execution/input-signature.js";
208
213
  export { createSandboxClient } from "./sandbox/create-sandbox.js";
209
214
  export { MachineBudgetSchema, ResourceLimitsSchema, ResourcePolicySchema, ResourceSpecSchema, parseResourcePolicy } from "./config/resource-limits.js";
210
215
  export { createWorkspaceFilesystem } from "./workspace/filesystem.js";
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Profile coverage — how much of the scanned tree the agent's kinds actually describe.
3
+ *
4
+ * Measured against the SUBMITTED kinds, never against the scan's own shapes. Computed
5
+ * from the shapes it would report only whether the scan grouped its own observations,
6
+ * which is always yes; measured against the kinds it is a real check, and it is the
7
+ * check that was missing when a profile covering 49 of 3513 files read as complete and
8
+ * fresh.
9
+ *
10
+ * Deterministic by construction: matching declared patterns against a known file set
11
+ * requires no judgement, and a coverage figure a model self-reported would not be a
12
+ * check at all.
13
+ */
14
+ export interface ProfileCoverage {
15
+ /** Files matching at least one declared kind pattern. */
16
+ readonly matched: number;
17
+ readonly unmatched: number;
18
+ readonly total: number;
19
+ readonly unmatchedSample: string[];
20
+ }
21
+ /** Coverage of a scanned file set by a set of declared kind patterns. */
22
+ export declare function computeCoverage(paths: readonly string[], patterns: readonly string[]): ProfileCoverage;
@@ -0,0 +1,85 @@
1
+ /**
2
+ * Profile coverage — how much of the scanned tree the agent's kinds actually describe.
3
+ *
4
+ * Measured against the SUBMITTED kinds, never against the scan's own shapes. Computed
5
+ * from the shapes it would report only whether the scan grouped its own observations,
6
+ * which is always yes; measured against the kinds it is a real check, and it is the
7
+ * check that was missing when a profile covering 49 of 3513 files read as complete and
8
+ * fresh.
9
+ *
10
+ * Deterministic by construction: matching declared patterns against a known file set
11
+ * requires no judgement, and a coverage figure a model self-reported would not be a
12
+ * check at all.
13
+ */
14
+ /** Unmatched paths carried on the snapshot, so a shortfall names examples rather than a bare count. */
15
+ const MAX_UNMATCHED_SAMPLE = 10;
16
+ /**
17
+ * Compile a kind's path pattern to a matcher.
18
+ *
19
+ * `**` crosses directory separators, `*` and `?` do not, and `<0>`-style placeholders —
20
+ * the syntax the manifest renders variable positions in, which an agent may reasonably
21
+ * copy — match one token. Everything else is literal.
22
+ */
23
+ function compilePattern(pattern) {
24
+ let out = "";
25
+ for (let i = 0; i < pattern.length; i++) {
26
+ const char = pattern[i];
27
+ if (char === "*") {
28
+ if (pattern[i + 1] === "*") {
29
+ out += ".*";
30
+ i++;
31
+ // `**/` should also match zero directories, so the slash is optional.
32
+ if (pattern[i + 1] === "/")
33
+ i++;
34
+ continue;
35
+ }
36
+ out += "[^/]*";
37
+ continue;
38
+ }
39
+ if (char === "?") {
40
+ out += "[^/]";
41
+ continue;
42
+ }
43
+ if (char === "<") {
44
+ const close = pattern.indexOf(">", i);
45
+ if (close > i) {
46
+ out += "[^/]*";
47
+ i = close;
48
+ continue;
49
+ }
50
+ }
51
+ out += char.replace(/[.+^${}()|[\]\\]/g, "\\$&");
52
+ }
53
+ return new RegExp(`^${out}$`);
54
+ }
55
+ function normalize(path) {
56
+ return path.replace(/^\.\//, "").replace(/^\/+/, "").replace(/\/+/g, "/");
57
+ }
58
+ /**
59
+ * Whether a path falls under a pattern. A pattern naming a directory covers everything
60
+ * beneath it — agents write `data/inputs/vcf` as often as `data/inputs/vcf/*.vcf.gz`,
61
+ * and reading the first as a zero-file kind would report a coverage failure that is an
62
+ * artefact of notation.
63
+ */
64
+ function matches(path, pattern, compiled) {
65
+ if (compiled.test(path))
66
+ return true;
67
+ const bare = pattern.replace(/\/+$/, "");
68
+ return bare.length > 0 && !/[*?<]/.test(bare) && path.startsWith(`${bare}/`);
69
+ }
70
+ /** Coverage of a scanned file set by a set of declared kind patterns. */
71
+ export function computeCoverage(paths, patterns) {
72
+ const compiled = patterns.map((pattern) => ({ pattern, regex: compilePattern(pattern) }));
73
+ const unmatchedSample = [];
74
+ let matched = 0;
75
+ for (const raw of paths) {
76
+ const path = normalize(raw);
77
+ if (compiled.some(({ pattern, regex }) => matches(path, pattern, regex))) {
78
+ matched++;
79
+ continue;
80
+ }
81
+ if (unmatchedSample.length < MAX_UNMATCHED_SAMPLE)
82
+ unmatchedSample.push(path);
83
+ }
84
+ return { matched, unmatched: paths.length - matched, total: paths.length, unmatchedSample };
85
+ }
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Per-shape header readout — the one part of the scan that needs the sandbox.
3
+ *
4
+ * A bounded prefix read compared against a magic table runs on the host; a DECODER
5
+ * over user-supplied bytes does not. zlib, HDF5, and PDF parsers in the long-lived
6
+ * multi-tenant harness process are exactly the exposure the ephemeral sandbox exists
7
+ * to contain, so every decode below runs there.
8
+ *
9
+ * Cost is O(shapes), not O(files): shapes are observed from names and sizes alone, so
10
+ * enrichment decodes a bounded number of members per shape — roughly four files for
11
+ * the 3513-file tree that motivated this capability. One exec covers every shape, so
12
+ * even the round trip does not scale with the tree.
13
+ *
14
+ * Nothing ships in `sandbox-base` for this: the decoder is a Python program passed on
15
+ * the command line through the existing `runSandboxExec` path, so the capability
16
+ * releases on the harness's own path rather than on the image's manual one.
17
+ */
18
+ import type { EmitFn } from "../tools/define-tool.js";
19
+ import type { SandboxClient } from "../sandbox/client.js";
20
+ import type { SandboxRef } from "../sandbox/types.js";
21
+ import type { FileShape } from "./types.js";
22
+ /** Members decoded per shape. One is enough to characterise a set whose names already match. */
23
+ export declare const MEMBERS_DECODED_PER_SHAPE = 1;
24
+ export interface EnrichShapesArgs {
25
+ readonly shapes: readonly FileShape[];
26
+ readonly sandboxClient: SandboxClient;
27
+ readonly sandbox: SandboxRef;
28
+ /** Absolute in-sandbox path of the analysis root (`/{analysisId}`). */
29
+ readonly mountRoot: string;
30
+ readonly execId: string;
31
+ readonly deadlineMs: number;
32
+ readonly emit: EmitFn;
33
+ }
34
+ /**
35
+ * Decode one member per shape and attach the readout.
36
+ *
37
+ * A shape whose decode failed keeps its `unavailable` note rather than losing the
38
+ * shape: the readout is enrichment, and a manifest without it still carries every
39
+ * structural observation the agent's grouping rests on.
40
+ */
41
+ export declare function enrichShapes(args: EnrichShapesArgs): Promise<FileShape[]>;