@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.
- package/dist/app/data-profile-orientation.d.ts +12 -1
- package/dist/app/data-profile-orientation.js +14 -2
- package/dist/prompts/briefing.d.ts +14 -4
- package/dist/prompts/briefing.js +15 -5
- package/dist/state/init.js +23 -6
- package/dist/tasks/data-profile.js +1 -1
- package/dist/tools/ad-hoc-router.js +1 -1
- package/dist/tools/approval/gateway.d.ts +4 -2
- package/dist/tools/approval/gateway.js +5 -4
- package/dist/tools/approval/queries.d.ts +4 -2
- package/dist/tools/approval/queries.js +18 -14
- package/dist/tools/research/generate-plan.js +3 -3
- package/dist/tools/research/inspect-data-profile.d.ts +6 -0
- package/dist/tools/research/inspect-data-profile.js +23 -2
- package/dist/workflows/execute-analysis.js +1 -1
- package/dist/workspace/paths.d.ts +19 -0
- package/dist/workspace/paths.js +47 -1
- package/package.json +10 -3
|
@@ -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
|
-
/**
|
|
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
|
-
/**
|
|
54
|
-
readonly
|
|
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
|
package/dist/prompts/briefing.js
CHANGED
|
@@ -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.
|
|
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): \`${
|
|
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 [
|
|
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
|
}
|
package/dist/state/init.js
CHANGED
|
@@ -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
|
|
455
|
-
-- the ask's grant key (the displayed command when the tool supplied no
|
|
456
|
-
-- key), so a later matching ask auto-approves without pausing. Lives for
|
|
457
|
-
-- analysis lifecycle, surviving process restarts, and never applies to
|
|
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
|
-
|
|
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
|
|
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
|
|
40
|
-
// supplied a broader key — short-circuits the prompt: record
|
|
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)`
|
|
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', $
|
|
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', $
|
|
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)`
|
|
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 = $
|
|
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
|
-
|
|
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
|
package/dist/workspace/paths.js
CHANGED
|
@@ -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.
|
|
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.
|
|
73
|
-
"hono": "^4.12.
|
|
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",
|