@agent-finops/core 0.8.1 → 0.9.1
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/README.md +5 -3
- package/dist/actionPlanner.d.ts +140 -0
- package/dist/actionPlanner.js +938 -0
- package/dist/actionVerification.d.ts +1240 -0
- package/dist/actionVerification.js +1028 -0
- package/dist/activitySnapshot.d.ts +142 -50
- package/dist/activitySnapshot.js +145 -6
- package/dist/activitySnapshotCache.d.ts +8 -1
- package/dist/activitySnapshotCache.js +103 -7
- package/dist/agentDraftToken.d.ts +80 -0
- package/dist/agentDraftToken.js +188 -0
- package/dist/agentEconomicsReceipt.d.ts +74 -74
- package/dist/agentLoopContract.d.ts +27 -0
- package/dist/agentLoopContract.js +36 -0
- package/dist/glance.d.ts +27 -1
- package/dist/glance.js +151 -12
- package/dist/guidedAnswer.d.ts +51 -0
- package/dist/guidedAnswer.js +352 -0
- package/dist/index.d.ts +14 -2
- package/dist/index.js +13 -1
- package/dist/localAgentFormats/gemini.js +2 -2
- package/dist/localAgentFormats/registry.js +6 -2
- package/dist/localAgentFormats/runtimeRegistry.js +5 -2
- package/dist/localAgentFormats/types.d.ts +2 -1
- package/dist/localAgentLogs.d.ts +362 -3
- package/dist/localAgentLogs.js +1964 -165
- package/dist/modelPricing.d.ts +1 -1
- package/dist/modelPricing.js +1 -1
- package/dist/projectEconomics.d.ts +617 -0
- package/dist/projectEconomics.js +620 -0
- package/dist/projectEconomicsBuilder.d.ts +89 -0
- package/dist/projectEconomicsBuilder.js +473 -0
- package/dist/projectIndexStore.d.ts +545 -0
- package/dist/projectIndexStore.js +606 -0
- package/dist/providerConnectors.d.ts +161 -1
- package/dist/providerConnectors.js +406 -11
- package/dist/qualitativeIndexCache.d.ts +494 -0
- package/dist/qualitativeIndexCache.js +930 -0
- package/dist/resultCard.d.ts +350 -0
- package/dist/resultCard.js +604 -0
- package/dist/runtimeCommands.d.ts +36 -0
- package/dist/runtimeCommands.js +50 -0
- package/dist/scanGuard.d.ts +3 -1
- package/dist/scanGuard.js +164 -4
- package/dist/schema.d.ts +33 -31
- package/dist/schema.js +9 -1
- package/dist/sessionVitals.d.ts +145 -0
- package/dist/sessionVitals.js +521 -0
- package/dist/toolInvocations.d.ts +40 -1
- package/dist/toolInvocations.js +101 -20
- package/package.json +1 -1
|
@@ -0,0 +1,606 @@
|
|
|
1
|
+
import { constants } from "node:fs";
|
|
2
|
+
import { lstat, mkdir, open, readdir, rename, unlink } from "node:fs/promises";
|
|
3
|
+
import { randomUUID } from "node:crypto";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { z } from "zod";
|
|
6
|
+
import { localAgentFinancialParserVersion, localAgentQualitativeParserVersion } from "./localAgentLogs.js";
|
|
7
|
+
import { QualitativeIndexCacheError, cachedWindowCoversRequest, hasPrivatePermissions, invocationWindowCanBeNarrowedExactly, isNodeError, noFollowFlag, qualitativeEntryKeySchema, qualitativeEntryValueSchema, qualitativeKeyFingerprint, resolveCacheDirectory, sameQualitativeFileKey, stripRawPaths, syncDirectory, withWriterLock } from "./qualitativeIndexCache.js";
|
|
8
|
+
export const projectIndexStoreDirectoryName = "project-index-v2";
|
|
9
|
+
export const projectIndexStoreLockFileName = ".project-index-v2.lock";
|
|
10
|
+
/** One transcript's derived evidence, all windowed variants included. */
|
|
11
|
+
export const projectIndexMaxDocumentBytes = 8 * 1_024 * 1_024;
|
|
12
|
+
/** Null-window variant plus the newest bounded windows (BLOCKER-2 option i). */
|
|
13
|
+
export const projectIndexMaxWindowedVariants = 4;
|
|
14
|
+
export const projectIndexFinancialParserVersion = localAgentFinancialParserVersion;
|
|
15
|
+
/**
|
|
16
|
+
* The financial value reuses the strict privacy-reduced v1 value contract.
|
|
17
|
+
* Financial parses never carry invocation evidence; the schema keeps that
|
|
18
|
+
* impossible rather than merely unexpected.
|
|
19
|
+
*/
|
|
20
|
+
const financialValueSchema = qualitativeEntryValueSchema.refine((value) => value.invocationFile === undefined && value.invocationWindowProof === undefined, { message: "financial entries never carry invocation evidence" });
|
|
21
|
+
const financialKeySchema = z.object({
|
|
22
|
+
schemaVersion: z.literal(2),
|
|
23
|
+
section: z.literal("financial"),
|
|
24
|
+
agent: z.enum(["claude-code", "codex", "gemini-cli"]),
|
|
25
|
+
pathHash: z.string().regex(/^[a-f0-9]{64}$/),
|
|
26
|
+
fileIdentity: z.string().min(11).max(256),
|
|
27
|
+
financialParserVersion: z.literal(projectIndexFinancialParserVersion)
|
|
28
|
+
}).strict();
|
|
29
|
+
const qualitativeVariantSchema = z.object({
|
|
30
|
+
key: qualitativeEntryKeySchema,
|
|
31
|
+
storedAt: z.string().datetime({ offset: true }),
|
|
32
|
+
value: qualitativeEntryValueSchema
|
|
33
|
+
}).strict();
|
|
34
|
+
const financialSectionSchema = z.object({
|
|
35
|
+
key: financialKeySchema,
|
|
36
|
+
storedAt: z.string().datetime({ offset: true }),
|
|
37
|
+
value: financialValueSchema
|
|
38
|
+
}).strict();
|
|
39
|
+
/**
|
|
40
|
+
* Header-pass ownership evidence (A4a consumer). "unknown" is a first-class
|
|
41
|
+
* state: ownership is never guessed from hashes, basenames, or absence.
|
|
42
|
+
*/
|
|
43
|
+
const ownershipSchema = z.object({
|
|
44
|
+
status: z.enum(["resolved", "no_calls", "unknown"]),
|
|
45
|
+
/** Ownership binds to one exact file identity; rotation supersedes it. */
|
|
46
|
+
fileIdentity: z.string().min(11).max(256),
|
|
47
|
+
projectRefs: z.array(z.string().regex(/^avref_[a-f0-9]{64}$/)).max(64),
|
|
48
|
+
headerAttribution: z.object({
|
|
49
|
+
status: z.enum(["proven", "unknown"]),
|
|
50
|
+
projectRef: z.string().regex(/^avref_[a-f0-9]{64}$/).optional(),
|
|
51
|
+
/** Header subagent marker — a scheduling hint only, never ownership. */
|
|
52
|
+
isSubagent: z.boolean().optional()
|
|
53
|
+
}).strict().optional()
|
|
54
|
+
}).strict();
|
|
55
|
+
/**
|
|
56
|
+
* Stream checkpoint envelope (design section e). The store validates the
|
|
57
|
+
* envelope strictly and treats the reducer/collector payloads as opaque
|
|
58
|
+
* bounded JSON: the loader owns their structure and re-validates on every
|
|
59
|
+
* resume, so a malformed payload degrades to a restart, never a crash or a
|
|
60
|
+
* reinterpretation. The parser version is pinned as a literal so checkpoints
|
|
61
|
+
* from any other parser contract fail closed as misses.
|
|
62
|
+
*
|
|
63
|
+
* schemaVersion 2: version 1 checkpoints were written before prompt
|
|
64
|
+
* survivors had absolute-path spans stripped, so raw local paths can sit in
|
|
65
|
+
* their reducer state on disk. They are deliberately unreadable AND purged
|
|
66
|
+
* on sight by the read path below — never resumed, never reinterpreted.
|
|
67
|
+
*/
|
|
68
|
+
const checkpointDocumentSchema = z.object({
|
|
69
|
+
kind: z.literal("aibill.project_index_checkpoint"),
|
|
70
|
+
schemaVersion: z.literal(2),
|
|
71
|
+
agent: z.enum(["claude-code", "codex", "gemini-cli"]),
|
|
72
|
+
pathHash: z.string().regex(/^[a-f0-9]{64}$/),
|
|
73
|
+
storedAt: z.string().datetime({ offset: true }),
|
|
74
|
+
checkpoint: z.object({
|
|
75
|
+
pin: z.object({
|
|
76
|
+
dev: z.number().finite(),
|
|
77
|
+
ino: z.number().finite(),
|
|
78
|
+
birthtimeMs: z.number().finite()
|
|
79
|
+
}).strict(),
|
|
80
|
+
parserVersion: z.literal(localAgentQualitativeParserVersion),
|
|
81
|
+
collectInvocationEvidence: z.boolean(),
|
|
82
|
+
sinceIso: z.string().datetime({ offset: true }).nullable(),
|
|
83
|
+
offset: z.number().int().nonnegative(),
|
|
84
|
+
prefixProbe: z.object({
|
|
85
|
+
bytes: z.number().int().nonnegative().max(64 * 1_024),
|
|
86
|
+
sha256: z.string().regex(/^[a-f0-9]{64}$/)
|
|
87
|
+
}).strict(),
|
|
88
|
+
reducerState: z.unknown(),
|
|
89
|
+
collectorState: z.unknown().optional()
|
|
90
|
+
}).strict()
|
|
91
|
+
}).strict();
|
|
92
|
+
const documentSchema = z.object({
|
|
93
|
+
kind: z.literal("aibill.project_index_file"),
|
|
94
|
+
schemaVersion: z.literal(2),
|
|
95
|
+
agent: z.enum(["claude-code", "codex", "gemini-cli"]),
|
|
96
|
+
pathHash: z.string().regex(/^[a-f0-9]{64}$/),
|
|
97
|
+
qualitative: z.array(qualitativeVariantSchema).max(projectIndexMaxWindowedVariants),
|
|
98
|
+
financial: financialSectionSchema.optional(),
|
|
99
|
+
ownership: ownershipSchema.optional()
|
|
100
|
+
}).strict();
|
|
101
|
+
export const projectIndexGcGraceMs = 10 * 60 * 1_000;
|
|
102
|
+
/**
|
|
103
|
+
* Sharded per-transcript store. Entry documents are lock-free: writes are
|
|
104
|
+
* private O_EXCL temp files atomically renamed over the shard path, so readers
|
|
105
|
+
* observe either complete version and concurrent same-key writers converge on
|
|
106
|
+
* semantically identical content (storedAt may differ; last writer wins).
|
|
107
|
+
* The writer lock guards only cross-document operations (GC).
|
|
108
|
+
*/
|
|
109
|
+
export function createProjectIndexAdapters(options = {}) {
|
|
110
|
+
const memoize = options.memoizeDocuments !== false;
|
|
111
|
+
const documents = new Map();
|
|
112
|
+
const invalidate = (cachePath) => documents.delete(cachePath);
|
|
113
|
+
const document = (cachePath, storeRoot) => {
|
|
114
|
+
if (!memoize)
|
|
115
|
+
return readDocument(join(storeRoot, cachePath));
|
|
116
|
+
let loaded = documents.get(cachePath);
|
|
117
|
+
if (!loaded) {
|
|
118
|
+
loaded = readDocument(join(storeRoot, cachePath)).catch((error) => {
|
|
119
|
+
documents.delete(cachePath);
|
|
120
|
+
throw error;
|
|
121
|
+
});
|
|
122
|
+
documents.set(cachePath, loaded);
|
|
123
|
+
}
|
|
124
|
+
return loaded;
|
|
125
|
+
};
|
|
126
|
+
// Resolve and validate the private cache directory once per adapter
|
|
127
|
+
// instance, exactly like v1's memoized index load: the validation includes
|
|
128
|
+
// git-privacy probes that spawn subprocesses, and repeating them for every
|
|
129
|
+
// sharded document read multiplies a ~40 ms check into whole seconds.
|
|
130
|
+
const resolvedRoots = new Map();
|
|
131
|
+
const storeRootFor = (create) => {
|
|
132
|
+
const mode = create ? "create" : "read";
|
|
133
|
+
let resolved = resolvedRoots.get(mode) ??
|
|
134
|
+
(create ? resolvedRoots.get("read") : undefined);
|
|
135
|
+
if (!resolved) {
|
|
136
|
+
resolved = resolveCacheDirectory(create, options).then(async (cacheDirectory) => {
|
|
137
|
+
const storeRoot = join(cacheDirectory, projectIndexStoreDirectoryName);
|
|
138
|
+
if (create)
|
|
139
|
+
await mkdir(join(storeRoot, "entries"), { recursive: true, mode: 0o700 });
|
|
140
|
+
return storeRoot;
|
|
141
|
+
}).catch((error) => {
|
|
142
|
+
resolvedRoots.delete(mode);
|
|
143
|
+
throw error;
|
|
144
|
+
});
|
|
145
|
+
resolvedRoots.set(mode, resolved);
|
|
146
|
+
// A successful create-mode resolution satisfies read mode too.
|
|
147
|
+
if (create)
|
|
148
|
+
resolvedRoots.set("read", resolved);
|
|
149
|
+
}
|
|
150
|
+
return resolved;
|
|
151
|
+
};
|
|
152
|
+
const withStoreRoot = async (create, operation) => operation(await storeRootFor(create));
|
|
153
|
+
return {
|
|
154
|
+
qualitative: {
|
|
155
|
+
read: async (key) => {
|
|
156
|
+
const parsedKey = parseQualitativeKey(key);
|
|
157
|
+
return withStoreRoot(false, async (storeRoot) => {
|
|
158
|
+
const doc = await document(shardPath(parsedKey.pathHash), storeRoot);
|
|
159
|
+
if (!doc || doc.agent !== parsedKey.agent)
|
|
160
|
+
return undefined;
|
|
161
|
+
return selectQualitativeVariant(doc, parsedKey);
|
|
162
|
+
}).catch(swallowMissingStore);
|
|
163
|
+
},
|
|
164
|
+
write: async (key, value) => {
|
|
165
|
+
const parsedKey = parseQualitativeKey(key);
|
|
166
|
+
const parsedValue = qualitativeEntryValueSchema.parse(stripRawPaths(value));
|
|
167
|
+
assertQualitativeOwnershipInvariant(parsedKey, parsedValue);
|
|
168
|
+
await withStoreRoot(true, async (storeRoot) => {
|
|
169
|
+
const cachePath = shardPath(parsedKey.pathHash);
|
|
170
|
+
// Read-modify-write must start from disk, not the in-process memo: a
|
|
171
|
+
// concurrent process may have merged a variant since this process
|
|
172
|
+
// last read. The residual race is one whole-document rename losing
|
|
173
|
+
// to another (one variant lost, self-healed on the loser's next
|
|
174
|
+
// parse) — the same accepted loss as concurrent v1 writers.
|
|
175
|
+
const current = await readDocument(join(storeRoot, cachePath));
|
|
176
|
+
const next = upsertQualitativeVariant(dropSupersededOwnership(baseDocument(current, parsedKey.agent, parsedKey.pathHash), parsedKey.fileIdentity), { key: parsedKey, storedAt: new Date().toISOString(), value: parsedValue });
|
|
177
|
+
await writeDocument(join(storeRoot, cachePath), next);
|
|
178
|
+
invalidate(cachePath);
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
},
|
|
182
|
+
financial: {
|
|
183
|
+
read: async (key) => {
|
|
184
|
+
const parsedKey = financialKeySchema.parse(key);
|
|
185
|
+
return withStoreRoot(false, async (storeRoot) => {
|
|
186
|
+
const doc = await document(shardPath(parsedKey.pathHash), storeRoot);
|
|
187
|
+
if (!doc || doc.agent !== parsedKey.agent || !doc.financial)
|
|
188
|
+
return undefined;
|
|
189
|
+
const stored = doc.financial.key;
|
|
190
|
+
if (stored.fileIdentity !== parsedKey.fileIdentity ||
|
|
191
|
+
stored.financialParserVersion !== parsedKey.financialParserVersion) {
|
|
192
|
+
return undefined;
|
|
193
|
+
}
|
|
194
|
+
// Deep-clone: callers may re-attach probed context to their copy;
|
|
195
|
+
// the in-process memo must never observe those mutations.
|
|
196
|
+
return structuredClone(doc.financial.value);
|
|
197
|
+
}).catch(swallowMissingStore);
|
|
198
|
+
},
|
|
199
|
+
write: async (key, value) => {
|
|
200
|
+
const parsedKey = financialKeySchema.parse(key);
|
|
201
|
+
const parsedValue = financialValueSchema.parse(stripRawPaths(value));
|
|
202
|
+
await withStoreRoot(true, async (storeRoot) => {
|
|
203
|
+
const cachePath = shardPath(parsedKey.pathHash);
|
|
204
|
+
const current = await readDocument(join(storeRoot, cachePath));
|
|
205
|
+
const next = {
|
|
206
|
+
...dropSupersededOwnership(baseDocument(current, parsedKey.agent, parsedKey.pathHash), parsedKey.fileIdentity),
|
|
207
|
+
financial: { key: parsedKey, storedAt: new Date().toISOString(), value: parsedValue }
|
|
208
|
+
};
|
|
209
|
+
await writeDocument(join(storeRoot, cachePath), documentSchema.parse(next));
|
|
210
|
+
invalidate(cachePath);
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
},
|
|
214
|
+
readOwnership: async (agent, pathHash) => withStoreRoot(false, async (storeRoot) => {
|
|
215
|
+
const doc = await document(shardPath(assertPathHash(pathHash)), storeRoot);
|
|
216
|
+
return doc && doc.agent === agent ? doc.ownership : undefined;
|
|
217
|
+
}).catch(swallowMissingStore),
|
|
218
|
+
writeOwnership: async (agent, pathHash, ownership) => {
|
|
219
|
+
const parsedOwnership = ownershipSchema.parse(ownership);
|
|
220
|
+
await withStoreRoot(true, async (storeRoot) => {
|
|
221
|
+
const cachePath = shardPath(assertPathHash(pathHash));
|
|
222
|
+
const current = await readDocument(join(storeRoot, cachePath));
|
|
223
|
+
const next = {
|
|
224
|
+
...baseDocument(current, agent, pathHash),
|
|
225
|
+
ownership: parsedOwnership
|
|
226
|
+
};
|
|
227
|
+
await writeDocument(join(storeRoot, cachePath), documentSchema.parse(next));
|
|
228
|
+
invalidate(cachePath);
|
|
229
|
+
});
|
|
230
|
+
},
|
|
231
|
+
readStreamCheckpoint: async (agent, pathHash) => withStoreRoot(false, async (storeRoot) => {
|
|
232
|
+
const target = join(storeRoot, checkpointPath(assertPathHash(pathHash)));
|
|
233
|
+
const outcome = await readCheckpointDocument(target);
|
|
234
|
+
if (outcome.status === "invalid") {
|
|
235
|
+
// Superseded (pre-sanitization) or corrupt checkpoints may hold
|
|
236
|
+
// un-sanitized text: purge on sight. The stream restarts from byte
|
|
237
|
+
// zero and rewrites its state under the current contract.
|
|
238
|
+
await unlink(target).catch(() => undefined);
|
|
239
|
+
return undefined;
|
|
240
|
+
}
|
|
241
|
+
if (outcome.status !== "valid")
|
|
242
|
+
return undefined;
|
|
243
|
+
const doc = outcome.doc;
|
|
244
|
+
if (doc.agent !== agent || doc.pathHash !== pathHash)
|
|
245
|
+
return undefined;
|
|
246
|
+
return doc.checkpoint;
|
|
247
|
+
}).catch(swallowMissingStore),
|
|
248
|
+
writeStreamCheckpoint: async (agent, pathHash, checkpoint) => {
|
|
249
|
+
const document = checkpointDocumentSchema.parse({
|
|
250
|
+
kind: "aibill.project_index_checkpoint",
|
|
251
|
+
schemaVersion: 2,
|
|
252
|
+
agent,
|
|
253
|
+
pathHash: assertPathHash(pathHash),
|
|
254
|
+
storedAt: new Date().toISOString(),
|
|
255
|
+
checkpoint
|
|
256
|
+
});
|
|
257
|
+
const contents = `${JSON.stringify(document)}\n`;
|
|
258
|
+
if (Buffer.byteLength(contents, "utf8") > projectIndexMaxDocumentBytes) {
|
|
259
|
+
throw new QualitativeIndexCacheError("oversized", "Project index checkpoint exceeds its private bound.");
|
|
260
|
+
}
|
|
261
|
+
await withStoreRoot(true, async (storeRoot) => {
|
|
262
|
+
const target = join(storeRoot, checkpointPath(pathHash));
|
|
263
|
+
// Mirror readDocument's future-version guard: a newer process's
|
|
264
|
+
// checkpoint is never clobbered by this writer (fail closed).
|
|
265
|
+
if ((await readCheckpointDocument(target)).status === "future") {
|
|
266
|
+
throw new QualitativeIndexCacheError("unsupported_version", "Project index checkpoint was written by a newer aibill version.");
|
|
267
|
+
}
|
|
268
|
+
await writeJsonFileAtomically(target, contents);
|
|
269
|
+
});
|
|
270
|
+
},
|
|
271
|
+
deleteStreamCheckpoint: async (agent, pathHash) => {
|
|
272
|
+
void agent;
|
|
273
|
+
await withStoreRoot(false, async (storeRoot) => {
|
|
274
|
+
await unlink(join(storeRoot, checkpointPath(assertPathHash(pathHash))))
|
|
275
|
+
.catch((error) => {
|
|
276
|
+
if (!isNodeError(error, "ENOENT"))
|
|
277
|
+
throw error;
|
|
278
|
+
});
|
|
279
|
+
}).catch(swallowMissingStore);
|
|
280
|
+
},
|
|
281
|
+
collectGarbage: async (gcOptions = {}) => withWriterLock(options, async (cacheDirectory) => {
|
|
282
|
+
// Orphan sweep with a freshness grace window: an unretained entry is
|
|
283
|
+
// removed only when its last write is older than the grace period, so
|
|
284
|
+
// a concurrent process's seconds-old work is never collected. Crash-
|
|
285
|
+
// orphaned temp files are swept on the same aging rule; unexpected
|
|
286
|
+
// litter is skipped, never fatal.
|
|
287
|
+
const storeRoot = join(cacheDirectory, projectIndexStoreDirectoryName);
|
|
288
|
+
const retain = gcOptions.retainPathHashes;
|
|
289
|
+
if (!retain)
|
|
290
|
+
return { removed: 0 };
|
|
291
|
+
const graceMs = gcOptions.graceMs ?? projectIndexGcGraceMs;
|
|
292
|
+
const cutoff = Date.now() - Math.max(0, graceMs);
|
|
293
|
+
let removed = 0;
|
|
294
|
+
const entriesRoot = join(storeRoot, "entries");
|
|
295
|
+
const shards = await readdir(entriesRoot).catch((error) => {
|
|
296
|
+
if (isNodeError(error, "ENOENT"))
|
|
297
|
+
return [];
|
|
298
|
+
throw error;
|
|
299
|
+
});
|
|
300
|
+
for (const shard of shards) {
|
|
301
|
+
const shardDirectory = join(entriesRoot, shard);
|
|
302
|
+
const files = await readdir(shardDirectory).catch(() => []);
|
|
303
|
+
for (const file of files) {
|
|
304
|
+
const target = join(shardDirectory, file);
|
|
305
|
+
const info = await lstat(target).catch(() => undefined);
|
|
306
|
+
if (!info || !info.isFile() || info.mtimeMs > cutoff)
|
|
307
|
+
continue;
|
|
308
|
+
const isEntry = file.endsWith(".json");
|
|
309
|
+
const isTemp = file.includes(".json.") && file.endsWith(".tmp");
|
|
310
|
+
if (isEntry) {
|
|
311
|
+
const pathHash = file.slice(0, -5);
|
|
312
|
+
if (retain.has(pathHash))
|
|
313
|
+
continue;
|
|
314
|
+
}
|
|
315
|
+
else if (!isTemp) {
|
|
316
|
+
continue;
|
|
317
|
+
}
|
|
318
|
+
const gone = await unlink(target).then(() => true).catch(() => false);
|
|
319
|
+
if (!gone)
|
|
320
|
+
continue;
|
|
321
|
+
if (isEntry) {
|
|
322
|
+
documents.delete(join("entries", shard, file));
|
|
323
|
+
removed += 1;
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
// Checkpoints age out on the same retain/grace rule: an orphaned
|
|
328
|
+
// checkpoint is a restart at worst, never lost complete evidence.
|
|
329
|
+
const checkpointsRoot = join(storeRoot, "checkpoints");
|
|
330
|
+
const checkpointFiles = await readdir(checkpointsRoot).catch(() => []);
|
|
331
|
+
for (const file of checkpointFiles) {
|
|
332
|
+
const target = join(checkpointsRoot, file);
|
|
333
|
+
const info = await lstat(target).catch(() => undefined);
|
|
334
|
+
if (!info || !info.isFile() || info.mtimeMs > cutoff)
|
|
335
|
+
continue;
|
|
336
|
+
const isCheckpoint = file.endsWith(".json");
|
|
337
|
+
const isTemp = file.includes(".json.") && file.endsWith(".tmp");
|
|
338
|
+
if (isCheckpoint) {
|
|
339
|
+
if (retain.has(file.slice(0, -5)))
|
|
340
|
+
continue;
|
|
341
|
+
}
|
|
342
|
+
else if (!isTemp) {
|
|
343
|
+
continue;
|
|
344
|
+
}
|
|
345
|
+
const gone = await unlink(target).then(() => true).catch(() => false);
|
|
346
|
+
if (gone && isCheckpoint)
|
|
347
|
+
removed += 1;
|
|
348
|
+
}
|
|
349
|
+
return { removed };
|
|
350
|
+
}, projectIndexStoreLockFileName)
|
|
351
|
+
};
|
|
352
|
+
}
|
|
353
|
+
/**
|
|
354
|
+
* Ownership binds to one exact file identity; any write that observes a newer
|
|
355
|
+
* identity drops the stale attribution rather than serving it (rotation-safe).
|
|
356
|
+
*/
|
|
357
|
+
function dropSupersededOwnership(doc, fileIdentity) {
|
|
358
|
+
if (!doc.ownership || doc.ownership.fileIdentity === fileIdentity)
|
|
359
|
+
return doc;
|
|
360
|
+
const { ownership: _superseded, ...rest } = doc;
|
|
361
|
+
return rest;
|
|
362
|
+
}
|
|
363
|
+
/** Mirror v1's assertOwnership write guard: calls must belong to the keyed agent. */
|
|
364
|
+
function assertQualitativeOwnershipInvariant(key, value) {
|
|
365
|
+
if (value.calls.some((call) => call.agent !== key.agent)) {
|
|
366
|
+
throw new QualitativeIndexCacheError("invalid_value", "Project index calls do not belong to the keyed parser.");
|
|
367
|
+
}
|
|
368
|
+
if (value.invocationFile && (!key.collectInvocationEvidence || key.agent !== "codex" ||
|
|
369
|
+
value.invocationFile.contextSignal.agent !== key.agent)) {
|
|
370
|
+
throw new QualitativeIndexCacheError("invalid_value", "Project index invocation evidence does not belong to the keyed parser window.");
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
function baseDocument(current, agent, pathHash) {
|
|
374
|
+
if (current && current.agent === agent && current.pathHash === pathHash)
|
|
375
|
+
return current;
|
|
376
|
+
return {
|
|
377
|
+
kind: "aibill.project_index_file",
|
|
378
|
+
schemaVersion: 2,
|
|
379
|
+
agent,
|
|
380
|
+
pathHash,
|
|
381
|
+
qualitative: []
|
|
382
|
+
};
|
|
383
|
+
}
|
|
384
|
+
function selectQualitativeVariant(doc, requestedKey) {
|
|
385
|
+
const fingerprint = qualitativeKeyFingerprint(requestedKey);
|
|
386
|
+
const exact = doc.qualitative.find((variant) => qualitativeKeyFingerprint(variant.key) === fingerprint);
|
|
387
|
+
if (exact)
|
|
388
|
+
return exact.value;
|
|
389
|
+
const compatible = doc.qualitative
|
|
390
|
+
.filter((variant) => sameQualitativeFileKey(variant.key, requestedKey))
|
|
391
|
+
.filter((variant) => cachedWindowCoversRequest(variant.key.sinceIso, requestedKey.sinceIso))
|
|
392
|
+
.filter((variant) => invocationWindowCanBeNarrowedExactly(variant.key, variant.value, requestedKey))
|
|
393
|
+
.sort((left, right) => (sinceSortValue(right.key.sinceIso) - sinceSortValue(left.key.sinceIso) ||
|
|
394
|
+
right.storedAt.localeCompare(left.storedAt)))[0];
|
|
395
|
+
return compatible?.value;
|
|
396
|
+
}
|
|
397
|
+
function upsertQualitativeVariant(current, variant) {
|
|
398
|
+
const fingerprint = qualitativeKeyFingerprint(variant.key);
|
|
399
|
+
const retained = current.qualitative.filter((candidate) => {
|
|
400
|
+
if (qualitativeKeyFingerprint(candidate.key) === fingerprint)
|
|
401
|
+
return false;
|
|
402
|
+
// A new file identity supersedes every variant of the old identity: the
|
|
403
|
+
// transcript changed, so stale windows must never satisfy a future read.
|
|
404
|
+
// Variants of the other invocation-evidence family are preserved
|
|
405
|
+
// (superset-merge: one caller's write never clobbers the other's).
|
|
406
|
+
return candidate.key.fileIdentity === variant.key.fileIdentity &&
|
|
407
|
+
candidate.key.parserVersion === variant.key.parserVersion;
|
|
408
|
+
});
|
|
409
|
+
const bounded = [...retained, variant]
|
|
410
|
+
.sort((left, right) => {
|
|
411
|
+
// Deterministic retention: the null (widest) window outranks everything,
|
|
412
|
+
// then newer windows outrank older ones.
|
|
413
|
+
const leftNull = left.key.sinceIso === null ? 0 : 1;
|
|
414
|
+
const rightNull = right.key.sinceIso === null ? 0 : 1;
|
|
415
|
+
return leftNull - rightNull ||
|
|
416
|
+
sinceSortValue(right.key.sinceIso) - sinceSortValue(left.key.sinceIso);
|
|
417
|
+
})
|
|
418
|
+
.slice(0, projectIndexMaxWindowedVariants);
|
|
419
|
+
const preserved = bounded.some((candidate) => qualitativeKeyFingerprint(candidate.key) === fingerprint)
|
|
420
|
+
? bounded
|
|
421
|
+
: [variant, ...bounded.slice(0, projectIndexMaxWindowedVariants - 1)];
|
|
422
|
+
return documentSchema.parse({ ...current, qualitative: preserved });
|
|
423
|
+
}
|
|
424
|
+
async function readDocument(filePath) {
|
|
425
|
+
// O_NOFOLLOW is 0 on win32; the explicit lstat keeps symlink fail-closed
|
|
426
|
+
// behavior identical on every platform.
|
|
427
|
+
const linkInfo = await lstat(filePath).catch((error) => {
|
|
428
|
+
if (isNodeError(error, "ENOENT"))
|
|
429
|
+
return undefined;
|
|
430
|
+
throw error;
|
|
431
|
+
});
|
|
432
|
+
if (!linkInfo)
|
|
433
|
+
return undefined;
|
|
434
|
+
if (linkInfo.isSymbolicLink()) {
|
|
435
|
+
throw new QualitativeIndexCacheError("unsafe_file", "Project index entry is a symbolic link.");
|
|
436
|
+
}
|
|
437
|
+
let handle;
|
|
438
|
+
try {
|
|
439
|
+
handle = await open(filePath, constants.O_RDONLY | noFollowFlag());
|
|
440
|
+
const opened = await handle.stat();
|
|
441
|
+
if (!opened.isFile() || !hasPrivatePermissions(opened.mode)) {
|
|
442
|
+
throw new QualitativeIndexCacheError("unsafe_file", "Project index entry is not a private regular file.");
|
|
443
|
+
}
|
|
444
|
+
if (opened.size > projectIndexMaxDocumentBytes) {
|
|
445
|
+
throw new QualitativeIndexCacheError("oversized", "Project index entry exceeds its private bound.");
|
|
446
|
+
}
|
|
447
|
+
const bounded = Buffer.allocUnsafe(Number(opened.size));
|
|
448
|
+
let bytesRead = 0;
|
|
449
|
+
while (bytesRead < bounded.length) {
|
|
450
|
+
const result = await handle.read(bounded, bytesRead, bounded.length - bytesRead, bytesRead);
|
|
451
|
+
if (result.bytesRead === 0)
|
|
452
|
+
break;
|
|
453
|
+
bytesRead += result.bytesRead;
|
|
454
|
+
}
|
|
455
|
+
let value;
|
|
456
|
+
try {
|
|
457
|
+
value = JSON.parse(bounded.subarray(0, bytesRead).toString("utf8"));
|
|
458
|
+
}
|
|
459
|
+
catch {
|
|
460
|
+
// Crash artifacts (zero-length or torn temp promoted by an interrupted
|
|
461
|
+
// rename implementation) read as a miss, never as poison.
|
|
462
|
+
return undefined;
|
|
463
|
+
}
|
|
464
|
+
if (typeof value === "object" && value !== null && !Array.isArray(value)) {
|
|
465
|
+
const version = value.schemaVersion;
|
|
466
|
+
if (typeof version === "number" && version > 2) {
|
|
467
|
+
// A newer store wrote this document. Fail closed instead of silently
|
|
468
|
+
// clobbering a future format with a v2 rewrite.
|
|
469
|
+
throw new QualitativeIndexCacheError("unsupported_version", "Project index entry was written by a newer aibill version.");
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
const parsed = documentSchema.safeParse(value);
|
|
473
|
+
return parsed.success ? parsed.data : undefined;
|
|
474
|
+
}
|
|
475
|
+
catch (error) {
|
|
476
|
+
if (isNodeError(error, "ENOENT"))
|
|
477
|
+
return undefined;
|
|
478
|
+
if (isNodeError(error, "ELOOP")) {
|
|
479
|
+
throw new QualitativeIndexCacheError("unsafe_file", "Project index entry is a symbolic link.");
|
|
480
|
+
}
|
|
481
|
+
throw error;
|
|
482
|
+
}
|
|
483
|
+
finally {
|
|
484
|
+
await handle?.close().catch(() => undefined);
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
async function writeDocument(filePath, doc) {
|
|
488
|
+
// Evict-to-fit: an oversized document sheds its oldest bounded windows
|
|
489
|
+
// rather than becoming permanently uncacheable (v1 fitIndex semantics).
|
|
490
|
+
let fitted = doc;
|
|
491
|
+
let contents = `${JSON.stringify(fitted)}\n`;
|
|
492
|
+
while (Buffer.byteLength(contents, "utf8") > projectIndexMaxDocumentBytes &&
|
|
493
|
+
fitted.qualitative.length > 0) {
|
|
494
|
+
fitted = { ...fitted, qualitative: fitted.qualitative.slice(0, -1) };
|
|
495
|
+
contents = `${JSON.stringify(fitted)}\n`;
|
|
496
|
+
}
|
|
497
|
+
if (Buffer.byteLength(contents, "utf8") > projectIndexMaxDocumentBytes) {
|
|
498
|
+
throw new QualitativeIndexCacheError("oversized", "Project index entry exceeds its private bound.");
|
|
499
|
+
}
|
|
500
|
+
await writeJsonFileAtomically(filePath, contents);
|
|
501
|
+
}
|
|
502
|
+
/** fsync-before-rename atomic JSON write shared by entries and checkpoints. */
|
|
503
|
+
async function writeJsonFileAtomically(filePath, contents) {
|
|
504
|
+
const directory = join(filePath, "..");
|
|
505
|
+
await mkdir(directory, { recursive: true, mode: 0o700 });
|
|
506
|
+
const temporaryPath = `${filePath}.${process.pid}.${randomUUID()}.tmp`;
|
|
507
|
+
let handle;
|
|
508
|
+
try {
|
|
509
|
+
handle = await open(temporaryPath, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | noFollowFlag(), 0o600);
|
|
510
|
+
await handle.writeFile(contents, "utf8");
|
|
511
|
+
await handle.sync();
|
|
512
|
+
await handle.close();
|
|
513
|
+
handle = undefined;
|
|
514
|
+
await rename(temporaryPath, filePath);
|
|
515
|
+
await syncDirectory(directory);
|
|
516
|
+
}
|
|
517
|
+
catch (error) {
|
|
518
|
+
await handle?.close().catch(() => undefined);
|
|
519
|
+
await unlink(temporaryPath).catch(() => undefined);
|
|
520
|
+
throw error;
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
async function readCheckpointDocument(filePath) {
|
|
524
|
+
const linkInfo = await lstat(filePath).catch((error) => {
|
|
525
|
+
if (isNodeError(error, "ENOENT"))
|
|
526
|
+
return undefined;
|
|
527
|
+
throw error;
|
|
528
|
+
});
|
|
529
|
+
if (!linkInfo)
|
|
530
|
+
return { status: "missing" };
|
|
531
|
+
if (linkInfo.isSymbolicLink()) {
|
|
532
|
+
throw new QualitativeIndexCacheError("unsafe_file", "Project index checkpoint is a symbolic link.");
|
|
533
|
+
}
|
|
534
|
+
let handle;
|
|
535
|
+
try {
|
|
536
|
+
handle = await open(filePath, constants.O_RDONLY | noFollowFlag());
|
|
537
|
+
const opened = await handle.stat();
|
|
538
|
+
if (!opened.isFile() || !hasPrivatePermissions(opened.mode)) {
|
|
539
|
+
throw new QualitativeIndexCacheError("unsafe_file", "Project index checkpoint is not a private regular file.");
|
|
540
|
+
}
|
|
541
|
+
if (opened.size > projectIndexMaxDocumentBytes) {
|
|
542
|
+
throw new QualitativeIndexCacheError("oversized", "Project index checkpoint exceeds its private bound.");
|
|
543
|
+
}
|
|
544
|
+
const bounded = Buffer.allocUnsafe(Number(opened.size));
|
|
545
|
+
let bytesRead = 0;
|
|
546
|
+
while (bytesRead < bounded.length) {
|
|
547
|
+
const result = await handle.read(bounded, bytesRead, bounded.length - bytesRead, bytesRead);
|
|
548
|
+
if (result.bytesRead === 0)
|
|
549
|
+
break;
|
|
550
|
+
bytesRead += result.bytesRead;
|
|
551
|
+
}
|
|
552
|
+
let value;
|
|
553
|
+
try {
|
|
554
|
+
value = JSON.parse(bounded.subarray(0, bytesRead).toString("utf8"));
|
|
555
|
+
}
|
|
556
|
+
catch {
|
|
557
|
+
return { status: "invalid" };
|
|
558
|
+
}
|
|
559
|
+
if (typeof value === "object" && value !== null && !Array.isArray(value)) {
|
|
560
|
+
const record = value;
|
|
561
|
+
if (record.kind === "aibill.project_index_checkpoint" &&
|
|
562
|
+
typeof record.schemaVersion === "number" && record.schemaVersion > 2) {
|
|
563
|
+
// A newer process owns this checkpoint: fail closed as a miss, but
|
|
564
|
+
// never purge or clobber it.
|
|
565
|
+
return { status: "future" };
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
const parsed = checkpointDocumentSchema.safeParse(value);
|
|
569
|
+
return parsed.success ? { status: "valid", doc: parsed.data } : { status: "invalid" };
|
|
570
|
+
}
|
|
571
|
+
catch (error) {
|
|
572
|
+
if (isNodeError(error, "ENOENT"))
|
|
573
|
+
return { status: "missing" };
|
|
574
|
+
if (isNodeError(error, "ELOOP")) {
|
|
575
|
+
throw new QualitativeIndexCacheError("unsafe_file", "Project index checkpoint is a symbolic link.");
|
|
576
|
+
}
|
|
577
|
+
throw error;
|
|
578
|
+
}
|
|
579
|
+
finally {
|
|
580
|
+
await handle?.close().catch(() => undefined);
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
function checkpointPath(pathHash) {
|
|
584
|
+
return join("checkpoints", `${pathHash}.json`);
|
|
585
|
+
}
|
|
586
|
+
function shardPath(pathHash) {
|
|
587
|
+
return join("entries", pathHash.slice(0, 2), `${pathHash}.json`);
|
|
588
|
+
}
|
|
589
|
+
function parseQualitativeKey(key) {
|
|
590
|
+
return qualitativeEntryKeySchema.parse(key);
|
|
591
|
+
}
|
|
592
|
+
function assertPathHash(pathHash) {
|
|
593
|
+
if (!/^[a-f0-9]{64}$/.test(pathHash)) {
|
|
594
|
+
throw new QualitativeIndexCacheError("invalid_key", "Project index path hash is not a sha256 hex digest.");
|
|
595
|
+
}
|
|
596
|
+
return pathHash;
|
|
597
|
+
}
|
|
598
|
+
function swallowMissingStore(error) {
|
|
599
|
+
if (isNodeError(error, "ENOENT"))
|
|
600
|
+
return undefined;
|
|
601
|
+
throw error;
|
|
602
|
+
}
|
|
603
|
+
function sinceSortValue(value) {
|
|
604
|
+
return value === null ? Number.NEGATIVE_INFINITY : Date.parse(value);
|
|
605
|
+
}
|
|
606
|
+
//# sourceMappingURL=projectIndexStore.js.map
|