@inflexa-ai/harness 0.26.3 → 0.27.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.
@@ -13,6 +13,13 @@
13
13
  *
14
14
  * A pure function over the record — no I/O, no LLM, no pipeline. It is the whole
15
15
  * mechanism.
16
+ *
17
+ * Every path it renders is projected into the frame-independent
18
+ * `/{analysisId}/…` form. The record stores a path relative to the analysis
19
+ * root, which is the correct form to store; but a step agent resolves a relative
20
+ * path against its own working directory, thus the stored form names a file that
21
+ * does not exist there. The absolute form resolves to the same file in every
22
+ * frame, including the conversation agent's, so the projection is unconditional.
16
23
  */
17
24
  import type { DataProfileResult } from "../state/data-profile.js";
18
25
  /** The character budget the projection guarantees it will not exceed. */
@@ -30,5 +37,9 @@ export declare const DATA_PROFILE_ORIENTATION_MAX_CHARS = 1200;
30
37
  * A snapshot written before the record was widened carries none of the structured
31
38
  * fields. Rather than emit a bare file list, that case falls back to the narrative
32
39
  * summary — the one orientation a legacy row does have.
40
+ *
41
+ * `analysisId` roots each rendered file path (see the module header). It is the
42
+ * id of the analysis the profile belongs to, and it is what the sandbox mounts
43
+ * the tree at.
33
44
  */
34
- export declare function buildDataProfileOrientation(result: DataProfileResult, maxChars?: number): string;
45
+ export declare function buildDataProfileOrientation(result: DataProfileResult, analysisId: string, maxChars?: number): string;
@@ -13,7 +13,15 @@
13
13
  *
14
14
  * A pure function over the record — no I/O, no LLM, no pipeline. It is the whole
15
15
  * mechanism.
16
+ *
17
+ * Every path it renders is projected into the frame-independent
18
+ * `/{analysisId}/…` form. The record stores a path relative to the analysis
19
+ * root, which is the correct form to store; but a step agent resolves a relative
20
+ * path against its own working directory, thus the stored form names a file that
21
+ * does not exist there. The absolute form resolves to the same file in every
22
+ * frame, including the conversation agent's, so the projection is unconditional.
16
23
  */
24
+ import { toAnalysisRootPath } from "../workspace/paths.js";
17
25
  /** The character budget the projection guarantees it will not exceed. */
18
26
  export const DATA_PROFILE_ORIENTATION_MAX_CHARS = 1200;
19
27
  /** Files listed before the tail is elided (the count still reports the total). */
@@ -81,8 +89,12 @@ function formatFileFacts(file) {
81
89
  * A snapshot written before the record was widened carries none of the structured
82
90
  * fields. Rather than emit a bare file list, that case falls back to the narrative
83
91
  * summary — the one orientation a legacy row does have.
92
+ *
93
+ * `analysisId` roots each rendered file path (see the module header). It is the
94
+ * id of the analysis the profile belongs to, and it is what the sandbox mounts
95
+ * the tree at.
84
96
  */
85
- export function buildDataProfileOrientation(result, maxChars = DATA_PROFILE_ORIENTATION_MAX_CHARS) {
97
+ export function buildDataProfileOrientation(result, analysisId, maxChars = DATA_PROFILE_ORIENTATION_MAX_CHARS) {
86
98
  const lines = [];
87
99
  const identity = [
88
100
  [present(result.domain), present(result.subtype)].filter((f) => f !== undefined).join(" / ") || undefined,
@@ -139,7 +151,7 @@ export function buildDataProfileOrientation(result, maxChars = DATA_PROFILE_ORIE
139
151
  const header = shown.length < result.files.length ? `${label} (${shown.length} of ${result.files.length}):` : `${label} (${result.files.length}):`;
140
152
  lines.push(header);
141
153
  for (const file of shown) {
142
- lines.push(`- ${file.path} — ${clip(file.description, MAX_FILE_DESCRIPTION_CHARS)}${formatFileFacts(file)}`);
154
+ lines.push(`- ${toAnalysisRootPath(analysisId, file.path)} — ${clip(file.description, MAX_FILE_DESCRIPTION_CHARS)}${formatFileFacts(file)}`);
143
155
  }
144
156
  }
145
157
  return clamp(lines.join("\n"), maxChars);
@@ -48,20 +48,30 @@ export declare const STEP_NON_TASK_FIELDS: readonly ["id", "track", "step_type",
48
48
  * improvises the rest.
49
49
  */
50
50
  export declare function renderTask(step: AnalysisStep): string;
51
- /** The two in-sandbox paths that anchor every path the agent writes or reads. */
51
+ /**
52
+ * What anchors every path the agent writes or reads: the id the tree is mounted
53
+ * at, and the step's writable directory.
54
+ *
55
+ * The frame carries the id rather than the analysis root, because the root is
56
+ * `/{analysisId}` and two fields for one value can disagree. The id is also what
57
+ * every other path in the briefing is rooted with, thus one field serves the
58
+ * workspace section and the orientation alike.
59
+ */
52
60
  export interface WorkspaceFrame {
53
- /** In-sandbox analysis root — the read-only mount of the whole analysis tree. */
54
- readonly analysisRoot: string;
61
+ /** The analysis id. The sandbox mounts the read-only analysis tree at `/{analysisId}`. */
62
+ readonly analysisId: string;
55
63
  /** In-sandbox working directory — this step's writable artifact directory, and its cwd. */
56
64
  readonly workingDir: string;
57
65
  }
66
+ /** The in-sandbox analysis root of a frame — the read-only mount of the whole tree. */
67
+ export declare function analysisRootOf(frame: WorkspaceFrame): string;
58
68
  export declare function renderWorkspace(frame: WorkspaceFrame): string;
59
69
  /**
60
70
  * What dataset the analysis is holding, projected from the persisted profile.
61
71
  * Absent or not-yet-profiled → `""`, and the agent falls back to its always-on
62
72
  * `inspect_data_profile` tool.
63
73
  */
64
- export declare function renderOrientation(profile: DataProfileResult | null | undefined): string;
74
+ export declare function renderOrientation(profile: DataProfileResult | null | undefined, analysisId: string): string;
65
75
  /**
66
76
  * One completed dependency's handoff. The seed carries the gist plus the PATHS
67
77
  * to the rest — an agent that needs more than the excerpt reads the summary at
@@ -104,12 +104,17 @@ export function renderTask(step) {
104
104
  }
105
105
  return parts.join("\n\n");
106
106
  }
107
+ /** The in-sandbox analysis root of a frame — the read-only mount of the whole tree. */
108
+ export function analysisRootOf(frame) {
109
+ return `/${frame.analysisId}`;
110
+ }
107
111
  export function renderWorkspace(frame) {
108
- if (!frame.analysisRoot.trim() || !frame.workingDir.trim())
112
+ if (!frame.analysisId.trim() || !frame.workingDir.trim())
109
113
  return "";
114
+ const analysisRoot = analysisRootOf(frame);
110
115
  return section("Workspace", bullets([
111
116
  `Working directory (writable, your cwd): \`${frame.workingDir}\` — write outputs to \`output/\`, \`figures/\`, \`scripts/\`, \`logs/\` under it.`,
112
- `Analysis root (read-only): \`${frame.analysisRoot}\` — inputs at \`${frame.analysisRoot}/data/inputs/\`, prior runs at \`${frame.analysisRoot}/runs/\`.`,
117
+ `Analysis root (read-only): \`${analysisRoot}\` — inputs at \`${analysisRoot}/data/inputs/\`, prior runs at \`${analysisRoot}/runs/\`.`,
113
118
  ]));
114
119
  }
115
120
  // ── (3) Data orientation ──────────────────────────────────────────────
@@ -118,10 +123,10 @@ export function renderWorkspace(frame) {
118
123
  * Absent or not-yet-profiled → `""`, and the agent falls back to its always-on
119
124
  * `inspect_data_profile` tool.
120
125
  */
121
- export function renderOrientation(profile) {
126
+ export function renderOrientation(profile, analysisId) {
122
127
  if (!profile)
123
128
  return "";
124
- const orientation = buildDataProfileOrientation(profile, DATA_PROFILE_ORIENTATION_MAX_CHARS);
129
+ const orientation = buildDataProfileOrientation(profile, analysisId, DATA_PROFILE_ORIENTATION_MAX_CHARS);
125
130
  if (orientation.trim().length === 0)
126
131
  return "";
127
132
  return section("Data orientation", `${orientation}\n\nThis is a bounded projection — call \`inspect_data_profile\` for the full profile.`);
@@ -160,7 +165,12 @@ function renderHandoff(handoff) {
160
165
  * caller's durable step replay-stable.
161
166
  */
162
167
  export function composeStepBriefing(briefing) {
163
- return [renderTask(briefing.step), renderWorkspace(briefing.workspace), renderOrientation(briefing.profile), renderUpstream(briefing.upstream)]
168
+ return [
169
+ renderTask(briefing.step),
170
+ renderWorkspace(briefing.workspace),
171
+ renderOrientation(briefing.profile, briefing.workspace.analysisId),
172
+ renderUpstream(briefing.upstream),
173
+ ]
164
174
  .filter(Boolean)
165
175
  .join("\n\n");
166
176
  }
@@ -428,6 +428,7 @@ CREATE TABLE IF NOT EXISTS cortex_working_memory (
428
428
  CREATE TABLE IF NOT EXISTS cortex_asks (
429
429
  id TEXT PRIMARY KEY,
430
430
  analysis_id TEXT NOT NULL,
431
+ user_id TEXT,
431
432
  thread_id TEXT,
432
433
  title TEXT NOT NULL,
433
434
  command TEXT NOT NULL,
@@ -451,16 +452,17 @@ CREATE INDEX IF NOT EXISTS idx_cortex_asks_analysis
451
452
  CREATE INDEX IF NOT EXISTS idx_cortex_asks_pending
452
453
  ON cortex_asks(status) WHERE status = 'pending';
453
454
 
454
- -- Standing approval grants behind an 'always' reply — keyed by the analysis and
455
- -- the ask's grant key (the displayed command when the tool supplied no broader
456
- -- key), so a later matching ask auto-approves without pausing. Lives for the
457
- -- analysis lifecycle, surviving process restarts, and never applies to another
458
- -- analysis.
455
+ -- Standing approval grants behind an 'always' reply — keyed by the analysis, the
456
+ -- user, and the ask's grant key (the displayed command when the tool supplied no
457
+ -- broader key), so a later matching ask auto-approves without pausing. Lives for
458
+ -- the analysis lifecycle, surviving process restarts, and never applies to
459
+ -- another analysis or another user.
459
460
  CREATE TABLE IF NOT EXISTS cortex_ask_grants (
460
461
  analysis_id TEXT NOT NULL,
462
+ user_id TEXT NOT NULL,
461
463
  grant_key TEXT NOT NULL,
462
464
  created_at TEXT NOT NULL,
463
- PRIMARY KEY (analysis_id, grant_key)
465
+ PRIMARY KEY (analysis_id, user_id, grant_key)
464
466
  );
465
467
  `;
466
468
  /**
@@ -640,6 +642,21 @@ export async function initCortexState(pool, injected) {
640
642
  ALTER TABLE cortex_ask_grants RENAME COLUMN command TO grant_key;
641
643
  END IF;
642
644
  END $$`,
645
+ "ALTER TABLE cortex_asks ADD COLUMN IF NOT EXISTS user_id TEXT",
646
+ // The user joins the grant key. Guarded and scoped like the rename
647
+ // above. An existing row keeps the empty default, which matches no real
648
+ // identity, so it is inert rather than deleted.
649
+ `DO $$ BEGIN
650
+ IF NOT EXISTS (
651
+ SELECT 1 FROM information_schema.columns
652
+ WHERE table_schema = current_schema() AND table_name = 'cortex_ask_grants' AND column_name = 'user_id'
653
+ ) THEN
654
+ ALTER TABLE cortex_ask_grants ADD COLUMN user_id TEXT NOT NULL DEFAULT '';
655
+ ALTER TABLE cortex_ask_grants ALTER COLUMN user_id DROP DEFAULT;
656
+ ALTER TABLE cortex_ask_grants DROP CONSTRAINT IF EXISTS cortex_ask_grants_pkey;
657
+ ALTER TABLE cortex_ask_grants ADD PRIMARY KEY (analysis_id, user_id, grant_key);
658
+ END IF;
659
+ END $$`,
643
660
  ];
644
661
  for (const sql of addMigrations) {
645
662
  await client.query(sql);
@@ -290,7 +290,7 @@ export async function runDataProfileBody(input, deps) {
290
290
  `Profile the input data for this analysis: say what the dataset IS, so planning can proceed.`,
291
291
  ``,
292
292
  renderWorkspace({
293
- analysisRoot: `/${analysisId}`,
293
+ analysisId,
294
294
  workingDir: toSandboxPath(workspaceRoot, analysisId, profileWritePrefix),
295
295
  }),
296
296
  ``,
@@ -53,7 +53,7 @@ async function profileOrientation(pool, analysisId, logger) {
53
53
  const status = unwrapOrThrow(await loadDataProfileStatus(pool, analysisId));
54
54
  if (!status?.result)
55
55
  return "No persisted data-profile facts are available.";
56
- return buildDataProfileOrientation(status.result, DATA_PROFILE_ORIENTATION_MAX_CHARS);
56
+ return buildDataProfileOrientation(status.result, analysisId, DATA_PROFILE_ORIENTATION_MAX_CHARS);
57
57
  }
58
58
  catch (error) {
59
59
  logger.warn("could not load data profile for ad hoc routing", { error: error instanceof Error ? error.message : String(error) });
@@ -20,11 +20,13 @@ export interface AskGatewayDeps {
20
20
  }
21
21
  /**
22
22
  * Per-turn context an embedder binds into the `Ask` seam. It scopes the ask to an
23
- * analysis/thread, carries the turn's abort `signal`, and supplies the `emit` sink
24
- * the `data-ask` part flows through.
23
+ * analysis/thread and to one user, carries the turn's abort `signal`, and supplies
24
+ * the `emit` sink the `data-ask` part flows through.
25
25
  */
26
26
  export interface AskContext {
27
27
  readonly analysisId: string;
28
+ /** The opaque identity of the person the ask goes to. The harness never reads it. */
29
+ readonly userId: string;
28
30
  readonly threadId?: string;
29
31
  readonly signal: AbortSignal;
30
32
  readonly emit: EmitFn;
@@ -29,6 +29,7 @@ export function createAskGateway(deps) {
29
29
  const row = {
30
30
  id,
31
31
  analysisId: ctx.analysisId,
32
+ userId: ctx.userId,
32
33
  threadId: ctx.threadId ?? null,
33
34
  title: request.title,
34
35
  command: request.command,
@@ -36,10 +37,10 @@ export function createAskGateway(deps) {
36
37
  grantKey: request.grantKey ?? null,
37
38
  createdAt: now,
38
39
  };
39
- // A standing grant for this ask's grant key — the command unless the tool
40
- // supplied a broader key — short-circuits the prompt: record the invocation as
41
- // `resolved` for the audit trail and return without pausing.
42
- if (unwrapOrThrow(await selectGrant(pool, ctx.analysisId, request.grantKey ?? request.command))) {
40
+ // A standing grant this user holds for this ask's grant key — the command
41
+ // unless the tool supplied a broader key — short-circuits the prompt: record
42
+ // the invocation as `resolved` for the audit trail and return without pausing.
43
+ if (unwrapOrThrow(await selectGrant(pool, ctx.analysisId, ctx.userId, request.grantKey ?? request.command))) {
43
44
  unwrapOrThrow(await insertGrantedAsk(pool, row, now));
44
45
  return { kind: "always" };
45
46
  }
@@ -21,6 +21,7 @@ export type AnswerOutcome = "applied" | "not_found" | "already_terminal";
21
21
  export interface PendingAsk {
22
22
  readonly id: string;
23
23
  readonly analysisId: string;
24
+ readonly userId: string;
24
25
  readonly threadId: string | null;
25
26
  readonly title: string;
26
27
  readonly command: string;
@@ -36,6 +37,7 @@ export interface AskResolution {
36
37
  export interface AskRow {
37
38
  readonly id: string;
38
39
  readonly analysisId: string;
40
+ readonly userId: string;
39
41
  readonly threadId: string | null;
40
42
  readonly title: string;
41
43
  readonly command: string;
@@ -52,8 +54,8 @@ export declare function insertPendingAsk(querier: Querier, row: AskRow): ResultA
52
54
  * every approval-gated action. The recorded reply is `{ kind: "always" }`.
53
55
  */
54
56
  export declare function insertGrantedAsk(querier: Querier, row: AskRow, resolvedAt: string): ResultAsync<void, DbError>;
55
- /** Does a standing grant cover `(analysisId, grantKey)` in this analysis? */
56
- export declare function selectGrant(querier: Querier, analysisId: string, grantKey: string): ResultAsync<boolean, DbError>;
57
+ /** Does a standing grant cover `(analysisId, userId, grantKey)` for this person? */
58
+ export declare function selectGrant(querier: Querier, analysisId: string, userId: string, grantKey: string): ResultAsync<boolean, DbError>;
57
59
  /** Read a row's current status + recorded reply. `null` when no such row exists. */
58
60
  export declare function selectAskResolution(querier: Querier, id: string): ResultAsync<AskResolution | null, DbError>;
59
61
  /**
@@ -15,9 +15,9 @@ export function insertPendingAsk(querier, row) {
15
15
  return tryMutation("asks.insertPending", async () => {
16
16
  await querier.query({
17
17
  text: `INSERT INTO cortex_asks
18
- (id, analysis_id, thread_id, title, command, detail, grant_key, status, created_at)
19
- VALUES ($1, $2, $3, $4, $5, $6, $7, 'pending', $8)`,
20
- values: [row.id, row.analysisId, row.threadId, row.title, row.command, row.detail, row.grantKey ?? row.command, row.createdAt],
18
+ (id, analysis_id, user_id, thread_id, title, command, detail, grant_key, status, created_at)
19
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, 'pending', $9)`,
20
+ values: [row.id, row.analysisId, row.userId, row.threadId, row.title, row.command, row.detail, row.grantKey ?? row.command, row.createdAt],
21
21
  });
22
22
  });
23
23
  }
@@ -30,11 +30,12 @@ export function insertGrantedAsk(querier, row, resolvedAt) {
30
30
  return tryMutation("asks.insertGranted", async () => {
31
31
  await querier.query({
32
32
  text: `INSERT INTO cortex_asks
33
- (id, analysis_id, thread_id, title, command, detail, grant_key, status, reply, created_at, resolved_at)
34
- VALUES ($1, $2, $3, $4, $5, $6, $7, 'resolved', $8::jsonb, $9, $10)`,
33
+ (id, analysis_id, user_id, thread_id, title, command, detail, grant_key, status, reply, created_at, resolved_at)
34
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, 'resolved', $9::jsonb, $10, $11)`,
35
35
  values: [
36
36
  row.id,
37
37
  row.analysisId,
38
+ row.userId,
38
39
  row.threadId,
39
40
  row.title,
40
41
  row.command,
@@ -47,12 +48,12 @@ export function insertGrantedAsk(querier, row, resolvedAt) {
47
48
  });
48
49
  });
49
50
  }
50
- /** Does a standing grant cover `(analysisId, grantKey)` in this analysis? */
51
- export function selectGrant(querier, analysisId, grantKey) {
51
+ /** Does a standing grant cover `(analysisId, userId, grantKey)` for this person? */
52
+ export function selectGrant(querier, analysisId, userId, grantKey) {
52
53
  return tryQuery("asks.selectGrant", async () => {
53
54
  const result = await querier.query({
54
- text: `SELECT 1 FROM cortex_ask_grants WHERE analysis_id = $1 AND grant_key = $2`,
55
- values: [analysisId, grantKey],
55
+ text: `SELECT 1 FROM cortex_ask_grants WHERE analysis_id = $1 AND user_id = $2 AND grant_key = $3`,
56
+ values: [analysisId, userId, grantKey],
56
57
  });
57
58
  return (result.rowCount ?? 0) > 0;
58
59
  });
@@ -86,12 +87,13 @@ export function markAborted(querier, id, resolvedAt) {
86
87
  export function selectPending(querier) {
87
88
  return tryQuery("asks.selectPending", async () => {
88
89
  const result = await querier.query({
89
- text: `SELECT id, analysis_id, thread_id, title, command, detail, created_at
90
+ text: `SELECT id, analysis_id, COALESCE(user_id, '') AS user_id, thread_id, title, command, detail, created_at
90
91
  FROM cortex_asks WHERE status = 'pending' ORDER BY created_at ASC`,
91
92
  });
92
93
  return result.rows.map((row) => ({
93
94
  id: row.id,
94
95
  analysisId: row.analysis_id,
96
+ userId: row.user_id,
95
97
  threadId: row.thread_id ?? null,
96
98
  title: row.title,
97
99
  command: row.command,
@@ -133,7 +135,7 @@ export function answerAsk(pool, id, reply, now) {
133
135
  return withTransaction(pool, "asks.answer", (client) => tryMutation("asks.answer:update", () => client.query({
134
136
  text: `UPDATE cortex_asks SET status = $1, reply = $2::jsonb, resolved_at = $3
135
137
  WHERE id = $4 AND status = 'pending'
136
- RETURNING analysis_id, COALESCE(grant_key, command) AS grant_key`,
138
+ RETURNING analysis_id, COALESCE(user_id, '') AS user_id, COALESCE(grant_key, command) AS grant_key`,
137
139
  values: [status, JSON.stringify(reply), now, id],
138
140
  })).andThen((updated) => {
139
141
  if ((updated.rowCount ?? 0) === 0)
@@ -144,12 +146,14 @@ export function answerAsk(pool, id, reply, now) {
144
146
  // caveat: every insert writes grant_key (command when the tool supplied
145
147
  // none), and the COALESCE above covers any pending row that predates
146
148
  // the column — `command` is NOT NULL, so the key can never be null.
149
+ // `user_id` reads the same way: its COALESCE gives the empty string for a
150
+ // row that predates the column, and that identity matches no real user.
147
151
  const granted = updated.rows[0];
148
152
  return tryMutation("asks.answer:grant", async () => {
149
153
  await client.query({
150
- text: `INSERT INTO cortex_ask_grants (analysis_id, grant_key, created_at)
151
- VALUES ($1, $2, $3) ON CONFLICT (analysis_id, grant_key) DO NOTHING`,
152
- values: [granted.analysis_id, granted.grant_key, now],
154
+ text: `INSERT INTO cortex_ask_grants (analysis_id, user_id, grant_key, created_at)
155
+ VALUES ($1, $2, $3, $4) ON CONFLICT (analysis_id, user_id, grant_key) DO NOTHING`,
156
+ values: [granted.analysis_id, granted.user_id, granted.grant_key, now],
153
157
  });
154
158
  }).map(() => "applied");
155
159
  }));
@@ -209,7 +209,7 @@ const NO_FACTS_GUIDANCE = "Plan from the research question alone, do not invent
209
209
  *
210
210
  * Pure — the same grounding renders the same string.
211
211
  */
212
- function renderDataContext(grounding) {
212
+ function renderDataContext(grounding, analysisId) {
213
213
  switch (grounding.kind) {
214
214
  case "absent":
215
215
  return "";
@@ -230,7 +230,7 @@ function renderDataContext(grounding) {
230
230
  "From this analysis's persisted data profile — the authoritative record of what the input data " +
231
231
  "is, derived by profiling the files themselves. Nobody typed it; ground your plan in it.",
232
232
  "",
233
- buildDataProfileOrientation(grounding.result, DATA_PROFILE_ORIENTATION_MAX_CHARS),
233
+ buildDataProfileOrientation(grounding.result, analysisId, DATA_PROFILE_ORIENTATION_MAX_CHARS),
234
234
  ];
235
235
  if (grounding.kind === "provisional") {
236
236
  lines.push("", `PROVISIONAL — this profile may not describe the analysis's current inputs: ${grounding.reasons.join("; ")}. ` +
@@ -748,7 +748,7 @@ export function createGeneratePlanTool(deps) {
748
748
  return null;
749
749
  });
750
750
  const grounding = classifyGrounding(profileStatus);
751
- const dataContextBlock = renderDataContext(grounding);
751
+ const dataContextBlock = renderDataContext(grounding, analysisId);
752
752
  // Reference data and package availability are mandatory grounding,
753
753
  // not optional planner lookups. Read both inventories host-side before
754
754
  // the loop and put their rendered content in the planner's seed.
@@ -19,6 +19,12 @@
19
19
  * Lifecycle states are data variants, not errors (the `defineTool` contract): a missing,
20
20
  * in-flight, failed, or stale profile is an ordinary, expected outcome the model must be
21
21
  * able to reason about, so each returns in the ok channel.
22
+ *
23
+ * Every path it serves is projected into the frame-independent `/{analysisId}/…`
24
+ * form. The record stores a path relative to the analysis root, and a step agent
25
+ * resolves a relative path against its own working directory — thus the stored
26
+ * form names a file that does not exist in the frame of the caller. The same
27
+ * projection guards the step briefing (`app/data-profile-orientation.ts`).
22
28
  */
23
29
  import type { Pool } from "pg";
24
30
  import { type DataProfileFile, type DataProfileResult } from "../../state/index.js";
@@ -19,12 +19,19 @@
19
19
  * Lifecycle states are data variants, not errors (the `defineTool` contract): a missing,
20
20
  * in-flight, failed, or stale profile is an ordinary, expected outcome the model must be
21
21
  * able to reason about, so each returns in the ok channel.
22
+ *
23
+ * Every path it serves is projected into the frame-independent `/{analysisId}/…`
24
+ * form. The record stores a path relative to the analysis root, and a step agent
25
+ * resolves a relative path against its own working directory — thus the stored
26
+ * form names a file that does not exist in the frame of the caller. The same
27
+ * projection guards the step briefing (`app/data-profile-orientation.ts`).
22
28
  */
23
29
  import { ok } from "neverthrow";
24
30
  import { z } from "zod";
25
31
  import { scopeResource } from "../../auth/types.js";
26
32
  import { unwrapOrThrow } from "../../lib/result.js";
27
33
  import { loadDataProfileStatus } from "../../state/index.js";
34
+ import { toAnalysisRootPath } from "../../workspace/paths.js";
28
35
  import { defineTool } from "../define-tool.js";
29
36
  /** Per-file records returned per page when the caller names none. */
30
37
  const DEFAULT_PAGE_SIZE = 20;
@@ -44,6 +51,20 @@ function datasetFileCount(result) {
44
51
  return result.kinds.reduce((sum, kind) => sum + kind.count, 0);
45
52
  return null;
46
53
  }
54
+ /** Serve each per-file record with a path that resolves in the frame of the caller. */
55
+ function rootFilePaths(files, analysisId) {
56
+ return files.map((file) => ({ ...file, path: toAnalysisRootPath(analysisId, file.path) }));
57
+ }
58
+ /**
59
+ * Serve each kind with a glob that resolves in the frame of the caller.
60
+ *
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.
64
+ */
65
+ function rootKindPatterns(kinds, analysisId) {
66
+ return kinds.map((kind) => ({ ...kind, pathPattern: toAnalysisRootPath(analysisId, kind.pathPattern) }));
67
+ }
47
68
  /**
48
69
  * Name every reason the served profile may not describe the current inputs.
49
70
  *
@@ -212,7 +233,7 @@ export function createInspectDataProfileTool(pool) {
212
233
  ...envelope,
213
234
  scope: "kinds",
214
235
  available: true,
215
- kinds: result.kinds,
236
+ kinds: rootKindPatterns(result.kinds, resourceId),
216
237
  ...(result.axes ? { axes: result.axes } : {}),
217
238
  ...(result.coverage ? { coverage: result.coverage } : {}),
218
239
  });
@@ -229,7 +250,7 @@ export function createInspectDataProfileTool(pool) {
229
250
  pageSize,
230
251
  total,
231
252
  hasMore: start + files.length < total,
232
- files,
253
+ files: rootFilePaths(files, resourceId),
233
254
  });
234
255
  },
235
256
  });
@@ -214,7 +214,7 @@ export async function composeStepSeed(args) {
214
214
  // through the same helpers the sandbox mount and the mutate tools use, so
215
215
  // the seed cannot disagree with the container about where anything is.
216
216
  const workspace = {
217
- analysisRoot: `/${analysisId}`,
217
+ analysisId,
218
218
  workingDir: toSandboxPath(workspaceRoot, analysisId, stepWritePrefix({ workspaceRoot, runId, stepId })),
219
219
  };
220
220
  // A DB failure here throws (house rules): a swallowed error would be frozen
@@ -159,6 +159,25 @@ export declare function tailWritePrefix(args: {
159
159
  * a step briefing names (see the harness-workspace-tools spec).
160
160
  */
161
161
  export declare function toSandboxPath(workspaceRoot: string, resourceId: string, hostAbsPath: string): string;
162
+ /**
163
+ * Map an analysis-root-relative path to the frame-independent `/{resourceId}/…`
164
+ * form this module defines at the top of the file.
165
+ *
166
+ * A record that outlives one agent stores the root-relative form, because that
167
+ * form does not depend on where a host puts the root. But a reader always reads
168
+ * inside a frame, and an agent whose working directory is a step directory
169
+ * resolves `data/inputs/x.csv` against that step directory. Thus one stored path
170
+ * is correct in the store and wrong in the message. This is the projection that a
171
+ * renderer applies before a stored path reaches an agent.
172
+ *
173
+ * The input comes from a record and it is a POSIX path, thus it normalizes under
174
+ * POSIX rules. The read is tolerant of the near-misses that a model writes: a
175
+ * leading slash, a `./` prefix, and a doubled separator all name the same file,
176
+ * and a path that already carries the correct root passes through. A tail that
177
+ * climbs above the root is returned as it is, because such a path must fail at
178
+ * the resolver as `out_of_scope`. A prefix would only make it look canonical.
179
+ */
180
+ export declare function toAnalysisRootPath(resourceId: string, path: string): string;
162
181
  /**
163
182
  * Parent of every report session, workspace-root-relative. Callers that
164
183
  * enumerate the sessions join this. Callers that address one join
@@ -25,7 +25,7 @@
25
25
  * comes back `out_of_prefix` (the read surface skips that — it roams the tree
26
26
  * read-only).
27
27
  */
28
- import { relative as relativePath, resolve as resolvePath, sep } from "node:path";
28
+ import { posix as posixPath, relative as relativePath, resolve as resolvePath, sep } from "node:path";
29
29
  /**
30
30
  * Resolve an agent-supplied path against the analysis tree under the
31
31
  * frame-aware model.
@@ -149,8 +149,54 @@ export function toSandboxPath(workspaceRoot, resourceId, hostAbsPath) {
149
149
  if (tail === ".." || tail.startsWith("../")) {
150
150
  throw new Error(`toSandboxPath: host path escapes the workspace root: ${hostAbsPath}`);
151
151
  }
152
+ return joinAnalysisRoot(resourceId, tail);
153
+ }
154
+ /**
155
+ * Map an analysis-root-relative path to the frame-independent `/{resourceId}/…`
156
+ * form this module defines at the top of the file.
157
+ *
158
+ * A record that outlives one agent stores the root-relative form, because that
159
+ * form does not depend on where a host puts the root. But a reader always reads
160
+ * inside a frame, and an agent whose working directory is a step directory
161
+ * resolves `data/inputs/x.csv` against that step directory. Thus one stored path
162
+ * is correct in the store and wrong in the message. This is the projection that a
163
+ * renderer applies before a stored path reaches an agent.
164
+ *
165
+ * The input comes from a record and it is a POSIX path, thus it normalizes under
166
+ * POSIX rules. The read is tolerant of the near-misses that a model writes: a
167
+ * leading slash, a `./` prefix, and a doubled separator all name the same file,
168
+ * and a path that already carries the correct root passes through. A tail that
169
+ * climbs above the root is returned as it is, because such a path must fail at
170
+ * the resolver as `out_of_scope`. A prefix would only make it look canonical.
171
+ */
172
+ export function toAnalysisRootPath(resourceId, path) {
173
+ const trimmed = path.trim();
174
+ if (trimmed === "")
175
+ return `/${resourceId}`;
176
+ // Already rooted at this analysis: normalize the tail, then attach the root again.
177
+ const rooted = trimmed.startsWith("/") ? stripAnalysisRoot(trimmed, resourceId) : null;
178
+ if (rooted !== null)
179
+ return joinAnalysisRoot(resourceId, normalizeTail(rooted));
180
+ // A record holds no host path. Thus a leading slash is how a model writes the
181
+ // same root-relative path, and the remainder is the tail.
182
+ const tail = normalizeTail(trimmed.replace(/^\/+/, ""));
183
+ if (tail === ".." || tail.startsWith("../"))
184
+ return trimmed;
185
+ return joinAnalysisRoot(resourceId, tail);
186
+ }
187
+ /** `/{resourceId}` plus a tail. An empty tail gives the bare root. */
188
+ function joinAnalysisRoot(resourceId, tail) {
152
189
  return tail === "" ? `/${resourceId}` : `/${resourceId}/${tail}`;
153
190
  }
191
+ /** Collapse `.`, `..`, and a doubled separator in a POSIX tail. A bare `.` becomes empty. */
192
+ function normalizeTail(tail) {
193
+ if (tail === "")
194
+ return "";
195
+ const normalized = posixPath.normalize(tail);
196
+ if (normalized === "." || normalized === "./")
197
+ return "";
198
+ return normalized.endsWith("/") ? normalized.slice(0, -1) : normalized;
199
+ }
154
200
  /**
155
201
  * Parent of every report session, workspace-root-relative. Callers that
156
202
  * enumerate the sessions join this. Callers that address one join
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@inflexa-ai/harness",
3
- "version": "0.26.3",
3
+ "version": "0.27.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",
@@ -69,8 +69,8 @@
69
69
  "cheerio": "^1.2.0",
70
70
  "dockerode": "^4.0.12",
71
71
  "echarts": "5.5.1",
72
- "fast-xml-parser": "^5.10.0",
73
- "hono": "^4.12.30",
72
+ "fast-xml-parser": "^5.10.1",
73
+ "hono": "^4.12.34",
74
74
  "hyparquet": "^1.28.1",
75
75
  "js-tiktoken": "^1.0.21",
76
76
  "mime": "^4.1.0",
@@ -83,6 +83,13 @@
83
83
  "puppeteer-core": "^23.11.1",
84
84
  "zod": "^4.4.3"
85
85
  },
86
+ "overrides": {
87
+ "ip-address": "^10.3.1",
88
+ "js-yaml": "^4.3.1",
89
+ "protobufjs": "^7.6.5",
90
+ "undici": "^7.29.0",
91
+ "uuid": "^11.1.1"
92
+ },
86
93
  "devDependencies": {
87
94
  "@eslint/js": "^10.0.1",
88
95
  "@types/bun": "latest",