@inflexa-ai/harness 0.4.1 → 0.4.2
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/execution/post-step-pipeline.d.ts +14 -3
- package/dist/execution/post-step-pipeline.js +72 -44
- package/dist/execution/reconcile-manifest.d.ts +3 -3
- package/dist/execution/reconcile-manifest.js +27 -14
- package/dist/lib/metrics.d.ts +2 -0
- package/dist/lib/metrics.js +6 -0
- package/dist/provenance/collector.d.ts +8 -5
- package/dist/provenance/collector.js +14 -6
- package/dist/provenance/exec-frame.d.ts +7 -1
- package/dist/provenance/exec-frame.js +8 -2
- package/dist/providers/errors.d.ts +11 -2
- package/dist/providers/errors.js +23 -2
- package/package.json +1 -1
|
@@ -60,9 +60,20 @@ export declare function generateStepSummaryAndWrite(deps: PostStepPipelineDeps,
|
|
|
60
60
|
*/
|
|
61
61
|
export declare function reconcileAndRegisterStepArtifacts(deps: PostStepPipelineDeps, postCtx: PostStepContext, manifest: readonly ArtifactManifestEntry[]): Promise<readonly ArtifactManifestEntry[]>;
|
|
62
62
|
/**
|
|
63
|
-
* Vector-index the threaded file descriptions + summary into the
|
|
64
|
-
* pgvector store. Best-effort (see the artifact-manifest spec):
|
|
65
|
-
* without failing the step
|
|
63
|
+
* Vector-index the threaded file descriptions + the step summary into the
|
|
64
|
+
* per-analysis pgvector store. Best-effort (see the artifact-manifest spec):
|
|
65
|
+
* indexing degrades without failing the step.
|
|
66
|
+
*
|
|
67
|
+
* Degradation is per-item. Index setup (ensuring the index exists and building
|
|
68
|
+
* the store) is all-or-nothing — a setup failure logs and skips the stage,
|
|
69
|
+
* because without an index there is nowhere to index into. Past setup, each
|
|
70
|
+
* file description and the summary is embedded and upserted under its own
|
|
71
|
+
* failure boundary, so one rejected input (e.g. an over-length document the
|
|
72
|
+
* embedding backend rejects) costs only its own entry and every other item is
|
|
73
|
+
* still attempted. A partial index is invisible at the search surface — it
|
|
74
|
+
* returns fewer hits, never an error — so each per-item failure logs the item
|
|
75
|
+
* id and input text length, and when any item fails a final record carries the
|
|
76
|
+
* indexed/failed counts as the only signal that degradation occurred.
|
|
66
77
|
*/
|
|
67
78
|
export declare function vectorIndexStepOutputs(deps: PostStepPipelineDeps, postCtx: PostStepContext, artifacts: PostStepArtifacts): Promise<void>;
|
|
68
79
|
/**
|
|
@@ -144,60 +144,88 @@ export async function reconcileAndRegisterStepArtifacts(deps, postCtx, manifest)
|
|
|
144
144
|
return reconciled.manifest;
|
|
145
145
|
}
|
|
146
146
|
/**
|
|
147
|
-
* Vector-index the threaded file descriptions + summary into the
|
|
148
|
-
* pgvector store. Best-effort (see the artifact-manifest spec):
|
|
149
|
-
* without failing the step
|
|
147
|
+
* Vector-index the threaded file descriptions + the step summary into the
|
|
148
|
+
* per-analysis pgvector store. Best-effort (see the artifact-manifest spec):
|
|
149
|
+
* indexing degrades without failing the step.
|
|
150
|
+
*
|
|
151
|
+
* Degradation is per-item. Index setup (ensuring the index exists and building
|
|
152
|
+
* the store) is all-or-nothing — a setup failure logs and skips the stage,
|
|
153
|
+
* because without an index there is nowhere to index into. Past setup, each
|
|
154
|
+
* file description and the summary is embedded and upserted under its own
|
|
155
|
+
* failure boundary, so one rejected input (e.g. an over-length document the
|
|
156
|
+
* embedding backend rejects) costs only its own entry and every other item is
|
|
157
|
+
* still attempted. A partial index is invisible at the search surface — it
|
|
158
|
+
* returns fewer hits, never an error — so each per-item failure logs the item
|
|
159
|
+
* id and input text length, and when any item fails a final record carries the
|
|
160
|
+
* indexed/failed counts as the only signal that degradation occurred.
|
|
150
161
|
*/
|
|
151
162
|
export async function vectorIndexStepOutputs(deps, postCtx, artifacts) {
|
|
152
163
|
const { input } = postCtx;
|
|
153
164
|
const { metadataEntries, summary, reconciledManifest } = artifacts;
|
|
165
|
+
const logger = (deps.logger ?? createNoopLogger()).named("post-step").named("vector-index").with({ runId: input.runId, stepId: input.stepId });
|
|
166
|
+
let vectorStore;
|
|
167
|
+
let indexName;
|
|
154
168
|
try {
|
|
169
|
+
// Setup is all-or-nothing: without an index there is nothing to index into.
|
|
155
170
|
await ensureSearchIndex(deps.pool, input.analysisId, deps.embedding.dimensions);
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
const
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
171
|
+
vectorStore = createVectorStore(deps.pool);
|
|
172
|
+
indexName = searchIndexName(input.analysisId);
|
|
173
|
+
}
|
|
174
|
+
catch (err) {
|
|
175
|
+
logger.warn("indexing failed", logger.errorFields(err));
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
const embedOne = async (text) => {
|
|
179
|
+
const [vec] = unwrapOrThrow(await deps.embedding.embed([text], postCtx.session));
|
|
180
|
+
if (!vec)
|
|
181
|
+
throw new Error("vectorIndexStepOutputs: empty embedding response");
|
|
182
|
+
return vec;
|
|
183
|
+
};
|
|
184
|
+
/** Index one item, absorbing its failure so the rest of the step still lands. */
|
|
185
|
+
const indexOne = async (id, text, metadata) => {
|
|
186
|
+
try {
|
|
187
|
+
const embedding = await embedOne(text);
|
|
188
|
+
unwrapOrThrow(await vectorStore.upsert({ indexName, vectors: [embedding], metadata: [metadata], ids: [id] }));
|
|
189
|
+
return true;
|
|
175
190
|
}
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
indexName,
|
|
182
|
-
vectors: [summaryEmbedding],
|
|
183
|
-
metadata: [
|
|
184
|
-
{
|
|
185
|
-
text: summary.markdown,
|
|
186
|
-
type: "summary",
|
|
187
|
-
stepId: input.stepId,
|
|
188
|
-
runId: input.runId,
|
|
189
|
-
agentId: input.agentId,
|
|
190
|
-
path: relPath,
|
|
191
|
-
},
|
|
192
|
-
],
|
|
193
|
-
ids: [summaryDbPath],
|
|
194
|
-
}));
|
|
191
|
+
catch (err) {
|
|
192
|
+
// Text length rides as a field because over-length input is the known
|
|
193
|
+
// failure driver, and the description/summary itself stays out of logs.
|
|
194
|
+
logger.warn("indexing failed", { id, textLength: text.length, ...logger.errorFields(err) });
|
|
195
|
+
return false;
|
|
195
196
|
}
|
|
197
|
+
};
|
|
198
|
+
const dbPathPrefix = `runs/${input.runId}/${input.stepId}/`;
|
|
199
|
+
const survivingPaths = new Set(reconciledManifest.map((a) => a.path));
|
|
200
|
+
const liveMetadata = metadataEntries.filter((e) => survivingPaths.has(e.dbPath.slice(dbPathPrefix.length)));
|
|
201
|
+
let indexed = 0;
|
|
202
|
+
let failed = 0;
|
|
203
|
+
for (const entry of liveMetadata) {
|
|
204
|
+
const ok = await indexOne(`/${input.analysisId}/${entry.dbPath}`, entry.description, { text: entry.description, type: "output", ...entry.metadata });
|
|
205
|
+
if (ok)
|
|
206
|
+
indexed++;
|
|
207
|
+
else
|
|
208
|
+
failed++;
|
|
196
209
|
}
|
|
197
|
-
|
|
198
|
-
const
|
|
199
|
-
|
|
210
|
+
if (summary && summary.markdown.trim().length > 0) {
|
|
211
|
+
const relPath = `runs/${input.runId}/${input.stepId}/output/summary.md`;
|
|
212
|
+
const ok = await indexOne(`/${input.analysisId}/${relPath}`, summary.markdown, {
|
|
213
|
+
text: summary.markdown,
|
|
214
|
+
type: "summary",
|
|
215
|
+
stepId: input.stepId,
|
|
216
|
+
runId: input.runId,
|
|
217
|
+
agentId: input.agentId,
|
|
218
|
+
path: relPath,
|
|
219
|
+
});
|
|
220
|
+
if (ok)
|
|
221
|
+
indexed++;
|
|
222
|
+
else
|
|
223
|
+
failed++;
|
|
200
224
|
}
|
|
225
|
+
// A partial index is invisible at the search surface — it returns fewer hits,
|
|
226
|
+
// never an error — so the count of what did not land is the only signal.
|
|
227
|
+
if (failed > 0)
|
|
228
|
+
logger.warn("indexed with failures", { indexed, failed });
|
|
201
229
|
}
|
|
202
230
|
/**
|
|
203
231
|
* Assemble the step-detail payload (file tree, summary markdown, output file
|
|
@@ -17,9 +17,9 @@ export interface ReconcileManifestInput {
|
|
|
17
17
|
/** Collector backing the manifest; mutated to reflect drops/rehashes. */
|
|
18
18
|
collector: ProvenanceCollector;
|
|
19
19
|
/**
|
|
20
|
-
* Operational logging seam; omitted falls back to no-op.
|
|
21
|
-
*
|
|
22
|
-
*
|
|
20
|
+
* Operational logging seam; omitted falls back to no-op. What this records
|
|
21
|
+
* — which entry or input was dropped, which input could not be hashed — is
|
|
22
|
+
* the sole account of why lineage changed or a step died.
|
|
23
23
|
*/
|
|
24
24
|
logger?: Logger;
|
|
25
25
|
}
|
|
@@ -23,7 +23,7 @@ import { stat } from "node:fs/promises";
|
|
|
23
23
|
import path from "node:path";
|
|
24
24
|
import { createNoopLogger } from "../lib/console-logger.js";
|
|
25
25
|
import { computeSha256File, hasValidContentHash } from "../lib/fs-helpers.js";
|
|
26
|
-
import { artifactReconcileDropped } from "../lib/metrics.js";
|
|
26
|
+
import { artifactReconcileDropped, lineageInputDropped } from "../lib/metrics.js";
|
|
27
27
|
export async function reconcileManifestWithDisk(input) {
|
|
28
28
|
const { workspaceRoot, resourceId, runId, stepId, agentId, manifest, collector } = input;
|
|
29
29
|
const logger = (input.logger ?? createNoopLogger()).named("reconcile-manifest").with({ runId, stepId, agentId });
|
|
@@ -74,7 +74,7 @@ export async function reconcileManifestWithDisk(input) {
|
|
|
74
74
|
const hash = await computeSha256File(absPath);
|
|
75
75
|
reconciled.push({ ...entry, hash, size: info.size });
|
|
76
76
|
}
|
|
77
|
-
await fillInputHashesFromDisk(collector, workspaceRoot, resourceId, runId, stepId, logger);
|
|
77
|
+
await fillInputHashesFromDisk(collector, workspaceRoot, resourceId, runId, stepId, agentId, logger);
|
|
78
78
|
return { manifest: reconciled, droppedCount };
|
|
79
79
|
}
|
|
80
80
|
/**
|
|
@@ -87,35 +87,47 @@ export async function reconcileManifestWithDisk(input) {
|
|
|
87
87
|
*
|
|
88
88
|
* `artifacts`-source reads are the step's OWN files (mutating, dropped by the
|
|
89
89
|
* registration translator), so they are not attested and not hashed here.
|
|
90
|
-
* Lineage attestation is fail-fast: an input
|
|
91
|
-
*
|
|
90
|
+
* Lineage attestation is fail-fast for drift: an input that should be on disk
|
|
91
|
+
* and is not (ENOENT) throws rather than registering a hashless edge. A read
|
|
92
|
+
* that resolves OUTSIDE the analysis tree is dropped from lineage instead —
|
|
93
|
+
* the sandbox can legitimately open paths above its mount (`/{resourceId}/..`
|
|
94
|
+
* is the container root) and a capture layer may report them; that is out of
|
|
95
|
+
* scope, not drift, and failing the step over it killed real analyses in the
|
|
96
|
+
* field. Dropping still registers no hashless edge, which is the invariant.
|
|
92
97
|
*/
|
|
93
|
-
async function fillInputHashesFromDisk(collector, workspaceRoot, resourceId, runId, stepId, logger) {
|
|
98
|
+
async function fillInputHashesFromDisk(collector, workspaceRoot, resourceId, runId, stepId, agentId, logger) {
|
|
94
99
|
const resourceRoot = path.resolve(workspaceRoot);
|
|
95
100
|
for (const ref of collector.getTrackedInputs()) {
|
|
96
101
|
if (ref.source === "artifacts")
|
|
97
102
|
continue;
|
|
98
103
|
if (hasValidContentHash(ref.hash))
|
|
99
104
|
continue;
|
|
100
|
-
//
|
|
101
|
-
//
|
|
102
|
-
//
|
|
103
|
-
//
|
|
104
|
-
//
|
|
105
|
+
// Every throw below is logged before it is raised, and every drop is
|
|
106
|
+
// logged as it happens. The thrown message reaches `failStep` as one
|
|
107
|
+
// opaque string and everything downstream of that is scrubbed, so naming
|
|
108
|
+
// the site and the offending ref HERE is the only account of why a step
|
|
109
|
+
// died — the read's `source` in particular says whether the step declared
|
|
105
110
|
// this input at all.
|
|
106
111
|
const attestation = { path: ref.path, source: ref.source, refRunId: ref.runId, refStepId: ref.stepId };
|
|
107
112
|
// `ref.path` is the absolute container path `/{resourceId}/...`; strip the
|
|
108
113
|
// mount segment and map the tail onto the host workspace root. `path.join`
|
|
109
114
|
// normalizes any `..`, and the bound confines the read to the analysis tree.
|
|
115
|
+
// An out-of-tree resolution is dropped, not thrown: it is not an attestable
|
|
116
|
+
// lineage edge, but it is also not drift — mirrors the out-of-bounds output
|
|
117
|
+
// skip in `reconcileManifestWithDisk` and the directory drop below.
|
|
110
118
|
const containerPrefix = `/${resourceId}`;
|
|
111
119
|
if (ref.path !== containerPrefix && !ref.path.startsWith(containerPrefix + "/")) {
|
|
112
|
-
logger.
|
|
113
|
-
|
|
120
|
+
logger.warn("dropping out-of-tree input from lineage", { ...attestation, boundSite: "container-prefix" });
|
|
121
|
+
lineageInputDropped.add(1, { agent_id: agentId, step_id: stepId, reason: "container-prefix" });
|
|
122
|
+
collector.dropInput(ref);
|
|
123
|
+
continue;
|
|
114
124
|
}
|
|
115
125
|
const hostPath = path.join(resourceRoot, ref.path.slice(containerPrefix.length + 1));
|
|
116
126
|
if (hostPath !== resourceRoot && !hostPath.startsWith(resourceRoot + path.sep)) {
|
|
117
|
-
logger.
|
|
118
|
-
|
|
127
|
+
logger.warn("dropping out-of-tree input from lineage", { ...attestation, hostPath, boundSite: "workspace-root" });
|
|
128
|
+
lineageInputDropped.add(1, { agent_id: agentId, step_id: stepId, reason: "workspace-root" });
|
|
129
|
+
collector.dropInput(ref);
|
|
130
|
+
continue;
|
|
119
131
|
}
|
|
120
132
|
let info;
|
|
121
133
|
try {
|
|
@@ -138,6 +150,7 @@ async function fillInputHashesFromDisk(collector, workspaceRoot, resourceId, run
|
|
|
138
150
|
// non-file drop above. A genuinely missing FILE (ENOENT) still fails fast
|
|
139
151
|
// as drift; a directory is not drift.
|
|
140
152
|
logger.debug("dropping non-file input from lineage", attestation);
|
|
153
|
+
lineageInputDropped.add(1, { agent_id: agentId, step_id: stepId, reason: "directory" });
|
|
141
154
|
collector.dropInput(ref);
|
|
142
155
|
continue;
|
|
143
156
|
}
|
package/dist/lib/metrics.d.ts
CHANGED
|
@@ -3,5 +3,7 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Instruments:
|
|
5
5
|
* - cortex.artifact.reconcile.dropped — counter for missing manifest entries
|
|
6
|
+
* - cortex.artifact.reconcile.input_dropped — counter for lineage input drops
|
|
6
7
|
*/
|
|
7
8
|
export declare const artifactReconcileDropped: import("@opentelemetry/api").Counter<import("@opentelemetry/api").Attributes>;
|
|
9
|
+
export declare const lineageInputDropped: import("@opentelemetry/api").Counter<import("@opentelemetry/api").Attributes>;
|
package/dist/lib/metrics.js
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Instruments:
|
|
5
5
|
* - cortex.artifact.reconcile.dropped — counter for missing manifest entries
|
|
6
|
+
* - cortex.artifact.reconcile.input_dropped — counter for lineage input drops
|
|
6
7
|
*/
|
|
7
8
|
import { metrics } from "@opentelemetry/api";
|
|
8
9
|
const meter = metrics.getMeter("cortex");
|
|
@@ -10,3 +11,8 @@ export const artifactReconcileDropped = meter.createCounter("cortex.artifact.rec
|
|
|
10
11
|
description: "Manifest entries dropped because their on-disk file was missing at " +
|
|
11
12
|
"registration time (writes-then-deletes, renames). Tagged by agent_id, step_id.",
|
|
12
13
|
});
|
|
14
|
+
export const lineageInputDropped = meter.createCounter("cortex.artifact.reconcile.input_dropped", {
|
|
15
|
+
description: "Tracked input reads dropped from lineage at reconcile because they are " +
|
|
16
|
+
"not content-attestable files of the analysis (directory reads, " +
|
|
17
|
+
"out-of-tree resolutions). Tagged by agent_id, step_id, reason.",
|
|
18
|
+
});
|
|
@@ -86,7 +86,9 @@ export declare class ProvenanceCollector {
|
|
|
86
86
|
* Called by sandbox exec provenance frame processing.
|
|
87
87
|
*
|
|
88
88
|
* @param mountPath - The workspace mount path (e.g., "/a1/data" or "/a1" for single-mount)
|
|
89
|
-
* @param relativePath - Path relative to the mount root
|
|
89
|
+
* @param relativePath - Path relative to the mount root; or, for a frame
|
|
90
|
+
* report naming somewhere outside the mount, the verbatim absolute path
|
|
91
|
+
* (see `stripMountPrefix`)
|
|
90
92
|
* @param hash - SHA-256 hash of the file content, or null if file no longer exists
|
|
91
93
|
* @param context - Explicit classification context from the caller. When provided,
|
|
92
94
|
* the collector uses it directly instead of classifying from the path.
|
|
@@ -124,10 +126,11 @@ export declare class ProvenanceCollector {
|
|
|
124
126
|
* Drop an input ref from lineage entirely — removes it from the tracked-input
|
|
125
127
|
* map and from every record's `inputs` array (identity match, since the refs
|
|
126
128
|
* are shared objects). `reconcileManifestWithDisk` calls this for a read that
|
|
127
|
-
*
|
|
128
|
-
*
|
|
129
|
-
*
|
|
130
|
-
*
|
|
129
|
+
* is not a content-attestable file of this analysis: one resolving to a
|
|
130
|
+
* directory (e.g. an `ls` / `list.files` of a mount, which the inotify frame
|
|
131
|
+
* tracks as an open), or one resolving outside the analysis tree (e.g.
|
|
132
|
+
* `/{resourceId}/..`, the container root). Neither may reach registration.
|
|
133
|
+
* Mirrors the output-side non-file and out-of-bounds drops.
|
|
131
134
|
*/
|
|
132
135
|
dropInput(ref: InputRef): void;
|
|
133
136
|
/**
|
|
@@ -121,7 +121,9 @@ export class ProvenanceCollector {
|
|
|
121
121
|
* Called by sandbox exec provenance frame processing.
|
|
122
122
|
*
|
|
123
123
|
* @param mountPath - The workspace mount path (e.g., "/a1/data" or "/a1" for single-mount)
|
|
124
|
-
* @param relativePath - Path relative to the mount root
|
|
124
|
+
* @param relativePath - Path relative to the mount root; or, for a frame
|
|
125
|
+
* report naming somewhere outside the mount, the verbatim absolute path
|
|
126
|
+
* (see `stripMountPrefix`)
|
|
125
127
|
* @param hash - SHA-256 hash of the file content, or null if file no longer exists
|
|
126
128
|
* @param context - Explicit classification context from the caller. When provided,
|
|
127
129
|
* the collector uses it directly instead of classifying from the path.
|
|
@@ -133,7 +135,12 @@ export class ProvenanceCollector {
|
|
|
133
135
|
return existing;
|
|
134
136
|
// Use explicit context if provided, otherwise fall back to path classification
|
|
135
137
|
const classification = context ?? classifyReadPath(relativePath, this.stepId, this.runId, this.dependsOn);
|
|
136
|
-
|
|
138
|
+
// A still-absolute `relativePath` is a report the caller could not
|
|
139
|
+
// relativize — it names somewhere outside the mount. It rides verbatim:
|
|
140
|
+
// prepending the mount root would forge an in-tree name for a foreign
|
|
141
|
+
// path, and reconcile would then see phantom drift (a missing file,
|
|
142
|
+
// which fails the step) instead of an out-of-tree read (which it drops).
|
|
143
|
+
const fullPath = relativePath.startsWith("/") ? relativePath : `${mountPath}/${relativePath}`;
|
|
137
144
|
// Resolve fileId for data-source inputs via materialized file metadata map
|
|
138
145
|
let fileId;
|
|
139
146
|
if (classification.source === "data" && this.inputFileIdMap) {
|
|
@@ -240,10 +247,11 @@ export class ProvenanceCollector {
|
|
|
240
247
|
* Drop an input ref from lineage entirely — removes it from the tracked-input
|
|
241
248
|
* map and from every record's `inputs` array (identity match, since the refs
|
|
242
249
|
* are shared objects). `reconcileManifestWithDisk` calls this for a read that
|
|
243
|
-
*
|
|
244
|
-
*
|
|
245
|
-
*
|
|
246
|
-
*
|
|
250
|
+
* is not a content-attestable file of this analysis: one resolving to a
|
|
251
|
+
* directory (e.g. an `ls` / `list.files` of a mount, which the inotify frame
|
|
252
|
+
* tracks as an open), or one resolving outside the analysis tree (e.g.
|
|
253
|
+
* `/{resourceId}/..`, the container root). Neither may reach registration.
|
|
254
|
+
* Mirrors the output-side non-file and out-of-bounds drops.
|
|
247
255
|
*/
|
|
248
256
|
dropInput(ref) {
|
|
249
257
|
for (const [key, val] of this.inputAccesses) {
|
|
@@ -14,7 +14,13 @@
|
|
|
14
14
|
*/
|
|
15
15
|
import type { ProvenanceFrame } from "../sandbox/types.js";
|
|
16
16
|
import { type ProvenanceCollector } from "./collector.js";
|
|
17
|
-
/**
|
|
17
|
+
/**
|
|
18
|
+
* Strip the `/{resourceId}/` mount prefix from an absolute frame path.
|
|
19
|
+
* Separators doubled at the boundary collapse (`/{rid}//a` → `a`) so an
|
|
20
|
+
* in-mount name lands on its canonical relative form. A path not under the
|
|
21
|
+
* mount comes back unchanged — still absolute — and the collector carries it
|
|
22
|
+
* verbatim (see `trackInputAccess`).
|
|
23
|
+
*/
|
|
18
24
|
export declare function stripMountPrefix(mountRoot: string, absPath: string): string;
|
|
19
25
|
export interface FeedExecFrameArgs {
|
|
20
26
|
readonly collector: ProvenanceCollector;
|
|
@@ -13,10 +13,16 @@
|
|
|
13
13
|
* It never throws — provenance is best-effort and must not fail an exec.
|
|
14
14
|
*/
|
|
15
15
|
import { classifyReadPath } from "./collector.js";
|
|
16
|
-
/**
|
|
16
|
+
/**
|
|
17
|
+
* Strip the `/{resourceId}/` mount prefix from an absolute frame path.
|
|
18
|
+
* Separators doubled at the boundary collapse (`/{rid}//a` → `a`) so an
|
|
19
|
+
* in-mount name lands on its canonical relative form. A path not under the
|
|
20
|
+
* mount comes back unchanged — still absolute — and the collector carries it
|
|
21
|
+
* verbatim (see `trackInputAccess`).
|
|
22
|
+
*/
|
|
17
23
|
export function stripMountPrefix(mountRoot, absPath) {
|
|
18
24
|
const prefix = mountRoot.endsWith("/") ? mountRoot : `${mountRoot}/`;
|
|
19
|
-
return absPath.startsWith(prefix) ? absPath.slice(prefix.length) : absPath;
|
|
25
|
+
return absPath.startsWith(prefix) ? absPath.slice(prefix.length).replace(/^\/+/, "") : absPath;
|
|
20
26
|
}
|
|
21
27
|
/**
|
|
22
28
|
* Translate one exec's frame into a collector record. Per-command input
|
|
@@ -10,9 +10,12 @@
|
|
|
10
10
|
* is required to disambiguate): the gateway emits `402` exclusively for
|
|
11
11
|
* `budget_exceeded` and `403` for a blocked tenant. Provider-originated
|
|
12
12
|
* failures (429, 5xx, connection) surface with their own status or as a
|
|
13
|
-
* network error with no status at all.
|
|
13
|
+
* network error with no status at all. A `401` is `auth`: the credential the
|
|
14
|
+
* host put behind the call is expired, revoked, or absent, and only a human
|
|
15
|
+
* re-authenticating fixes it — which is a different remedy from every other
|
|
16
|
+
* non-retryable 4xx, so it is a different kind.
|
|
14
17
|
*/
|
|
15
|
-
export type ProviderErrorKind = "budget" | "tenant-blocked" | "provider";
|
|
18
|
+
export type ProviderErrorKind = "auth" | "budget" | "tenant-blocked" | "provider";
|
|
16
19
|
/**
|
|
17
20
|
* The provider error value channel — a `DomainError`-conforming
|
|
18
21
|
* discriminated union mirroring `ProviderErrorKind`. `chat` / `embed` return
|
|
@@ -23,6 +26,11 @@ export type ProviderErrorKind = "budget" | "tenant-blocked" | "provider";
|
|
|
23
26
|
* step boundary.
|
|
24
27
|
*/
|
|
25
28
|
export type ProviderError = {
|
|
29
|
+
readonly type: "auth";
|
|
30
|
+
readonly retryable: false;
|
|
31
|
+
readonly message: string;
|
|
32
|
+
readonly cause?: unknown;
|
|
33
|
+
} | {
|
|
26
34
|
readonly type: "budget";
|
|
27
35
|
readonly retryable: false;
|
|
28
36
|
readonly message: string;
|
|
@@ -72,6 +80,7 @@ export interface ProviderErrorClassification {
|
|
|
72
80
|
/**
|
|
73
81
|
* Classify a provider failure by origin.
|
|
74
82
|
*
|
|
83
|
+
* - Provider `401` → `auth`, not retryable.
|
|
75
84
|
* - Billing-gateway `402` → `budget`, not retryable.
|
|
76
85
|
* - Billing-gateway `403` → `tenant-blocked`, not retryable.
|
|
77
86
|
* - Provider `429` / `5xx` / connection errors → `provider`, retryable.
|
package/dist/providers/errors.js
CHANGED
|
@@ -10,7 +10,10 @@
|
|
|
10
10
|
* is required to disambiguate): the gateway emits `402` exclusively for
|
|
11
11
|
* `budget_exceeded` and `403` for a blocked tenant. Provider-originated
|
|
12
12
|
* failures (429, 5xx, connection) surface with their own status or as a
|
|
13
|
-
* network error with no status at all.
|
|
13
|
+
* network error with no status at all. A `401` is `auth`: the credential the
|
|
14
|
+
* host put behind the call is expired, revoked, or absent, and only a human
|
|
15
|
+
* re-authenticating fixes it — which is a different remedy from every other
|
|
16
|
+
* non-retryable 4xx, so it is a different kind.
|
|
14
17
|
*/
|
|
15
18
|
/**
|
|
16
19
|
* Structural guard for an already-constructed `ProviderError` value. A
|
|
@@ -22,7 +25,9 @@ export function isProviderError(value) {
|
|
|
22
25
|
if (typeof value !== "object" || value === null)
|
|
23
26
|
return false;
|
|
24
27
|
const v = value;
|
|
25
|
-
return (v.type === "
|
|
28
|
+
return ((v.type === "auth" || v.type === "budget" || v.type === "tenant-blocked" || v.type === "provider") &&
|
|
29
|
+
typeof v.retryable === "boolean" &&
|
|
30
|
+
typeof v.message === "string");
|
|
26
31
|
}
|
|
27
32
|
/**
|
|
28
33
|
* Turn a caught SDK throwable into a `ProviderError` value. Routes through
|
|
@@ -42,6 +47,14 @@ export function toProviderError(e, workload) {
|
|
|
42
47
|
return e;
|
|
43
48
|
const { kind, retryable } = classifyProviderError(e);
|
|
44
49
|
const detail = e instanceof Error ? e.message : String(e);
|
|
50
|
+
if (kind === "auth") {
|
|
51
|
+
return {
|
|
52
|
+
type: "auth",
|
|
53
|
+
retryable: false,
|
|
54
|
+
message: `Provider rejected the credential for ${workload} — it is expired, revoked, or absent: ${detail}`,
|
|
55
|
+
cause: e,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
45
58
|
if (kind === "budget") {
|
|
46
59
|
return {
|
|
47
60
|
type: "budget",
|
|
@@ -109,6 +122,7 @@ function looksLikeConnectionError(err) {
|
|
|
109
122
|
/**
|
|
110
123
|
* Classify a provider failure by origin.
|
|
111
124
|
*
|
|
125
|
+
* - Provider `401` → `auth`, not retryable.
|
|
112
126
|
* - Billing-gateway `402` → `budget`, not retryable.
|
|
113
127
|
* - Billing-gateway `403` → `tenant-blocked`, not retryable.
|
|
114
128
|
* - Provider `429` / `5xx` / connection errors → `provider`, retryable.
|
|
@@ -116,6 +130,13 @@ function looksLikeConnectionError(err) {
|
|
|
116
130
|
*/
|
|
117
131
|
export function classifyProviderError(e) {
|
|
118
132
|
const status = extractStatus(e);
|
|
133
|
+
// `auth` is its own kind rather than a plain non-retryable 4xx because the
|
|
134
|
+
// remedy is categorically different: the request was well-formed and the
|
|
135
|
+
// credential behind it is not, so no amount of re-issuing or rephrasing
|
|
136
|
+
// helps — a human has to re-authenticate. Callers surface that; the generic
|
|
137
|
+
// 4xx branch below would tell them the request was wrong, which is false.
|
|
138
|
+
if (status === 401)
|
|
139
|
+
return { kind: "auth", retryable: false };
|
|
119
140
|
if (status === 402)
|
|
120
141
|
return { kind: "budget", retryable: false };
|
|
121
142
|
if (status === 403)
|
package/package.json
CHANGED