@inflexa-ai/harness 0.28.0 → 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.
Files changed (87) hide show
  1. package/dist/app/data-profile-orientation.d.ts +52 -27
  2. package/dist/app/data-profile-orientation.js +331 -92
  3. package/dist/app/data-profile-view.d.ts +70 -0
  4. package/dist/app/data-profile-view.js +123 -0
  5. package/dist/app/synthesize-run.js +1 -1
  6. package/dist/contracts/data-profile.d.ts +276 -25
  7. package/dist/contracts/index.d.ts +3 -1
  8. package/dist/contracts/index.js +1 -0
  9. package/dist/contracts/profile-vocabulary.d.ts +103 -0
  10. package/dist/contracts/profile-vocabulary.js +449 -0
  11. package/dist/contracts/target-dossier.d.ts +21 -21
  12. package/dist/execution/artifact-registration.js +9 -0
  13. package/dist/execution/artifact-registry.d.ts +14 -0
  14. package/dist/execution/input-signature.d.ts +16 -3
  15. package/dist/execution/input-signature.js +0 -0
  16. package/dist/execution/post-step-pipeline.d.ts +6 -4
  17. package/dist/execution/post-step-pipeline.js +18 -9
  18. package/dist/execution/staged-input.d.ts +4 -4
  19. package/dist/index.d.ts +5 -1
  20. package/dist/index.js +7 -0
  21. package/dist/input-scan/clustering.d.ts +63 -0
  22. package/dist/input-scan/clustering.js +208 -0
  23. package/dist/input-scan/companions.d.ts +30 -0
  24. package/dist/input-scan/companions.js +138 -0
  25. package/dist/input-scan/decoder/container_readout.py +171 -0
  26. package/dist/input-scan/detect-sets.d.ts +24 -0
  27. package/dist/input-scan/detect-sets.js +279 -0
  28. package/dist/input-scan/enrich.d.ts +62 -19
  29. package/dist/input-scan/enrich.js +132 -187
  30. package/dist/input-scan/formats.d.ts +13 -5
  31. package/dist/input-scan/formats.js +13 -5
  32. package/dist/input-scan/index.d.ts +10 -4
  33. package/dist/input-scan/index.js +7 -4
  34. package/dist/input-scan/markers.d.ts +21 -0
  35. package/dist/input-scan/markers.js +89 -0
  36. package/dist/input-scan/menu.d.ts +127 -0
  37. package/dist/input-scan/menu.js +0 -0
  38. package/dist/input-scan/quarantine.d.ts +17 -0
  39. package/dist/input-scan/quarantine.js +69 -0
  40. package/dist/input-scan/readout-budget.d.ts +32 -0
  41. package/dist/input-scan/readout-budget.js +61 -0
  42. package/dist/input-scan/readout.d.ts +33 -0
  43. package/dist/input-scan/readout.js +336 -0
  44. package/dist/input-scan/scan.d.ts +5 -4
  45. package/dist/input-scan/scan.js +5 -5
  46. package/dist/input-scan/set-types.d.ts +209 -0
  47. package/dist/input-scan/set-types.js +15 -0
  48. package/dist/input-scan/templates.d.ts +93 -0
  49. package/dist/input-scan/templates.js +369 -0
  50. package/dist/input-scan/tokens.d.ts +94 -0
  51. package/dist/input-scan/tokens.js +267 -0
  52. package/dist/input-scan/tuning.d.ts +55 -0
  53. package/dist/input-scan/tuning.js +55 -0
  54. package/dist/input-scan/types.d.ts +2 -2
  55. package/dist/prompts/conversation.d.ts +1 -1
  56. package/dist/prompts/conversation.js +8 -6
  57. package/dist/prompts/report-session.d.ts +1 -1
  58. package/dist/prompts/report-session.js +3 -2
  59. package/dist/prompts/sandbox/data-profiler.d.ts +10 -1
  60. package/dist/prompts/sandbox/data-profiler.js +197 -53
  61. package/dist/prompts/sandbox-standards.d.ts +1 -1
  62. package/dist/prompts/sandbox-standards.js +10 -7
  63. package/dist/schemas/data-profile-schemas.d.ts +943 -80
  64. package/dist/schemas/data-profile-schemas.js +300 -115
  65. package/dist/state/data-profile.d.ts +1 -1
  66. package/dist/state/index.d.ts +1 -1
  67. package/dist/state/vector-store.d.ts +13 -0
  68. package/dist/state/vector-store.js +8 -0
  69. package/dist/tasks/data-profile-absorb.d.ts +78 -0
  70. package/dist/tasks/data-profile-absorb.js +285 -0
  71. package/dist/tasks/data-profile-index.d.ts +34 -25
  72. package/dist/tasks/data-profile-index.js +160 -59
  73. package/dist/tasks/data-profile-resolve.d.ts +72 -0
  74. package/dist/tasks/data-profile-resolve.js +639 -0
  75. package/dist/tasks/data-profile.d.ts +16 -5
  76. package/dist/tasks/data-profile.js +270 -131
  77. package/dist/tools/research/inspect-data-profile.d.ts +48 -22
  78. package/dist/tools/research/inspect-data-profile.js +73 -54
  79. package/dist/tools/workspace/scan-inputs.d.ts +4 -4
  80. package/dist/tools/workspace/scan-inputs.js +13 -14
  81. package/dist/tools/workspace/workspace-search.d.ts +11 -2
  82. package/dist/tools/workspace/workspace-search.js +44 -16
  83. package/dist/workflows/sandbox-step.js +36 -6
  84. package/dist/workflows/target-assessment/phase4-assemble.d.ts +21 -21
  85. package/package.json +5 -3
  86. package/dist/input-scan/coverage.d.ts +0 -22
  87. package/dist/input-scan/coverage.js +0 -85
@@ -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 (organism, domain, design, per-file type/format/dimensions),
12
- * and it is why the record is served in full rather than summarized: an agent that
13
- * cannot pull a fact here has no fallback but to re-derive it from the raw bytes.
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 per-file scope is explicitly
16
- * paged and always reports `total` and `hasMore`: an elided tail is a fact the
17
- * model can see and act on, never a silent truncation.
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
@@ -28,6 +31,7 @@
28
31
  */
29
32
  import { ok } from "neverthrow";
30
33
  import { z } from "zod";
34
+ import { profileCaveats, profileDatasetFileCount, profileFileRecords, profileGroups } from "../../app/data-profile-view.js";
31
35
  import { scopeResource } from "../../auth/types.js";
32
36
  import { unwrapOrThrow } from "../../lib/result.js";
33
37
  import { loadDataProfileStatus } from "../../state/index.js";
@@ -37,31 +41,19 @@ import { defineTool } from "../define-tool.js";
37
41
  const DEFAULT_PAGE_SIZE = 20;
38
42
  /** Ceiling on `pageSize` — a page is a context-window budget, not a dump. */
39
43
  const MAX_PAGE_SIZE = 100;
40
- /**
41
- * How many files the dataset holds, as far as the snapshot can establish it.
42
- *
43
- * Coverage counts the scanned tree, so it is the authority where present. Summed kind
44
- * counts are the fallback. A snapshot with neither returns `null` rather than the
45
- * described-file count, which would be a dataset size the row cannot support.
46
- */
47
- function datasetFileCount(result) {
48
- if (result.coverage)
49
- return result.coverage.total;
50
- if (result.kinds && result.kinds.length > 0)
51
- return result.kinds.reduce((sum, kind) => sum + kind.count, 0);
52
- return null;
53
- }
54
44
  /** Serve each per-file record with a path that resolves in the frame of the caller. */
55
45
  function rootFilePaths(files, analysisId) {
56
46
  return files.map((file) => ({ ...file, path: toAnalysisRootPath(analysisId, file.path) }));
57
47
  }
58
48
  /**
59
- * Serve each kind with a glob that resolves in the frame of the caller.
49
+ * Serve each group with a display pattern that resolves in the frame of the caller.
60
50
  *
61
- * A `pathPattern` is a path with a wildcard segment, thus it takes the same
62
- * projection as a file path. The wildcard characters survive it: the projection
63
- * only re-roots the pattern.
51
+ * A pattern is a path with varying segments, thus it takes the same projection as a file
52
+ * path. Its placeholders survive it: the projection only re-roots the pattern.
64
53
  */
54
+ function rootGroupPatterns(groups, analysisId) {
55
+ return groups.map((group) => ({ ...group, displayPattern: toAnalysisRootPath(analysisId, group.displayPattern) }));
56
+ }
65
57
  function rootKindPatterns(kinds, analysisId) {
66
58
  return kinds.map((kind) => ({ ...kind, pathPattern: toAnalysisRootPath(analysisId, kind.pathPattern) }));
67
59
  }
@@ -103,20 +95,21 @@ export function createInspectDataProfileTool(pool) {
103
95
  id: "inspect_data_profile",
104
96
  description: "Read this analysis's data profile — the AUTHORITATIVE record of what the input dataset is: " +
105
97
  "organism and taxon id, scientific domain and subtype, tissue, cell type, condition, experimental design, " +
106
- "dataset-wide quality concerns and strengths, public accessions, the KINDS of file the dataset is made of " +
107
- "and what varies across them, and per-file data type, format, row/column dimensions, tags and warnings for " +
108
- "the files described individually. " +
98
+ "dataset-wide caveats, public accessions, the GROUPS of file the dataset is made of, the DIMENSIONS that vary " +
99
+ "across it with the evidence behind each, and the file census (kept, unclassified, quarantined). " +
109
100
  "There is NO data-profile file in the workspace — this tool is the only way to read it. Do not search for one, " +
110
101
  "and do not rediscover these facts by listing or reading the raw input files. " +
111
102
  "Call it before you reason about the data (planning, writing analysis code, interpreting results). " +
112
- "scope:'overview' (the default) returns the dataset-level orientation, how many files are described individually, " +
113
- "and how many files the dataset holds — those two differ on purpose, because a large dataset is described by its " +
114
- "kinds rather than file by file. " +
115
- "scope:'kinds' returns those kinds with their counts, path patterns, and axes — this is where a dataset of thousands " +
116
- "of files is described. A profile taken before kinds existed reports the scope unavailable. " +
117
- "scope:'files' returns the individually described files, paged: page (1-based, default 1) and pageSize (default 20, max 100), " +
103
+ "scope:'overview' (the default) returns the dataset-level orientation plus the partition accounting — how many " +
104
+ "files were kept, how many landed in no group, how many were quarantined — and how many members are described " +
105
+ "individually. That last figure and the dataset's file count differ on purpose: a large dataset is described by " +
106
+ "its groups, not file by file. " +
107
+ "scope:'groups' returns the groups with their derived counts, display patterns, and slots, plus the dimensions " +
108
+ "with their observations, reconciliations, and probe outcomes — this is where a dataset of thousands of files is " +
109
+ "described. A profile written under the previous model returns its kinds and axes, labelled as such. " +
110
+ "scope:'files' returns the individually annotated members, paged: page (1-based, default 1) and pageSize (default 20, max 100), " +
118
111
  "always with the true total and hasMore, so you can see exactly what you have not read yet. " +
119
- "For the paths of files no record describes, list the workspace tree. " +
112
+ "For the paths of files no record describes, use scope:'groups' for the structure and list the workspace tree for the paths. " +
120
113
  "The state field says what you got: 'ready'; 'stale' (a profile is returned but may not describe the current " +
121
114
  "inputs — staleReason says why); 'pending' (profiling is still running); 'failed'; or 'absent' (never profiled, " +
122
115
  "or the analysis has no input files). " +
@@ -126,19 +119,19 @@ export function createInspectDataProfileTool(pool) {
126
119
  "needs re-running rather than diagnosing the data itself.",
127
120
  inputSchema: z.object({
128
121
  scope: z
129
- .enum(["overview", "kinds", "files"])
122
+ .enum(["overview", "groups", "files"])
130
123
  .optional()
131
- .describe("'overview' (default): dataset-level facts, described-file count, and dataset file count. " +
132
- "'kinds': the sets the dataset is made of and what varies across them. " +
133
- "'files': paged records for the individually described files."),
134
- page: z.number().int().min(1).optional().describe("1-based page of per-file records. Only used when scope is 'files'. Default 1."),
124
+ .describe("'overview' (default): dataset-level facts, the partition accounting, the annotated-member count, and the dataset file count. " +
125
+ "'groups': the groups the dataset is made of, with their slots, and the dimensions that vary across it. " +
126
+ "'files': paged records for the individually annotated members."),
127
+ page: z.number().int().min(1).optional().describe("1-based page of member records. Only used when scope is 'files'. Default 1."),
135
128
  pageSize: z
136
129
  .number()
137
130
  .int()
138
131
  .min(1)
139
132
  .max(MAX_PAGE_SIZE)
140
133
  .optional()
141
- .describe(`Per-file records per page. Only used when scope is 'files'. Default ${DEFAULT_PAGE_SIZE}, max ${MAX_PAGE_SIZE}.`),
134
+ .describe(`Member records per page. Only used when scope is 'files'. Default ${DEFAULT_PAGE_SIZE}, max ${MAX_PAGE_SIZE}.`),
142
135
  }),
143
136
  // `execute` defaults both fields internally, and the hook applies the same
144
137
  // defaults. Without them the ordinary call, which names no field at all,
@@ -160,8 +153,8 @@ export function createInspectDataProfileTool(pool) {
160
153
  if (status.status === "failed") {
161
154
  // A failure that DID leave a prior profile on the row never reaches
162
155
  // here — it is served as `stale`, where `stalenessReasons` names the
163
- // failed re-profile and compares the input sets through the single
164
- // shared predicate. This branch is the case with nothing to compare.
156
+ // failed re-profile. This branch is the case with no prior profile to
157
+ // serve at all.
165
158
  return ok({
166
159
  state: "failed",
167
160
  error: status.error,
@@ -188,14 +181,16 @@ export function createInspectDataProfileTool(pool) {
188
181
  ? { state: "stale", staleReason: reasons.join("; "), profiledAt: result.profiledAt }
189
182
  : { state: "ready", profiledAt: result.profiledAt };
190
183
  const scope = input.scope ?? "overview";
184
+ const describedFiles = profileFileRecords(result);
185
+ const groups = profileGroups(result);
191
186
  if (scope === "overview") {
192
- const dataset = datasetFileCount(result);
193
- const described = result.files.length;
187
+ const dataset = profileDatasetFileCount(result);
188
+ const described = describedFiles.length;
194
189
  const structureNote = dataset === null
195
190
  ? "This profile predates the dataset-structure record, so only the individually described files are known; " +
196
191
  "list the workspace tree for the rest."
197
192
  : dataset !== described
198
- ? `${described} of ${dataset} files are described individually. Call scope:'kinds' for what the rest are, ` +
193
+ ? `${described} of ${dataset} files are described individually. Call scope:'groups' for what the rest are, ` +
199
194
  "and list the workspace tree for their paths."
200
195
  : undefined;
201
196
  return ok({
@@ -209,40 +204,63 @@ export function createInspectDataProfileTool(pool) {
209
204
  cellType: result.cellType,
210
205
  condition: result.condition,
211
206
  experimentalDesign: result.experimentalDesign,
212
- qualityAssessment: result.qualityAssessment,
207
+ caveats: profileCaveats(result),
213
208
  accessions: result.accessions,
214
209
  describedFileCount: described,
215
210
  datasetFileCount: dataset,
216
- kindCount: result.kinds ? result.kinds.length : null,
211
+ // Presence, not truthiness: a completed profile over a tree the scan kept
212
+ // nothing of carries `groups: []`, and "zero groups" is a finding — reading
213
+ // it as "no structure record" would report the analysis unprofiled.
214
+ groupCount: result.groups !== undefined || result.kinds !== undefined ? groups.length : null,
217
215
  ...(structureNote ? { structureNote } : {}),
216
+ ...(result.partition ? { partition: result.partition } : {}),
218
217
  ...(result.coverage ? { coverage: result.coverage } : {}),
219
218
  });
220
219
  }
221
- if (scope === "kinds") {
222
- if (!result.kinds) {
220
+ if (scope === "groups") {
221
+ if (result.groups !== undefined) {
222
+ return ok({
223
+ ...envelope,
224
+ scope: "groups",
225
+ available: true,
226
+ groups: rootGroupPatterns(result.groups, resourceId),
227
+ ...(result.dimensions ? { dimensions: result.dimensions } : {}),
228
+ ...(result.probes ? { probes: result.probes } : {}),
229
+ ...(result.partition ? { partition: result.partition } : {}),
230
+ ...(result.recipe ? { recipe: result.recipe } : {}),
231
+ });
232
+ }
233
+ if (result.kinds === undefined) {
223
234
  return ok({
224
235
  ...envelope,
225
- scope: "kinds",
236
+ scope: "groups",
226
237
  available: false,
227
- message: "This profile was taken before the dataset-structure record existed, so it carries no kinds. " +
238
+ message: "This profile was taken before the dataset-structure record existed, so it carries no groups. " +
228
239
  "That is a fact about the profile, not about the dataset: use scope:'files' and the workspace " +
229
240
  "listing tools, or re-profile the analysis.",
230
241
  });
231
242
  }
243
+ // The structure exists; only its vocabulary is older. Serving it labelled beats
244
+ // reporting the scope unavailable, which would tell an agent the dataset has no
245
+ // structure when the row plainly holds some.
232
246
  return ok({
233
247
  ...envelope,
234
- scope: "kinds",
248
+ scope: "groups",
235
249
  available: true,
250
+ legacy: true,
236
251
  kinds: rootKindPatterns(result.kinds, resourceId),
237
252
  ...(result.axes ? { axes: result.axes } : {}),
238
253
  ...(result.coverage ? { coverage: result.coverage } : {}),
254
+ message: "Authored under the previous model: these are kinds and axes, not resolved groups and dimensions. " +
255
+ "Counts and patterns were the profiler's own claims rather than a computed partition, and there is no " +
256
+ "unclassified sweep or quarantine accounting on a profile of that era.",
239
257
  });
240
258
  }
241
259
  const pageSize = input.pageSize ?? DEFAULT_PAGE_SIZE;
242
260
  const page = input.page ?? 1;
243
- const total = result.files.length;
261
+ const total = describedFiles.length;
244
262
  const start = (page - 1) * pageSize;
245
- const files = result.files.slice(start, start + pageSize);
263
+ const files = describedFiles.slice(start, start + pageSize);
246
264
  return ok({
247
265
  ...envelope,
248
266
  scope: "files",
@@ -250,6 +268,7 @@ export function createInspectDataProfileTool(pool) {
250
268
  pageSize,
251
269
  total,
252
270
  hasMore: start + files.length < total,
271
+ datasetFileCount: profileDatasetFileCount(result),
253
272
  files: rootFilePaths(files, resourceId),
254
273
  });
255
274
  },
@@ -7,9 +7,9 @@
7
7
  * with the observed shapes, or wants a directory the injected manifest summarised, can
8
8
  * get the evidence to group differently.
9
9
  *
10
- * Dependency-bearing factory. The walk and the shape observation run in-process over
11
- * the workspace read seam; only the per-shape header readout reaches into the sandbox
12
- * (see `input-scan/enrich.ts`).
10
+ * Dependency-bearing factory. The walk, the shape observation, and every
11
+ * prefix-sufficient header readout run in-process over the workspace read seam; only a
12
+ * footer-indexed container reaches into the sandbox (see `input-scan/enrich.ts`).
13
13
  */
14
14
  import type { SandboxClient } from "../../sandbox/client.js";
15
15
  import type { SandboxRef } from "../../sandbox/types.js";
@@ -17,7 +17,7 @@ import type { WorkspaceFilesystem } from "../../workspace/filesystem.js";
17
17
  export interface ScanInputsDeps {
18
18
  readonly workspaceFs: WorkspaceFilesystem;
19
19
  readonly analysisId: string;
20
- /** Omit to run the host-side scan with no header readout (no sandbox wired). */
20
+ /** Omit to skip the container readouts; the prefix readouts run regardless. */
21
21
  readonly sandboxClient?: SandboxClient;
22
22
  readonly sandbox?: SandboxRef;
23
23
  readonly workflowId: string;
@@ -7,9 +7,9 @@
7
7
  * with the observed shapes, or wants a directory the injected manifest summarised, can
8
8
  * get the evidence to group differently.
9
9
  *
10
- * Dependency-bearing factory. The walk and the shape observation run in-process over
11
- * the workspace read seam; only the per-shape header readout reaches into the sandbox
12
- * (see `input-scan/enrich.ts`).
10
+ * Dependency-bearing factory. The walk, the shape observation, and every
11
+ * prefix-sufficient header readout run in-process over the workspace read seam; only a
12
+ * footer-indexed container reaches into the sandbox (see `input-scan/enrich.ts`).
13
13
  */
14
14
  import { ok } from "neverthrow";
15
15
  import { z } from "zod";
@@ -38,17 +38,16 @@ export function createScanInputsTool(deps) {
38
38
  root: path.replace(/^\/+/, "").replace(/\/+$/, ""),
39
39
  signal: ctx.signal,
40
40
  });
41
- const shapes = deps.sandboxClient && deps.sandbox
42
- ? await enrichShapes({
43
- shapes: scan.manifest.shapes,
44
- sandboxClient: deps.sandboxClient,
45
- sandbox: deps.sandbox,
46
- mountRoot: `/${deps.analysisId}`,
47
- execId: `${deps.workflowId}:${deps.stepId}:${deps.nextFunctionId()}`,
48
- deadlineMs: deps.deadlineMs(),
49
- emit: ctx.emit,
50
- })
51
- : scan.manifest.shapes;
41
+ const shapes = await enrichShapes({
42
+ shapes: scan.manifest.shapes,
43
+ session: ctx.session,
44
+ fs: deps.workspaceFs,
45
+ ...(deps.sandboxClient && deps.sandbox ? { sandboxClient: deps.sandboxClient, sandbox: deps.sandbox } : {}),
46
+ mountRoot: `/${deps.analysisId}`,
47
+ execId: `${deps.workflowId}:${deps.stepId}:${deps.nextFunctionId()}`,
48
+ deadlineMs: deps.deadlineMs(),
49
+ emit: ctx.emit,
50
+ });
52
51
  const manifest = { ...scan.manifest, shapes };
53
52
  return ok({
54
53
  path: manifest.root,
@@ -12,14 +12,23 @@
12
12
  */
13
13
  import type { Pool } from "pg";
14
14
  import type { EmbeddingProvider } from "../../providers/types.js";
15
+ /**
16
+ * The workspace path a hit addresses, or `undefined` for a pattern entry. Resolved here,
17
+ * once, across every index era — group/dimension/kind entries and previous-era entity
18
+ * entries (`entity` in metadata) are patterns; everything else is a file whose path is
19
+ * stamped in metadata or, on rows older than the stamp, is the id minus the resource
20
+ * segment. The consumer branches on presence, nothing else.
21
+ */
22
+ export declare function fileTargetOf(id: string, metadata: unknown): string | undefined;
15
23
  export declare function createWorkspaceSearchTool(pool: Pool, embedding: EmbeddingProvider): import("../define-tool.js").Tool<{
16
24
  query: string;
17
25
  limit: number;
18
- type?: "output" | "input" | "synthesis" | "summary" | "input-kind" | undefined;
26
+ type?: "output" | "input" | "synthesis" | "summary" | "input-group" | "input-dimension" | "input-kind" | undefined;
19
27
  }, {
20
28
  results: {
29
+ metadata: unknown;
30
+ path?: string | undefined;
21
31
  id: string;
22
32
  score: number;
23
- metadata: unknown;
24
33
  }[];
25
34
  }>;
@@ -29,25 +29,49 @@ function indexName(analysisId) {
29
29
  }
30
30
  return name;
31
31
  }
32
+ /** Entry types that address a pattern spanning many files, never a single one. */
33
+ const PATTERN_TYPES = new Set(["input-group", "input-dimension", "input-kind"]);
34
+ /**
35
+ * The workspace path a hit addresses, or `undefined` for a pattern entry. Resolved here,
36
+ * once, across every index era — group/dimension/kind entries and previous-era entity
37
+ * entries (`entity` in metadata) are patterns; everything else is a file whose path is
38
+ * stamped in metadata or, on rows older than the stamp, is the id minus the resource
39
+ * segment. The consumer branches on presence, nothing else.
40
+ */
41
+ export function fileTargetOf(id, metadata) {
42
+ const meta = metadata && typeof metadata === "object" ? metadata : {};
43
+ const type = typeof meta.type === "string" ? meta.type : undefined;
44
+ if (type !== undefined && PATTERN_TYPES.has(type))
45
+ return undefined;
46
+ if (type === "input" && meta.entity !== undefined)
47
+ return undefined;
48
+ if (typeof meta.path === "string")
49
+ return meta.path;
50
+ return id.replace(/^\/[^/]+\//, "");
51
+ }
32
52
  export function createWorkspaceSearchTool(pool, embedding) {
33
53
  return defineTool({
34
54
  id: "workspace_search",
35
55
  description: "Semantic search over the analysis workspace. Returns ranked entries with descriptions " +
36
- "and metadata; read a file separately to see its contents. Indexed entries are exactly " +
37
- 'five types, and `type` restricts results to one of them: "input-kind" (a KIND of input ' +
38
- "data — a repeating set the data profiler named, carrying its count and a pathPattern " +
39
- 'matching its members), "input" (one entity the dataset is about — a subject, sample, or ' +
40
- 'other axis value — with the kinds of file it carries), "output" (files a step produced), ' +
41
- '"summary" (a step\'s summary), "synthesis" (a run\'s literature-grounded synthesis). ' +
42
- "For output/summary/synthesis the entry id IS the workspace path; for the two input types " +
43
- "it is not, because a kind and an entity each span many files — search input-kind for a " +
44
- "set's pathPattern, then list the tree for its members.",
56
+ "and metadata; read a file separately to see its contents. `type` restricts results to one " +
57
+ 'entry type: "input-group" (a GROUP of input data the data profiler declared, carrying its ' +
58
+ 'derived count, its display pattern, and its slots), "input-dimension" (something that VARIES ' +
59
+ "across the dataset — subject, timepoint, batch — with the observations evidencing it), " +
60
+ '"input" (one input file the profiler annotated individually, or, on a profile of the ' +
61
+ 'previous era, one entity the dataset is about), "output" (files a step produced), ' +
62
+ '"summary" (a step\'s summary), "synthesis" (a run\'s literature-grounded synthesis), ' +
63
+ '"input-kind" (a repeating set, on a profile of the previous era). ' +
64
+ "A hit carrying `path` is a FILE — read that path to see its contents. A hit without " +
65
+ "`path` is a PATTERN spanning many files (a group, a dimension, an entity): use its " +
66
+ "metadata, e.g. search input-group for a group's display pattern, then list the tree for " +
67
+ "its members. Most input files carry no entry of their own — find them through their " +
68
+ "group and the listing tools.",
45
69
  inputSchema: z.object({
46
70
  query: z.string().min(1).describe("What to search for, in natural language"),
47
71
  type: z
48
- .enum(["input-kind", "input", "output", "summary", "synthesis"])
72
+ .enum(["input-group", "input-dimension", "input", "input-kind", "output", "summary", "synthesis"])
49
73
  .optional()
50
- .describe('Restrict results to one entry type: "input-kind", "input", "output", "summary", or "synthesis"'),
74
+ .describe('Restrict results to one entry type: "input-group", "input-dimension", "input", "input-kind", "output", "summary", or "synthesis"'),
51
75
  limit: z.number().int().min(1).max(50).default(8).describe("Maximum number of results to return"),
52
76
  }),
53
77
  describeCall: ({ query, type }) => (type === undefined ? query : `${query} (${type})`),
@@ -77,11 +101,15 @@ export function createWorkspaceSearchTool(pool, embedding) {
77
101
  // exist. That is absence, not a failure: recover into ok([]).
78
102
  isUndefinedTable(e.cause) ? okAsync([]) : err(e)));
79
103
  return ok({
80
- results: rows.map((r) => ({
81
- id: r.id,
82
- score: r.score,
83
- metadata: r.metadata,
84
- })),
104
+ results: rows.map((r) => {
105
+ const path = fileTargetOf(r.id, r.metadata);
106
+ return {
107
+ id: r.id,
108
+ score: r.score,
109
+ ...(path !== undefined ? { path } : {}),
110
+ metadata: r.metadata,
111
+ };
112
+ }),
85
113
  });
86
114
  },
87
115
  });
@@ -500,16 +500,46 @@ export async function runSandboxStepBody(input, deps) {
500
500
  // outputs, so it fails the step loudly instead of finishing green. Transient
501
501
  // managed-root errors are already retried in the client, so anything thrown here is
502
502
  // persistent — tear down, mark failed, and re-raise so the parent's fail-fast
503
- // cascade fires (mirrors the agent-loop failure path).
503
+ // cascade fires (mirrors the agent-loop failure path). The two operations are
504
+ // sequenced independently: byte-sync is attempted on both arms, so a rejected
505
+ // registration still uploads the bytes of whatever DID register.
504
506
  await emitActivity("persisting", "Registering artifacts");
507
+ // A bare call, deliberately not `DBOS.runStep`-wrapped: it is reachable from
508
+ // two arms below, and a checkpointed step reached from a conditional arm
509
+ // would shift the body's function-ID sequence between the two paths (see the
510
+ // harness-durable-runtime spec).
511
+ const syncStepArtifacts = () => deps.artifactRegistry.sync({
512
+ resourceId: input.analysisId,
513
+ runId: input.runId,
514
+ stepId: input.stepId,
515
+ }, session);
505
516
  let reconciledManifest;
506
517
  try {
507
518
  reconciledManifest = await reconcileAndRegisterStepArtifacts(deps, postCtx, manifest);
508
- await deps.artifactRegistry.sync({
509
- resourceId: input.analysisId,
510
- runId: input.runId,
511
- stepId: input.stepId,
512
- }, session);
519
+ }
520
+ catch (registrationErr) {
521
+ // Registration and byte-sync are independent, and a registry rejection is
522
+ // partial: the rows that DID register carry `artifact_id` with `file_id`
523
+ // still NULL, which is exactly what `queryUnsyncedStepArtifacts` selects.
524
+ // Syncing anyway uploads their bytes (rows that never registered are not
525
+ // selected, so this stays idempotent under re-execution); skipping it is
526
+ // what strands them registered-but-byteless.
527
+ try {
528
+ await syncStepArtifacts();
529
+ }
530
+ catch (syncErr) {
531
+ // A cancellation is control-flow, not a sync failure — re-raise it
532
+ // verbatim, as every other stage in this body does. Any real failure
533
+ // is swallowed so it cannot displace the registration rejection as the
534
+ // step's cause; this record is the only account of it.
535
+ if (syncErr instanceof DBOSErrors.DBOSWorkflowCancelledError)
536
+ throw syncErr;
537
+ logger.named("post-step.sync").warn("failed after a registration rejection (non-fatal)", logger.errorFields(syncErr));
538
+ }
539
+ throw await failStep("lineage_attestation", registrationErr);
540
+ }
541
+ try {
542
+ await syncStepArtifacts();
513
543
  }
514
544
  catch (err) {
515
545
  throw await failStep("lineage_attestation", err);
@@ -124,6 +124,27 @@ export declare const Phase4OutputSchema: z.ZodObject<{
124
124
  coverage: z.ZodLiteral<"not_loaded">;
125
125
  reason: z.ZodOptional<z.ZodString>;
126
126
  }, z.core.$strip>], "coverage">;
127
+ entity: z.ZodObject<{
128
+ canonicalId: z.ZodString;
129
+ symbol: z.ZodString;
130
+ displayName: z.ZodString;
131
+ entityType: z.ZodEnum<{
132
+ gene: "gene";
133
+ rna: "rna";
134
+ protein: "protein";
135
+ complex: "complex";
136
+ }>;
137
+ ontology: z.ZodString;
138
+ identifiers: z.ZodObject<{
139
+ hgnc: z.ZodOptional<z.ZodString>;
140
+ ensembl: z.ZodOptional<z.ZodString>;
141
+ uniprot: z.ZodOptional<z.ZodString>;
142
+ chembl: z.ZodOptional<z.ZodString>;
143
+ ncbiGene: z.ZodOptional<z.ZodString>;
144
+ }, z.core.$strip>;
145
+ synonyms: z.ZodDefault<z.ZodArray<z.ZodString>>;
146
+ proteinFamily: z.ZodOptional<z.ZodString>;
147
+ }, z.core.$strip>;
127
148
  off_target_panel: z.ZodDiscriminatedUnion<[z.ZodObject<{
128
149
  coverage: z.ZodLiteral<"available">;
129
150
  data: z.ZodObject<{
@@ -347,27 +368,6 @@ export declare const Phase4OutputSchema: z.ZodObject<{
347
368
  inferred_therapeutic_area: z.ZodNullable<z.ZodString>;
348
369
  no_liabilities_disclosure: z.ZodOptional<z.ZodString>;
349
370
  }, z.core.$strict>;
350
- entity: z.ZodObject<{
351
- canonicalId: z.ZodString;
352
- symbol: z.ZodString;
353
- displayName: z.ZodString;
354
- entityType: z.ZodEnum<{
355
- gene: "gene";
356
- rna: "rna";
357
- protein: "protein";
358
- complex: "complex";
359
- }>;
360
- ontology: z.ZodString;
361
- identifiers: z.ZodObject<{
362
- hgnc: z.ZodOptional<z.ZodString>;
363
- ensembl: z.ZodOptional<z.ZodString>;
364
- uniprot: z.ZodOptional<z.ZodString>;
365
- chembl: z.ZodOptional<z.ZodString>;
366
- ncbiGene: z.ZodOptional<z.ZodString>;
367
- }, z.core.$strip>;
368
- synonyms: z.ZodDefault<z.ZodArray<z.ZodString>>;
369
- proteinFamily: z.ZodOptional<z.ZodString>;
370
- }, z.core.$strip>;
371
371
  indications: z.ZodDiscriminatedUnion<[z.ZodObject<{
372
372
  coverage: z.ZodLiteral<"available">;
373
373
  data: z.ZodObject<{
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@inflexa-ai/harness",
3
- "version": "0.28.0",
3
+ "version": "0.29.0",
4
4
  "description": "Host-agnostic bioinformatics agent harness — hand-rolled agent loop, DBOS-durable workflows, sandboxed compute.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -33,15 +33,17 @@
33
33
  "directory": "harness"
34
34
  },
35
35
  "scripts": {
36
- "build": "tsc -p tsconfig.json",
36
+ "build": "tsc -p tsconfig.json && node scripts/copy-assets.mjs",
37
37
  "design:fixture": "bun scripts/render-fixture.ts",
38
38
  "format": "prettier --write src/",
39
39
  "format:file": "prettier --write",
40
40
  "lint": "eslint .",
41
- "prepare": "tsc -p tsconfig.json",
41
+ "lint:python": "ruff check src/input-scan/decoder && ruff format --check src/input-scan/decoder",
42
+ "prepare": "tsc -p tsconfig.json && node scripts/copy-assets.mjs",
42
43
  "prepublishOnly": "bun run build && bun run smoke",
43
44
  "smoke": "node scripts/smoke.mjs",
44
45
  "test:full": "bash scripts/test-full.sh",
46
+ "test:python": "python3 -m pytest src/input-scan/decoder -q",
45
47
  "typecheck": "tsc --noEmit"
46
48
  },
47
49
  "dependencies": {
@@ -1,22 +0,0 @@
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;