@inflexa-ai/harness 0.23.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 (123) hide show
  1. package/README.md +1 -2
  2. package/dist/agents/conversation-agent.d.ts +0 -9
  3. package/dist/agents/sandbox/__fixtures__/deps.js +6 -0
  4. package/dist/agents/sandbox/data-profiler.js +5 -4
  5. package/dist/agents/sandbox/shared.js +11 -0
  6. package/dist/agents/sandbox/types.d.ts +1 -1
  7. package/dist/app/data-profile-orientation.js +30 -1
  8. package/dist/app/data-profile-policy.d.ts +33 -7
  9. package/dist/app/data-profile-policy.js +31 -9
  10. package/dist/contracts/chat-parts.d.ts +29 -33
  11. package/dist/contracts/content-url.d.ts +7 -41
  12. package/dist/contracts/content-url.js +7 -48
  13. package/dist/contracts/data-profile.d.ts +166 -0
  14. package/dist/contracts/index.d.ts +3 -2
  15. package/dist/contracts/index.js +1 -1
  16. package/dist/contracts/message.d.ts +1 -1
  17. package/dist/contracts/message.js +1 -1
  18. package/dist/contracts/part-registry.js +2 -3
  19. package/dist/contracts/schemas/chat-parts.d.ts +16 -53
  20. package/dist/contracts/schemas/chat-parts.js +15 -25
  21. package/dist/contracts/schemas/index.d.ts +1 -1
  22. package/dist/contracts/schemas/index.js +1 -1
  23. package/dist/execution/input-signature.d.ts +33 -0
  24. package/dist/execution/input-signature.js +0 -0
  25. package/dist/execution/run-authorizer.d.ts +1 -1
  26. package/dist/execution/run-authorizer.js +1 -1
  27. package/dist/index.d.ts +5 -4
  28. package/dist/index.js +5 -2
  29. package/dist/input-scan/coverage.d.ts +22 -0
  30. package/dist/input-scan/coverage.js +85 -0
  31. package/dist/input-scan/enrich.d.ts +41 -0
  32. package/dist/input-scan/enrich.js +227 -0
  33. package/dist/input-scan/formats.d.ts +41 -0
  34. package/dist/input-scan/formats.js +279 -0
  35. package/dist/input-scan/index.d.ts +12 -0
  36. package/dist/input-scan/index.js +11 -0
  37. package/dist/input-scan/scan.d.ts +50 -0
  38. package/dist/input-scan/scan.js +182 -0
  39. package/dist/input-scan/shapes.d.ts +46 -0
  40. package/dist/input-scan/shapes.js +0 -0
  41. package/dist/input-scan/types.d.ts +172 -0
  42. package/dist/input-scan/types.js +12 -0
  43. package/dist/lib/zod-issues.d.ts +2 -3
  44. package/dist/lib/zod-issues.js +2 -3
  45. package/dist/loop/__fixtures__/scripted-provider.js +3 -1
  46. package/dist/loop/run-agent.d.ts +5 -0
  47. package/dist/loop/run-agent.js +10 -1
  48. package/dist/loop/run-to-terminal.d.ts +1 -1
  49. package/dist/loop/run-to-terminal.js +1 -1
  50. package/dist/memory/card-builders.d.ts +0 -27
  51. package/dist/memory/card-builders.js +0 -75
  52. package/dist/memory/conversation-display-backfill.d.ts +2 -4
  53. package/dist/memory/conversation-display-backfill.js +3 -9
  54. package/dist/memory/conversation-display-storage.d.ts +3 -4
  55. package/dist/memory/conversation-display-storage.js +3 -4
  56. package/dist/memory/reconstruct-cards.d.ts +5 -7
  57. package/dist/memory/reconstruct-cards.js +6 -20
  58. package/dist/prompts/sandbox/data-profiler.d.ts +1 -1
  59. package/dist/prompts/sandbox/data-profiler.js +159 -275
  60. package/dist/provenance/exec-frame.js +9 -3
  61. package/dist/runtime/boot.js +2 -12
  62. package/dist/sandbox/client.d.ts +7 -0
  63. package/dist/sandbox/create-sandbox.js +1 -0
  64. package/dist/sandbox/docker-client.d.ts +1 -0
  65. package/dist/sandbox/docker-client.js +20 -15
  66. package/dist/sandbox/k8s-client.d.ts +6 -5
  67. package/dist/sandbox/k8s-client.js +55 -39
  68. package/dist/sandbox/reaper.d.ts +24 -13
  69. package/dist/sandbox/reaper.js +47 -14
  70. package/dist/sandbox/types.d.ts +6 -5
  71. package/dist/schemas/data-profile-schemas.d.ts +62 -0
  72. package/dist/schemas/data-profile-schemas.js +68 -2
  73. package/dist/state/data-profile.d.ts +7 -88
  74. package/dist/state/index.d.ts +1 -1
  75. package/dist/tasks/data-profile-activity.d.ts +8 -0
  76. package/dist/tasks/data-profile-activity.js +1 -0
  77. package/dist/tasks/data-profile-index.d.ts +45 -0
  78. package/dist/tasks/data-profile-index.js +134 -0
  79. package/dist/tasks/data-profile.d.ts +8 -23
  80. package/dist/tasks/data-profile.js +150 -117
  81. package/dist/tools/report-session/preview-report.d.ts +2 -1
  82. package/dist/tools/report-session/preview-report.js +9 -1
  83. package/dist/tools/research/generate-plan.js +1 -1
  84. package/dist/tools/research/inspect-data-profile.d.ts +30 -4
  85. package/dist/tools/research/inspect-data-profile.js +66 -10
  86. package/dist/tools/start-report-session.js +2 -2
  87. package/dist/tools/workspace/scan-inputs.d.ts +38 -0
  88. package/dist/tools/workspace/scan-inputs.js +61 -0
  89. package/dist/tools/workspace/workspace-search.d.ts +1 -1
  90. package/dist/tools/workspace/workspace-search.js +12 -9
  91. package/dist/workspace/filesystem.d.ts +27 -0
  92. package/dist/workspace/filesystem.js +27 -0
  93. package/dist/workspace/paths.d.ts +0 -21
  94. package/dist/workspace/paths.js +0 -37
  95. package/package.json +1 -3
  96. package/dist/agents/report-builder.d.ts +0 -34
  97. package/dist/agents/report-builder.js +0 -34
  98. package/dist/execution/report-render.d.ts +0 -39
  99. package/dist/execution/report-render.js +0 -98
  100. package/dist/execution/report-runner.d.ts +0 -111
  101. package/dist/execution/report-runner.js +0 -230
  102. package/dist/prompts/report-builder.d.ts +0 -1
  103. package/dist/prompts/report-builder.js +0 -174
  104. package/dist/tools/iterate-report.d.ts +0 -227
  105. package/dist/tools/iterate-report.js +0 -883
  106. package/dist/tools/lib/preview-lock.d.ts +0 -20
  107. package/dist/tools/lib/preview-lock.js +0 -35
  108. package/dist/tools/lib/report-preflight.d.ts +0 -39
  109. package/dist/tools/lib/report-preflight.js +0 -283
  110. package/dist/tools/report/build-report.d.ts +0 -13
  111. package/dist/tools/report/build-report.js +0 -27
  112. package/dist/tools/report/index.d.ts +0 -15
  113. package/dist/tools/report/index.js +0 -15
  114. package/dist/tools/report/mint-preview-url.d.ts +0 -26
  115. package/dist/tools/report/mint-preview-url.js +0 -47
  116. package/dist/tools/report/preview-publisher.d.ts +0 -53
  117. package/dist/tools/report/preview-publisher.js +0 -45
  118. package/dist/tools/report/preview-snapshot.d.ts +0 -34
  119. package/dist/tools/report/preview-snapshot.js +0 -98
  120. package/dist/tools/report/submit-report.d.ts +0 -26
  121. package/dist/tools/report/submit-report.js +0 -148
  122. package/dist/tools/report/version-fs.d.ts +0 -15
  123. package/dist/tools/report/version-fs.js +0 -165
@@ -50,6 +50,14 @@ export type ActivityWrite = (part: unknown) => Promise<void>;
50
50
  export interface ProfileActivityEmitter {
51
51
  /** Reported before the sandbox is created — the longest wait in a profile, and the one that precedes the agent entirely. */
52
52
  sandboxInit(): Promise<void>;
53
+ /**
54
+ * Reported before the deterministic input scan, which runs between a ready sandbox
55
+ * and the agent's first turn. On a large tree it is the second-longest operation in
56
+ * a profile, and left unreported it would read as `Running data-profiler` for
57
+ * minutes before the agent had begun — the misreport `sandbox-init` exists to
58
+ * prevent.
59
+ */
60
+ scanning(): Promise<void>;
53
61
  /** Reported once the sandbox is ready, covering the gap before the agent's first tool call. */
54
62
  agentStarting(): Promise<void>;
55
63
  /**
@@ -47,6 +47,7 @@ export function createProfileActivityEmitter(write, frame, logger) {
47
47
  };
48
48
  return {
49
49
  sandboxInit: () => emit("sandbox-init", "Starting sandbox"),
50
+ scanning: () => emit("executing", "Scanning input files"),
50
51
  agentStarting: () => emit("executing", "Running data-profiler"),
51
52
  forTool: (name, input, resolveDetail) => emit("executing", activityForTool(name, input, resolveDetail)),
52
53
  indexing: () => emit("indexing", "Indexing input descriptions for search"),
@@ -0,0 +1,45 @@
1
+ /**
2
+ * The profile's vector index, as a pure projection.
3
+ *
4
+ * The index is a function of the scan crossed with the submitted kinds: for each kind,
5
+ * one entry; for each entity, one entry templated from the kinds it participates in and
6
+ * its own axis value. It has no persisted representation of its own and nothing derives
7
+ * from it, so it can be rebuilt at any time by re-running the scan and reading `kinds`
8
+ * off the existing profile — no model call, no re-profile. Improving the template,
9
+ * adding a tier, or folding in entity attributes later are all re-projections.
10
+ *
11
+ * Two tiers, because they answer different queries. A query naming a KIND of data
12
+ * ("variant calls") wants one result carrying the set's count and path pattern; a
13
+ * per-entity tier would return five arbitrary subjects out of thousands of
14
+ * indistinguishable ones. A query naming an ENTITY ("PT0421") wants that entity. A
15
+ * per-file tier adds nothing: a file is identified by its kind and its entity, and its
16
+ * path is on the filesystem.
17
+ *
18
+ * `type: "input"` keeps its existing meaning for the entity tier, so searches written
19
+ * against it keep matching; `type: "input-kind"` is additive. Renaming `"input"` would
20
+ * have silently broken existing search on every new analysis.
21
+ */
22
+ import type { InputScan } from "../input-scan/types.js";
23
+ import type { DataProfileAxis, DataProfileFile, DataProfileKind } from "../state/data-profile.js";
24
+ /** Entity entries built per analysis. Past this the entity tier is a liability, not a search aid. */
25
+ export declare const MAX_ENTITY_ENTRIES = 20000;
26
+ export interface ProfileIndexEntry {
27
+ readonly id: string;
28
+ readonly text: string;
29
+ readonly metadata: Record<string, unknown>;
30
+ }
31
+ export interface BuildProfileIndexArgs {
32
+ readonly analysisId: string;
33
+ readonly kinds: readonly DataProfileKind[];
34
+ readonly axes?: readonly DataProfileAxis[];
35
+ /** The scan the profile was taken against — the source of the entity value set. */
36
+ readonly scan?: InputScan;
37
+ /** Individually described files, indexed only when there are no kinds to index. */
38
+ readonly files?: readonly DataProfileFile[];
39
+ }
40
+ /**
41
+ * Build every index entry for a completed profile. Deterministic and model-free: the
42
+ * text is templated from the kind's own description and the entity's axis value, so
43
+ * indexing costs no LLM tokens whatever the dataset's size.
44
+ */
45
+ export declare function buildProfileIndexEntries(args: BuildProfileIndexArgs): ProfileIndexEntry[];
@@ -0,0 +1,134 @@
1
+ /**
2
+ * The profile's vector index, as a pure projection.
3
+ *
4
+ * The index is a function of the scan crossed with the submitted kinds: for each kind,
5
+ * one entry; for each entity, one entry templated from the kinds it participates in and
6
+ * its own axis value. It has no persisted representation of its own and nothing derives
7
+ * from it, so it can be rebuilt at any time by re-running the scan and reading `kinds`
8
+ * off the existing profile — no model call, no re-profile. Improving the template,
9
+ * adding a tier, or folding in entity attributes later are all re-projections.
10
+ *
11
+ * Two tiers, because they answer different queries. A query naming a KIND of data
12
+ * ("variant calls") wants one result carrying the set's count and path pattern; a
13
+ * per-entity tier would return five arbitrary subjects out of thousands of
14
+ * indistinguishable ones. A query naming an ENTITY ("PT0421") wants that entity. A
15
+ * per-file tier adds nothing: a file is identified by its kind and its entity, and its
16
+ * path is on the filesystem.
17
+ *
18
+ * `type: "input"` keeps its existing meaning for the entity tier, so searches written
19
+ * against it keep matching; `type: "input-kind"` is additive. Renaming `"input"` would
20
+ * have silently broken existing search on every new analysis.
21
+ */
22
+ /** Entity entries built per analysis. Past this the entity tier is a liability, not a search aid. */
23
+ export const MAX_ENTITY_ENTRIES = 20_000;
24
+ /** Kind names named in one entity's entry text. */
25
+ const MAX_KINDS_IN_ENTITY_TEXT = 6;
26
+ function slug(value) {
27
+ return value
28
+ .toLowerCase()
29
+ .replace(/[^a-z0-9]+/g, "-")
30
+ .replace(/^-|-$/g, "")
31
+ .slice(0, 60);
32
+ }
33
+ /**
34
+ * The entity value set: the largest distinct-value set any single variable position
35
+ * carries.
36
+ *
37
+ * Entities are what a dataset is ABOUT, and the position that varies most widely across
38
+ * one shape is what identifies them — 1171 subjects, against three timepoints and two
39
+ * replicates. Taking the widest position rather than the cross product is deliberate:
40
+ * the cross product is the file set, which is the tier this projection exists to avoid.
41
+ */
42
+ function entityValues(scan) {
43
+ if (!scan)
44
+ return { values: [], distinct: 0 };
45
+ let best = [];
46
+ for (const shape of scan.manifest.shapes) {
47
+ const positions = scan.positionValues.get(shape.id);
48
+ if (!positions)
49
+ continue;
50
+ for (const position of shape.variablePositions) {
51
+ const values = positions[position.index];
52
+ if (values && values.length > best.length)
53
+ best = values;
54
+ }
55
+ }
56
+ return { values: [...best].sort((a, b) => a.localeCompare(b, "en")), distinct: best.length };
57
+ }
58
+ /**
59
+ * The axis label for an entity set. The agent labels axes; the scan counts values, so
60
+ * the axis whose cardinality matches the observed set is the one the agent was naming.
61
+ * With no match the entries still index under a neutral label rather than not at all.
62
+ */
63
+ function entityAxisLabel(axes, distinct) {
64
+ if (!axes || axes.length === 0)
65
+ return "entity";
66
+ const exact = axes.find((axis) => axis.cardinality === distinct);
67
+ if (exact)
68
+ return exact.label;
69
+ const widest = [...axes].sort((a, b) => b.cardinality - a.cardinality)[0];
70
+ return widest?.label ?? "entity";
71
+ }
72
+ function kindEntry(analysisId, kind) {
73
+ const facts = [`${kind.count} files`, kind.format, kind.pathPattern].filter((f) => Boolean(f)).join(", ");
74
+ return {
75
+ id: `/${analysisId}/kind/${slug(kind.name)}`,
76
+ text: `${kind.name} — ${kind.description} One member is ${kind.memberRepresents}. (${facts})`,
77
+ metadata: {
78
+ type: "input-kind",
79
+ kind: kind.name,
80
+ pathPattern: kind.pathPattern,
81
+ count: kind.count,
82
+ ...(kind.format ? { format: kind.format } : {}),
83
+ ...(kind.axisLabels ? { axisLabels: kind.axisLabels } : {}),
84
+ },
85
+ };
86
+ }
87
+ function entityEntry(analysisId, label, value, kinds) {
88
+ const named = kinds.slice(0, MAX_KINDS_IN_ENTITY_TEXT);
89
+ const carried = named.map((kind) => `${kind.name} (${kind.memberRepresents})`).join("; ");
90
+ return {
91
+ id: `/${analysisId}/entity/${encodeURIComponent(value)}`,
92
+ text: `${label} ${value} — input data for this ${label}: ${carried}.`,
93
+ metadata: {
94
+ type: "input",
95
+ entity: value,
96
+ axis: label,
97
+ kinds: named.map((kind) => kind.name),
98
+ },
99
+ };
100
+ }
101
+ /** A file entry, for the degenerate case where a profile submitted no kinds at all. */
102
+ function fileEntry(analysisId, file) {
103
+ return {
104
+ id: `/${analysisId}/${file.path}`,
105
+ text: `${file.path} — ${file.description}`,
106
+ metadata: {
107
+ type: "input",
108
+ ...(file.dataType ? { dataType: file.dataType } : {}),
109
+ ...(file.format ? { format: file.format } : {}),
110
+ ...(file.tags ? { tags: file.tags } : {}),
111
+ },
112
+ };
113
+ }
114
+ /**
115
+ * Build every index entry for a completed profile. Deterministic and model-free: the
116
+ * text is templated from the kind's own description and the entity's axis value, so
117
+ * indexing costs no LLM tokens whatever the dataset's size.
118
+ */
119
+ export function buildProfileIndexEntries(args) {
120
+ const { analysisId, kinds, axes, scan, files } = args;
121
+ if (kinds.length === 0) {
122
+ // No kinds is a degenerate profile, but the notable files it did describe stay
123
+ // discoverable — the same losslessness the per-file fallback description had.
124
+ return (files ?? []).map((file) => fileEntry(analysisId, file));
125
+ }
126
+ const entries = kinds.map((kind) => kindEntry(analysisId, kind));
127
+ const { values, distinct } = entityValues(scan);
128
+ if (values.length > 1 && values.length <= MAX_ENTITY_ENTRIES) {
129
+ const label = entityAxisLabel(axes, distinct);
130
+ for (const value of values)
131
+ entries.push(entityEntry(analysisId, label, value, kinds));
132
+ }
133
+ return entries;
134
+ }
@@ -1,9 +1,10 @@
1
1
  /**
2
- * Data-profile DBOS workflow — profiles the already-staged input files, runs
3
- * the data-profiler sandbox agent, writes per-file metadata to
4
- * cortex_artifacts, and indexes descriptions into the pgvector store. Inputs
5
- * are staged under `data/inputs/` by the embedder before the run (see the data-profile-init spec);
6
- * the body assumes a populated tree and never downloads.
2
+ * Data-profile DBOS workflow — scans the already-staged input tree, runs the
3
+ * data-profiler sandbox agent over the resulting manifest, registers each staged
4
+ * file in cortex_artifacts, and indexes the profile's kinds and entities into the
5
+ * pgvector store. Inputs are staged under `data/inputs/` by the embedder before the
6
+ * run (see the data-profile-init spec); the body assumes a populated tree and never
7
+ * downloads.
7
8
  *
8
9
  * Recoverable: a crashed Cortex pod resumes the workflow from the DBOS step
9
10
  * cache. The run authorization is minted at the async edge (`triggerDataProfile`)
@@ -29,7 +30,7 @@ import type { SandboxClient } from "../sandbox/client.js";
29
30
  import type { WorkspaceFilesystem } from "../workspace/filesystem.js";
30
31
  import { type ResolveWorkspaceRoot } from "../workspace/paths.js";
31
32
  import { type ProfilerOutput } from "../schemas/data-profile-schemas.js";
32
- import { type DataProfileInputFile, type DataProfileResult } from "../state/index.js";
33
+ import { type DataProfileCoverage, type DataProfileResult } from "../state/index.js";
33
34
  /** The body's construction-time deps — closed over at registration. */
34
35
  export interface DataProfileDeps extends EnvironmentStorePaths {
35
36
  /** Operational logging seam; omitted falls back to no-op. */
@@ -92,22 +93,6 @@ export interface DataProfileWorkflowInput {
92
93
  */
93
94
  readonly ownsMandate?: boolean;
94
95
  }
95
- /**
96
- * Build the drift-signature comparand a completed profile persists, or `undefined`
97
- * to omit it. `inputFiles` records whether the profiled files still hold the same
98
- * bytes; it sits beside the `inputFileIds` audit record (WHICH files) in the stored
99
- * `DataProfileResult`.
100
- *
101
- * A workflow input persisted before `StagedInput.mtimeMs` existed (recovered across
102
- * the deploy that added it) carries entries with NO `mtimeMs` despite the compile-time
103
- * type — the same recovered-legacy-input case `ownsMandate` handles. A per-entry
104
- * `{ …, mtimeMs: undefined }` would JSON.stringify to `{ fileId, size }` and violate
105
- * `DataProfileInputFile`, so the WHOLE signature is omitted when ANY entry lacks
106
- * `mtimeMs`: an absent comparand reads back as drift and buys one clean re-profile,
107
- * exactly as a wholly-absent `result` already does (see `DataProfileResult.inputFiles`).
108
- * The cast defeats the required-`number` type precisely to observe that legacy gap.
109
- */
110
- export declare function buildDriftSignature(stagedInputs: readonly StagedInput[]): DataProfileInputFile[] | undefined;
111
96
  /**
112
97
  * Project the profiler's structured output plus the staged manifest into the record
113
98
  * persisted on `cortex_analysis_state`.
@@ -124,7 +109,7 @@ export declare function buildDriftSignature(stagedInputs: readonly StagedInput[]
124
109
  * indistinguishable from a legacy snapshot that predates the field, which is exactly
125
110
  * the reading a consumer must already tolerate.
126
111
  */
127
- export declare function buildDataProfileResult(profile: ProfilerOutput, stagedInputs: readonly StagedInput[], profiledAt: string): DataProfileResult;
112
+ export declare function buildDataProfileResult(profile: ProfilerOutput, stagedInputs: readonly StagedInput[], profiledAt: string, coverage?: DataProfileCoverage): DataProfileResult;
128
113
  /**
129
114
  * Register the data-profile workflow with DBOS. Returns the registered
130
115
  * callable so `triggerDataProfile` can dispatch via `DBOS.startWorkflow`.
@@ -1,9 +1,10 @@
1
1
  /**
2
- * Data-profile DBOS workflow — profiles the already-staged input files, runs
3
- * the data-profiler sandbox agent, writes per-file metadata to
4
- * cortex_artifacts, and indexes descriptions into the pgvector store. Inputs
5
- * are staged under `data/inputs/` by the embedder before the run (see the data-profile-init spec);
6
- * the body assumes a populated tree and never downloads.
2
+ * Data-profile DBOS workflow — scans the already-staged input tree, runs the
3
+ * data-profiler sandbox agent over the resulting manifest, registers each staged
4
+ * file in cortex_artifacts, and indexes the profile's kinds and entities into the
5
+ * pgvector store. Inputs are staged under `data/inputs/` by the embedder before the
6
+ * run (see the data-profile-init spec); the body assumes a populated tree and never
7
+ * downloads.
7
8
  *
8
9
  * Recoverable: a crashed Cortex pod resumes the workflow from the DBOS step
9
10
  * cache. The run authorization is minted at the async edge (`triggerDataProfile`)
@@ -34,6 +35,11 @@ import { generateExecutionId } from "../sandbox/execution-id.js";
34
35
  import { mintSandboxIdentity } from "../sandbox/identity.js";
35
36
  import { createVectorStore } from "../state/vector-store.js";
36
37
  import { ProfilerOutputSchema } from "../schemas/data-profile-schemas.js";
38
+ import { computeInputSignature } from "../execution/input-signature.js";
39
+ import { computeCoverage } from "../input-scan/coverage.js";
40
+ import { enrichShapes } from "../input-scan/enrich.js";
41
+ import { renderInputScanManifest, scanInputTree } from "../input-scan/scan.js";
42
+ import { buildProfileIndexEntries } from "./data-profile-index.js";
37
43
  import { completeDataProfile, failDataProfile, loadDataProfileStatus, loadSeedInputFileIds, recordDataProfileWorkflowId, tryRerunDataProfile, tryStartDataProfile, upsertArtifacts, } from "../state/index.js";
38
44
  import { createProfileActivityEmitter } from "./data-profile-activity.js";
39
45
  import { ensureSearchIndex, searchIndexName } from "../workspace/search-config.js";
@@ -42,39 +48,20 @@ import { ensureSearchIndex, searchIndexName } from "../workspace/search-config.j
42
48
  import { DATA_PROFILE_RUN_LITERAL } from "../contracts/data-profile.js";
43
49
  const DATA_PROFILE_STEP_LITERAL = "profile";
44
50
  const DATA_PROFILE_AGENT_ID = "data-profiler";
45
- /** Sandbox-server exec budget for the profile run. */
46
- const DEFAULT_DEADLINE_MS = 300_000;
51
+ /**
52
+ * Sandbox-server exec budget for the profile run. Twenty minutes: the deterministic
53
+ * scan bounds the discovery half, and what remains is the agent's own reading of a
54
+ * bounded number of example files.
55
+ */
56
+ const DEFAULT_DEADLINE_MS = 1_200_000;
57
+ /** Index entries embedded and upserted per round trip. */
58
+ const INDEX_BATCH_SIZE = 256;
59
+ /** Where the embedder stages this analysis's inputs (see the data-profile-init spec). */
60
+ const STAGED_INPUT_ROOT = "data/inputs";
47
61
  /** Build the canonical artifact path for a staged input file. */
48
62
  function inputArtifactPath(f) {
49
63
  return `data/${f.relativePath}`;
50
64
  }
51
- /** Lowercased extension of a path, or `"unknown"` when none. */
52
- function fileExtension(p) {
53
- const base = p.slice(p.lastIndexOf("/") + 1);
54
- const dot = base.lastIndexOf(".");
55
- return dot > 0 ? base.slice(dot + 1).toLowerCase() : "unknown";
56
- }
57
- /**
58
- * Build the drift-signature comparand a completed profile persists, or `undefined`
59
- * to omit it. `inputFiles` records whether the profiled files still hold the same
60
- * bytes; it sits beside the `inputFileIds` audit record (WHICH files) in the stored
61
- * `DataProfileResult`.
62
- *
63
- * A workflow input persisted before `StagedInput.mtimeMs` existed (recovered across
64
- * the deploy that added it) carries entries with NO `mtimeMs` despite the compile-time
65
- * type — the same recovered-legacy-input case `ownsMandate` handles. A per-entry
66
- * `{ …, mtimeMs: undefined }` would JSON.stringify to `{ fileId, size }` and violate
67
- * `DataProfileInputFile`, so the WHOLE signature is omitted when ANY entry lacks
68
- * `mtimeMs`: an absent comparand reads back as drift and buys one clean re-profile,
69
- * exactly as a wholly-absent `result` already does (see `DataProfileResult.inputFiles`).
70
- * The cast defeats the required-`number` type precisely to observe that legacy gap.
71
- */
72
- export function buildDriftSignature(stagedInputs) {
73
- const mtimeOf = (f) => f.mtimeMs;
74
- if (!stagedInputs.every((f) => mtimeOf(f) !== undefined))
75
- return undefined;
76
- return stagedInputs.map((f) => ({ fileId: f.fileId, size: f.size, mtimeMs: mtimeOf(f) }));
77
- }
78
65
  /**
79
66
  * Project the profiler's structured output plus the staged manifest into the record
80
67
  * persisted on `cortex_analysis_state`.
@@ -91,7 +78,7 @@ export function buildDriftSignature(stagedInputs) {
91
78
  * indistinguishable from a legacy snapshot that predates the field, which is exactly
92
79
  * the reading a consumer must already tolerate.
93
80
  */
94
- export function buildDataProfileResult(profile, stagedInputs, profiledAt) {
81
+ export function buildDataProfileResult(profile, stagedInputs, profiledAt, coverage) {
95
82
  return {
96
83
  summary: profile.analysisSummary,
97
84
  files: profile.files.map((f) => ({
@@ -105,8 +92,23 @@ export function buildDataProfileResult(profile, stagedInputs, profiledAt) {
105
92
  warnings: f.warnings,
106
93
  metrics: f.metrics,
107
94
  })),
108
- inputFileIds: stagedInputs.map((f) => f.fileId),
109
- inputFiles: buildDriftSignature(stagedInputs),
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
+ })),
110
+ inputSignature: computeInputSignature(stagedInputs),
111
+ coverage,
110
112
  profiledAt,
111
113
  domain: profile.domain,
112
114
  subtype: profile.subtype,
@@ -119,6 +121,37 @@ export function buildDataProfileResult(profile, stagedInputs, profiledAt) {
119
121
  qualityAssessment: profile.qualityAssessment,
120
122
  };
121
123
  }
124
+ /**
125
+ * Walk the staged tree and enrich its shapes with a header readout.
126
+ *
127
+ * The walk and the shape observation run in this process over the workspace read seam;
128
+ * only the readout reaches into the sandbox, and only once per shape. A readout that
129
+ * fails is logged and dropped: it is enrichment, and a manifest without it still
130
+ * carries every structural observation the agent's grouping rests on, so failing the
131
+ * profile over it would trade the whole capability for a nicety.
132
+ */
133
+ async function runInputScan(args) {
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;
138
+ try {
139
+ const shapes = await enrichShapes({
140
+ shapes: scan.manifest.shapes,
141
+ sandboxClient: deps.sandboxClient,
142
+ sandbox,
143
+ mountRoot: `/${analysisId}`,
144
+ execId,
145
+ deadlineMs,
146
+ emit: async () => { },
147
+ });
148
+ return { ...scan, manifest: { ...scan.manifest, shapes } };
149
+ }
150
+ catch (err) {
151
+ logger.warn("input-scan header readout failed (non-fatal)", logger.errorFields(err));
152
+ return scan;
153
+ }
154
+ }
122
155
  /**
123
156
  * Register the data-profile workflow with DBOS. Returns the registered
124
157
  * callable so `triggerDataProfile` can dispatch via `DBOS.startWorkflow`.
@@ -192,34 +225,6 @@ export async function runDataProfileBody(input, deps) {
192
225
  // meaningless, so no softer handling is warranted.
193
226
  const workspaceRoot = deps.resolveWorkspaceRoot(analysisId);
194
227
  const profileWritePrefix = `${workspaceRoot}/runs/${DATA_PROFILE_RUN_LITERAL}/${DATA_PROFILE_STEP_LITERAL}`;
195
- const fileList = stagedInputs.map((f) => inputArtifactPath(f));
196
- // The profiler runs outside `executeAnalysis`, so no scheduler composes a
197
- // briefing for it — this prompt IS its briefing, and the sandbox system
198
- // prompt names no paths (it is static per agent type, for the prompt cache).
199
- // Its workspace frame therefore has to be stated here, in the same words the
200
- // step briefing uses.
201
- const prompt = [
202
- `Profile all input data files for this analysis.`,
203
- ``,
204
- renderWorkspace({
205
- analysisRoot: `/${analysisId}`,
206
- workingDir: toSandboxPath(workspaceRoot, analysisId, profileWritePrefix),
207
- }),
208
- ``,
209
- `IMPORTANT: You MUST use execute_command to profile files before submitting results. Do NOT submit empty files or placeholder text.`,
210
- ``,
211
- `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.`,
212
- ``,
213
- `The following input files are available (paths relative to the analysis root):`,
214
- ...fileList.map((f) => `- ${f}`),
215
- ``,
216
- `Steps:`,
217
- `1. For each file, use head and wc to inspect structure (ALWAYS double-quote file paths)`,
218
- `2. Write a Python script for detailed profiling — use pathlib or os.path for path handling (handles spaces natively)`,
219
- `3. Call the \`submit_profile\` tool with structured metadata for EVERY file listed above`,
220
- ``,
221
- `All file paths in your response must be relative to the analysis root and must EXACTLY match the paths listed above.`,
222
- ].join("\n");
223
228
  logger.info("starting sandbox", { executionId });
224
229
  // Reported BEFORE the container exists, deliberately: provisioning is the longest single
225
230
  // operation in a profile and it precedes the agent entirely, so a body that reported only
@@ -255,6 +260,48 @@ export async function runDataProfileBody(input, deps) {
255
260
  // which captures its step deadline the same way).
256
261
  const deadlineAbs = (await DBOS.now()) + DEFAULT_DEADLINE_MS;
257
262
  const nextFunctionId = makeNextFunctionId();
263
+ // The deterministic scan, before the agent's first turn. It is always needed
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({
270
+ session: childSession,
271
+ deps,
272
+ analysisId,
273
+ sandbox,
274
+ execId: `${workflowId}:${DATA_PROFILE_STEP_LITERAL}:${nextFunctionId()}`,
275
+ deadlineMs: deadlineAbs,
276
+ logger,
277
+ });
278
+ logger.info("input scan complete", {
279
+ files: scan.manifest.fileCount,
280
+ shapes: scan.manifest.shapes.length,
281
+ unstructured: scan.manifest.unstructured.count,
282
+ truncated: scan.manifest.truncated,
283
+ });
284
+ // The profiler runs outside `executeAnalysis`, so no scheduler composes a
285
+ // briefing for it — this prompt IS its briefing, and the sandbox system
286
+ // prompt names no paths (it is static per agent type, for the prompt cache).
287
+ // Its workspace frame therefore has to be stated here, in the same words the
288
+ // step briefing uses.
289
+ const prompt = [
290
+ `Profile the input data for this analysis: say what the dataset IS, so planning can proceed.`,
291
+ ``,
292
+ renderWorkspace({
293
+ analysisRoot: `/${analysisId}`,
294
+ workingDir: toSandboxPath(workspaceRoot, analysisId, profileWritePrefix),
295
+ }),
296
+ ``,
297
+ `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
+ ``,
299
+ renderInputScanManifest(scan.manifest),
300
+ ``,
301
+ `Decide the dataset's kinds from these observations — you may split a shape, span several shapes, or`,
302
+ `label an axis the scan did not observe. Inspect ONE example file per kind where a description needs`,
303
+ `content the scan did not capture, then call \`submit_profile\` once.`,
304
+ ].join("\n");
258
305
  const sandboxAgentDeps = {
259
306
  provider: deps.provider,
260
307
  pool: deps.pool,
@@ -322,78 +369,64 @@ export async function runDataProfileBody(input, deps) {
322
369
  }, {
323
370
  tools: [submitProfileTool],
324
371
  nudge: "You stopped without calling submit_profile, so no profile was " +
325
- "recorded. Call submit_profile now with structured metadata for " +
326
- "every input file — base it on the profiling you already did.",
372
+ "recorded. Call submit_profile now with the kinds this dataset is " +
373
+ "made of, the axes that vary across them, and any individually " +
374
+ "notable files — base it on the scan and the work you already did.",
327
375
  });
328
376
  if (!capturedProfile) {
329
377
  throw new Error("Data profiling failed: agent did not call submit_profile");
330
378
  }
331
379
  const profilerData = capturedProfile;
332
- // 4. Index into vector store — real files only, consistent type metadata
380
+ // 4. Index into vector store — one entry per kind, one per entity, none per file.
333
381
  //
334
382
  // Reported as `indexing`, not `persisting`: the contract defines `persisting` as step
335
383
  // bytes uploading to an artifact store, and a profile uploads nothing — its durable
336
384
  // products are this vector index and the ledger row.
337
385
  await activity.indexing();
338
- const profilerByPath = new Map(profilerData.files.map((f) => [f.path, f]));
339
- const normPath = (p) => p
340
- .replace(/\/+/g, "/")
341
- .replace(/^\/|\/$/g, "")
342
- .trim();
343
- const profilerByNorm = new Map(profilerData.files.map((f) => [normPath(f.path), f]));
386
+ const profileRecord = buildDataProfileResult(profilerData, stagedInputs, new Date().toISOString(),
387
+ // Coverage measures the PROFILE, not the scan: the agent's own patterns are
388
+ // matched against the scanned tree, so a profile whose kinds leave most of
389
+ // the tree unmatched is visibly incomplete instead of reading as fresh.
390
+ computeCoverage(scan.files.map((f) => f.path), profilerData.kinds.map((kind) => kind.pathPattern)));
344
391
  await ensureSearchIndex(deps.pool, analysisId, deps.embedding.dimensions);
345
392
  const vectorStore = createVectorStore(deps.pool);
346
- const embedOne = async (text, session) => {
347
- const [vec] = unwrapOrThrow(await deps.embedding.embed([text], session));
348
- if (!vec)
349
- throw new Error("data-profile: empty embedding response");
350
- return vec;
351
- };
352
393
  const indexName = searchIndexName(analysisId);
353
- let indexed = 0;
354
- let fallbackCount = 0;
355
- for (const matFile of stagedInputs) {
356
- const dbPath = inputArtifactPath(matFile);
357
- const desc = profilerByPath.get(dbPath) ?? profilerByNorm.get(normPath(dbPath));
358
- // Lossless: a materialized input file the profiler agent omitted
359
- // still gets a deterministic, no-LLM description so it stays
360
- // discoverable via search. The agent's profile is enrichment, not a
361
- // gate on indexing — mirrors the step-output metadata fallback.
362
- const searchMeta = desc
363
- ? {
364
- text: desc.description,
365
- type: "input",
366
- dataType: desc.dataType,
367
- format: desc.format,
368
- ...(desc.tags ? { tags: desc.tags } : {}),
369
- }
370
- : {
371
- text: `${dbPath} — input file (${fileExtension(dbPath)}, ${matFile.size} bytes); automated profile unavailable.`,
372
- type: "input",
373
- dataType: "unknown",
374
- format: fileExtension(dbPath),
375
- };
376
- if (!desc)
377
- fallbackCount++;
378
- const embedding = await embedOne(searchMeta.text, runSession);
394
+ const entries = buildProfileIndexEntries({
395
+ analysisId,
396
+ kinds: profileRecord.kinds ?? [],
397
+ ...(profileRecord.axes ? { axes: profileRecord.axes } : {}),
398
+ scan,
399
+ files: profileRecord.files,
400
+ });
401
+ // Batched: both interfaces already take arrays, and one round trip per entry is
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`);
379
408
  unwrapOrThrow(await vectorStore.upsert({
380
409
  indexName,
381
- vectors: [embedding],
382
- metadata: [searchMeta],
383
- ids: [`/${analysisId}/${dbPath}`],
410
+ vectors,
411
+ metadata: batch.map((entry) => ({ ...entry.metadata, text: entry.text })),
412
+ ids: batch.map((entry) => entry.id),
384
413
  }));
385
- indexed++;
386
414
  }
387
- if (fallbackCount > 0) {
388
- logger.warn("input file(s) used a deterministic fallback description — profiler omitted them", {
389
- fallbackCount,
390
- stagedCount: stagedInputs.length,
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,
391
425
  });
392
426
  }
393
- logger.info("indexed file(s)", { indexed });
394
- // 5. Complete — store the FULL profiler finding plus the input snapshot for
427
+ // 5. Complete — store the FULL profiler finding plus the input signature for
395
428
  // staleness detection. The scratch tree is gone; this row is all that survives.
396
- if (!unwrapOrThrow(await completeDataProfile(deps.pool, analysisId, buildDataProfileResult(profilerData, stagedInputs, new Date().toISOString())))) {
429
+ if (!unwrapOrThrow(await completeDataProfile(deps.pool, analysisId, profileRecord))) {
397
430
  logTerminalNoop(logger, analysisId, "completion");
398
431
  }
399
432
  await deps.runAuthorizer.revoke(authorization, "data-profile-completed");
@@ -28,7 +28,8 @@
28
28
  * render problem, a figure that escapes the workspace root, a write failure, and a stamp failure. The tool
29
29
  * never throws for one of them. When the page
30
30
  * lands, the tool stamps the hash of the rendered draft on the session state, thus the eyes and the record
31
- * know which draft the page shows. The filesystem speaks the
31
+ * know which draft the page shows. It then emits one durable `data-report-rendered` part, thus the
32
+ * transcript places the render and a live client learns that a fresh page exists. The filesystem speaks the
32
33
  * throw protocol, and the workspace-root seam signals an unresolvable resource the same way. Thus the
33
34
  * write runs through the `tryFs` glue, which turns a genuine fault into the ok-channel outcome and lets a
34
35
  * control-flow exception propagate.