hoi4-agent-tools 3.5.1 → 3.7.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/CHANGELOG.md +18 -0
- package/README.md +137 -116
- package/dist/hoi4_agent_tools/core/engine.js +7 -7
- package/dist/hoi4_agent_tools/core/file-glob.js +164 -0
- package/dist/hoi4_agent_tools/core/index-segments.js +4 -0
- package/dist/hoi4_agent_tools/core/job-operation-registration.js +31 -0
- package/dist/hoi4_agent_tools/core/job-worker-host.js +27 -11
- package/dist/hoi4_agent_tools/core/job-worker.js +8 -16
- package/dist/hoi4_agent_tools/core/persistent-analysis-cache.js +37 -6
- package/dist/hoi4_agent_tools/core/scanner.js +133 -47
- package/dist/hoi4_agent_tools/core/source/cache.js +4 -0
- package/dist/hoi4_agent_tools/gui/layout.js +237 -11
- package/dist/hoi4_agent_tools/gui/renderer.js +36 -3
- package/dist/hoi4_agent_tools/gui/scenario.js +18 -0
- package/dist/hoi4_agent_tools/gui/studio.js +73 -21
- package/dist/hoi4_agent_tools/map/service.js +2 -2
- package/dist/hoi4_agent_tools/mcp/server/instructions.js +1 -1
- package/dist/hoi4_agent_tools/mcp/tools/reference.js +10 -1
- package/dist/hoi4_agent_tools/reference/lines.js +12 -0
- package/dist/hoi4_agent_tools/reference/operations.js +45 -2
- package/dist/hoi4_agent_tools/reference/script-validation.js +277 -0
- package/dist/hoi4_agent_tools/reference/service.js +139 -67
- package/dist/hoi4_agent_tools/reference/source-lookup.js +91 -15
- package/dist/hoi4_agent_tools/reference/source-navigation.js +130 -0
- package/dist/hoi4_agent_tools/schemas/reference.js +55 -0
- package/dist/hoi4_agent_tools/schemas/script-validation.js +69 -0
- package/dist/hoi4_agent_tools/version.js +1 -1
- package/docs/README.md +34 -18
- package/docs/examples.md +75 -0
- package/docs/gui.md +23 -0
- package/docs/reference.md +43 -2
- package/docs/research/reference-benchmark.md +41 -0
- package/docs/setup.md +50 -26
- package/docs/tools.md +65 -0
- package/npm-shrinkwrap.json +7088 -0
- package/package.json +10 -7
- package/schemas/configuration.schema.json +1 -1
- package/schemas/continuous-focus-palette.schema.json +1 -1
- package/schemas/custom-weighted-pool.schema.json +1 -1
- package/schemas/decision-inspect-input.schema.json +1 -1
- package/schemas/event-feature-manifest.schema.json +1 -1
- package/schemas/focus-plan.schema.json +1 -1
- package/schemas/focus-planning-sidecar.schema.json +1 -1
- package/schemas/gui-animation-source.schema.json +1 -1
- package/schemas/gui-helper.schema.json +1 -1
- package/schemas/gui-scenario.schema.json +139 -89
- package/schemas/helper-expansion-request.schema.json +1 -1
- package/schemas/helper-expansion-summary.schema.json +1 -1
- package/schemas/impact-inspect-input.schema.json +1 -1
- package/schemas/map-operation.schema.json +1 -1
- package/schemas/mechanic-test-input.schema.json +1 -1
- package/schemas/operation-result.schema.json +1 -1
- package/schemas/package-check-input.schema.json +1 -1
- package/schemas/package-manifest.schema.json +1 -1
- package/schemas/probability-analysis-result.schema.json +1 -1
- package/schemas/probability-compare-input.schema.json +1 -1
- package/schemas/probability-evaluate-input.schema.json +1 -1
- package/schemas/probability-inspect-input.schema.json +1 -1
- package/schemas/probability-render-input.schema.json +1 -1
- package/schemas/probability-scenario-set.schema.json +1 -1
- package/schemas/probability-sequence-input.schema.json +1 -1
- package/schemas/probability-simulate-input.schema.json +1 -1
- package/schemas/probability-sweep-input.schema.json +1 -1
- package/schemas/reference-context-input.schema.json +1 -1
- package/schemas/reference-read-input.schema.json +9 -1
- package/schemas/reference-search-input.schema.json +1 -1
- package/schemas/scenario-suite.schema.json +1 -1
- package/schemas/scenario-test-input.schema.json +1 -1
- package/schemas/script-validate-input.schema.json +92 -0
- package/schemas/shared-scenario.schema.json +1 -1
- package/schemas/source-lookup-input.schema.json +68 -2
- package/server.json +3 -3
|
@@ -1,16 +1,10 @@
|
|
|
1
1
|
import { z } from 'zod/v4';
|
|
2
2
|
import { serverConfigurationSchema } from './configuration.js';
|
|
3
3
|
import { CoreEngine } from './engine.js';
|
|
4
|
-
import { JobExecutor
|
|
4
|
+
import { JobExecutor } from './job-executor.js';
|
|
5
5
|
import { JobService } from './job-service.js';
|
|
6
6
|
import { WorkspaceResolver } from './workspace.js';
|
|
7
|
-
import {
|
|
8
|
-
import { registerFocusJobs } from '../focus/job-operations.js';
|
|
9
|
-
import { registerGuiJobs } from '../gui/job-operations.js';
|
|
10
|
-
import { registerMapJobs } from '../map/job-operations.js';
|
|
11
|
-
import { registerProbabilityJobs } from '../probability/job-operations.js';
|
|
12
|
-
import { registerTechnologyJobs } from '../technology/job-operations.js';
|
|
13
|
-
import { registerAnalysisJobs } from './analysis-job-operations.js';
|
|
7
|
+
import { registerWorkerOperations } from './job-operation-registration.js';
|
|
14
8
|
// Fixed internal process entry point. It accepts only a trusted host's structured IPC
|
|
15
9
|
// message, never a script path, module name, callback, command, or transport request.
|
|
16
10
|
const inputSchema = z
|
|
@@ -38,19 +32,17 @@ process.once('message', (value) => {
|
|
|
38
32
|
clearTimeout(startup);
|
|
39
33
|
void (async () => {
|
|
40
34
|
const input = inputSchema.parse(value);
|
|
35
|
+
// The send callback confirms enqueueing, not receipt. Acknowledge before the host
|
|
36
|
+
// disconnects; losing that channel after receipt never cancels durable execution.
|
|
37
|
+
if (process.connected)
|
|
38
|
+
process.send?.({ type: 'accepted' }, () => undefined);
|
|
41
39
|
const resolver = await WorkspaceResolver.create(input.configuration);
|
|
42
40
|
const engine = new CoreEngine(resolver);
|
|
43
41
|
// Recovery is targeted by the job executor. Do not sweep or interfere with
|
|
44
42
|
// unrelated live rewrite journals when a worker starts.
|
|
45
43
|
const jobs = await JobService.create(resolver);
|
|
46
|
-
const
|
|
47
|
-
|
|
48
|
-
registerAnalysisJobs(operations, engine);
|
|
49
|
-
registerTechnologyJobs(operations, engine);
|
|
50
|
-
registerProbabilityJobs(operations, engine);
|
|
51
|
-
registerMapJobs(operations, engine);
|
|
52
|
-
registerGuiJobs(operations, engine);
|
|
53
|
-
registerFocusJobs(operations, engine);
|
|
44
|
+
const record = await jobs.get(input.workspaceId, input.jobId, input.principal);
|
|
45
|
+
const operations = await registerWorkerOperations(engine, record.request.toolName);
|
|
54
46
|
await new JobExecutor(engine, jobs, operations).run(input.workspaceId, input.jobId, input.principal);
|
|
55
47
|
})()
|
|
56
48
|
.catch(() => {
|
|
@@ -54,6 +54,8 @@ export class PersistentAnalysisCache {
|
|
|
54
54
|
maxBytes;
|
|
55
55
|
maxEntries;
|
|
56
56
|
maxSingleBytes;
|
|
57
|
+
batchMaxWrites;
|
|
58
|
+
batchMaxPayloadBytes;
|
|
57
59
|
#lock;
|
|
58
60
|
#hits = 0;
|
|
59
61
|
#misses = 0;
|
|
@@ -63,23 +65,30 @@ export class PersistentAnalysisCache {
|
|
|
63
65
|
#evictions = 0;
|
|
64
66
|
#invalidEntries = 0;
|
|
65
67
|
#oversized = 0;
|
|
68
|
+
#limitedBatches = 0;
|
|
66
69
|
#invalidAddresses = new Set();
|
|
67
|
-
constructor(state, root, maxBytes, maxEntries, maxSingleBytes) {
|
|
70
|
+
constructor(state, root, maxBytes, maxEntries, maxSingleBytes, batchMaxWrites, batchMaxPayloadBytes) {
|
|
68
71
|
this.state = state;
|
|
69
72
|
this.root = root;
|
|
70
73
|
this.maxBytes = maxBytes;
|
|
71
74
|
this.maxEntries = maxEntries;
|
|
72
75
|
this.maxSingleBytes = maxSingleBytes;
|
|
76
|
+
this.batchMaxWrites = batchMaxWrites;
|
|
77
|
+
this.batchMaxPayloadBytes = batchMaxPayloadBytes;
|
|
73
78
|
this.#lock = new SharedRequestCapacity(root, 1);
|
|
74
79
|
}
|
|
75
80
|
static async create(state, options = {}) {
|
|
76
81
|
const maxBytes = options.maxBytes ?? 268_435_456;
|
|
77
82
|
const maxEntries = options.maxEntries ?? 50_000;
|
|
78
83
|
const maxSingleBytes = options.maxSingleBytes ?? 16_777_216;
|
|
84
|
+
const batchMaxWrites = options.batchMaxWrites ?? 16;
|
|
85
|
+
const batchMaxPayloadBytes = options.batchMaxPayloadBytes ?? 1_048_576;
|
|
79
86
|
for (const [name, value] of [
|
|
80
87
|
['byte budget', maxBytes],
|
|
81
88
|
['entry budget', maxEntries],
|
|
82
89
|
['single-entry budget', maxSingleBytes],
|
|
90
|
+
['batch write budget', batchMaxWrites],
|
|
91
|
+
['batch payload budget', batchMaxPayloadBytes],
|
|
83
92
|
]) {
|
|
84
93
|
if (!Number.isSafeInteger(value) || value < 0)
|
|
85
94
|
throw new RangeError(`Persistent analysis cache ${name} must be a non-negative integer`);
|
|
@@ -88,7 +97,7 @@ export class PersistentAnalysisCache {
|
|
|
88
97
|
const root = await containedGeneratedPath(state.root, 'analysis-cache');
|
|
89
98
|
await mkdir(root, { recursive: true, mode: 0o700 });
|
|
90
99
|
await assertUnlinked(root);
|
|
91
|
-
return new PersistentAnalysisCache(state, root, maxBytes, maxEntries, maxSingleBytes);
|
|
100
|
+
return new PersistentAnalysisCache(state, root, maxBytes, maxEntries, maxSingleBytes, batchMaxWrites, batchMaxPayloadBytes);
|
|
92
101
|
}
|
|
93
102
|
async hydrate(scope, files, documents, segments, signal) {
|
|
94
103
|
signal?.throwIfAborted();
|
|
@@ -101,7 +110,7 @@ export class PersistentAnalysisCache {
|
|
|
101
110
|
signal?.throwIfAborted();
|
|
102
111
|
const key = sourceDocumentCacheKey(file.bytes, file.displayPath);
|
|
103
112
|
const documentAddress = sourceAddress(key);
|
|
104
|
-
if (sourceEntries.has(documentAddress)) {
|
|
113
|
+
if (!documents.has(key) && sourceEntries.has(documentAddress)) {
|
|
105
114
|
const payload = await this.read(scopeHash, 'source-document', documentAddress, signal);
|
|
106
115
|
if (payload !== undefined) {
|
|
107
116
|
try {
|
|
@@ -118,7 +127,7 @@ export class PersistentAnalysisCache {
|
|
|
118
127
|
}
|
|
119
128
|
for (const provinceDefinition of [false, true]) {
|
|
120
129
|
const address = indexSegmentAddress(file, provinceDefinition);
|
|
121
|
-
if (!indexEntries.has(address))
|
|
130
|
+
if (segments.has(address) || !indexEntries.has(address))
|
|
122
131
|
continue;
|
|
123
132
|
const payload = await this.read(scopeHash, 'index-segment', address, signal);
|
|
124
133
|
if (payload === undefined)
|
|
@@ -140,15 +149,34 @@ export class PersistentAnalysisCache {
|
|
|
140
149
|
this.inventory(scopeHash, 'source-document', signal),
|
|
141
150
|
this.inventory(scopeHash, 'index-segment', signal),
|
|
142
151
|
]);
|
|
152
|
+
const batch = { writes: 0, payloadBytes: 0, limited: false };
|
|
153
|
+
const retain = async (kind, address, payload) => {
|
|
154
|
+
if (batch.writes >= this.batchMaxWrites ||
|
|
155
|
+
payload.length > this.batchMaxPayloadBytes - batch.payloadBytes) {
|
|
156
|
+
batch.limited = true;
|
|
157
|
+
return false;
|
|
158
|
+
}
|
|
159
|
+
const written = await this.write(scopeHash, kind, address, payload, signal);
|
|
160
|
+
if (written) {
|
|
161
|
+
batch.writes++;
|
|
162
|
+
batch.payloadBytes += payload.length;
|
|
163
|
+
}
|
|
164
|
+
return written;
|
|
165
|
+
};
|
|
143
166
|
for (const file of files) {
|
|
144
167
|
signal.throwIfAborted();
|
|
168
|
+
if (batch.writes >= this.batchMaxWrites ||
|
|
169
|
+
batch.payloadBytes >= this.batchMaxPayloadBytes) {
|
|
170
|
+
batch.limited = true;
|
|
171
|
+
break;
|
|
172
|
+
}
|
|
145
173
|
const key = sourceDocumentCacheKey(file.bytes, file.displayPath);
|
|
146
174
|
const documentAddress = sourceAddress(key);
|
|
147
175
|
const document = documents.exportEncoded(key);
|
|
148
176
|
if (document !== undefined &&
|
|
149
177
|
(!sourceEntries.has(documentAddress) ||
|
|
150
178
|
this.#invalidAddresses.has(`${scopeHash}\0source-document\0${documentAddress}`))) {
|
|
151
|
-
if (await
|
|
179
|
+
if (await retain('source-document', documentAddress, document)) {
|
|
152
180
|
sourceEntries.add(documentAddress);
|
|
153
181
|
}
|
|
154
182
|
}
|
|
@@ -159,11 +187,13 @@ export class PersistentAnalysisCache {
|
|
|
159
187
|
(indexEntries.has(address) &&
|
|
160
188
|
!this.#invalidAddresses.has(`${scopeHash}\0index-segment\0${address}`)))
|
|
161
189
|
continue;
|
|
162
|
-
if (await
|
|
190
|
+
if (await retain('index-segment', address, Buffer.from(segment))) {
|
|
163
191
|
indexEntries.add(address);
|
|
164
192
|
}
|
|
165
193
|
}
|
|
166
194
|
}
|
|
195
|
+
if (batch.limited)
|
|
196
|
+
this.#limitedBatches++;
|
|
167
197
|
await this.prune(signal);
|
|
168
198
|
});
|
|
169
199
|
}
|
|
@@ -177,6 +207,7 @@ export class PersistentAnalysisCache {
|
|
|
177
207
|
evictions: this.#evictions,
|
|
178
208
|
invalidEntries: this.#invalidEntries,
|
|
179
209
|
oversized: this.#oversized,
|
|
210
|
+
limitedBatches: this.#limitedBatches,
|
|
180
211
|
};
|
|
181
212
|
}
|
|
182
213
|
async directory(scopeHash, kind) {
|
|
@@ -1,9 +1,53 @@
|
|
|
1
|
-
import { open } from 'node:fs/promises';
|
|
1
|
+
import { open, realpath, stat } from 'node:fs/promises';
|
|
2
2
|
import path from 'node:path';
|
|
3
|
-
import
|
|
3
|
+
import { iterateFiles } from './file-glob.js';
|
|
4
4
|
import { compareCodeUnits, sha256Bytes } from './canonical.js';
|
|
5
5
|
import { DEFAULT_SCAN_MAX_BYTES, DEFAULT_SCAN_MAX_FILE_BYTES, DEFAULT_SCAN_MAX_FILES, } from './configuration.js';
|
|
6
6
|
import { ServiceError } from './result.js';
|
|
7
|
+
import { isWithin } from './workspace.js';
|
|
8
|
+
const SCAN_IO_CONCURRENCY = 8;
|
|
9
|
+
const SCAN_READ_BATCH_BYTES = 16_777_216;
|
|
10
|
+
export async function verifySource(root, source) {
|
|
11
|
+
const resolved = await realpath(source.absolutePath);
|
|
12
|
+
if (!isWithin(root.path, resolved))
|
|
13
|
+
throw new ServiceError('SCAN_ROOT_ESCAPE', 'Source path resolves outside its authorized root', {
|
|
14
|
+
file: source.relativePath,
|
|
15
|
+
});
|
|
16
|
+
const current = await stat(resolved);
|
|
17
|
+
const expected = source.metadata;
|
|
18
|
+
if (current.dev !== expected.dev ||
|
|
19
|
+
current.ino !== expected.ino ||
|
|
20
|
+
current.size !== expected.size ||
|
|
21
|
+
current.mtimeMs !== expected.mtimeMs ||
|
|
22
|
+
current.ctimeMs !== expected.ctimeMs)
|
|
23
|
+
throw new ServiceError('SCAN_SOURCE_CHANGED', 'Source changed while it was being scanned; retry a focused query', { file: source.relativePath });
|
|
24
|
+
}
|
|
25
|
+
export async function prepareSource(root, relativePath, signal) {
|
|
26
|
+
signal?.throwIfAborted();
|
|
27
|
+
const absolutePath = path.join(root.path, relativePath);
|
|
28
|
+
const canonicalPath = await realpath(absolutePath);
|
|
29
|
+
if (!isWithin(root.path, canonicalPath))
|
|
30
|
+
throw new ServiceError('SCAN_ROOT_ESCAPE', 'Source path resolves outside its authorized root', {
|
|
31
|
+
file: relativePath,
|
|
32
|
+
});
|
|
33
|
+
const handle = await open(canonicalPath, 'r');
|
|
34
|
+
try {
|
|
35
|
+
const source = {
|
|
36
|
+
relativePath,
|
|
37
|
+
absolutePath,
|
|
38
|
+
canonicalPath,
|
|
39
|
+
handle,
|
|
40
|
+
metadata: await handle.stat(),
|
|
41
|
+
};
|
|
42
|
+
await verifySource(root, source);
|
|
43
|
+
signal?.throwIfAborted();
|
|
44
|
+
return source;
|
|
45
|
+
}
|
|
46
|
+
catch (error) {
|
|
47
|
+
await handle.close();
|
|
48
|
+
throw error;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
7
51
|
function normalizeRelative(value) {
|
|
8
52
|
return value.replaceAll('\\', '/').replace(/^\.\//u, '');
|
|
9
53
|
}
|
|
@@ -98,14 +142,12 @@ export class WorkspaceScanner {
|
|
|
98
142
|
options.signal?.throwIfAborted();
|
|
99
143
|
// Re-enumerate every root: installing content or changing load order can
|
|
100
144
|
// invalidate a vanilla inventory just as it can a mod inventory.
|
|
101
|
-
const matches =
|
|
145
|
+
const matches = iterateFiles(options.patterns, {
|
|
102
146
|
cwd: root.path,
|
|
103
|
-
onlyFiles: true,
|
|
104
|
-
unique: true,
|
|
105
|
-
dot: false,
|
|
106
|
-
followSymbolicLinks: false,
|
|
107
147
|
ignore: options.ignore ?? ['**/.hoi4-agent/**'],
|
|
148
|
+
...(options.signal === undefined ? {} : { signal: options.signal }),
|
|
108
149
|
});
|
|
150
|
+
const relativePaths = [];
|
|
109
151
|
for await (const match of matches) {
|
|
110
152
|
options.signal?.throwIfAborted();
|
|
111
153
|
enumeratedFiles += 1;
|
|
@@ -115,55 +157,99 @@ export class WorkspaceScanner {
|
|
|
115
157
|
limit: maxFiles,
|
|
116
158
|
});
|
|
117
159
|
}
|
|
118
|
-
const relativePath = normalizeRelative(
|
|
160
|
+
const relativePath = normalizeRelative(match);
|
|
119
161
|
if (hiddenByReplacePath(workspace, root, relativePath))
|
|
120
162
|
continue;
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
163
|
+
relativePaths.push(relativePath);
|
|
164
|
+
}
|
|
165
|
+
for (let offset = 0; offset < relativePaths.length; offset += SCAN_IO_CONCURRENCY) {
|
|
166
|
+
options.signal?.throwIfAborted();
|
|
167
|
+
const prepared = await Promise.allSettled(relativePaths
|
|
168
|
+
.slice(offset, offset + SCAN_IO_CONCURRENCY)
|
|
169
|
+
.map((relativePath) => prepareSource(root, relativePath, options.signal)));
|
|
170
|
+
const opened = prepared.flatMap((entry) => entry.status === 'fulfilled' ? [entry.value] : []);
|
|
124
171
|
try {
|
|
125
|
-
const
|
|
126
|
-
if (
|
|
127
|
-
|
|
128
|
-
const
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
172
|
+
const failed = prepared.find((entry) => entry.status === 'rejected');
|
|
173
|
+
if (failed?.status === 'rejected')
|
|
174
|
+
throw failed.reason;
|
|
175
|
+
const readable = opened.filter(({ metadata }) => metadata.isFile());
|
|
176
|
+
let reserved = 0;
|
|
177
|
+
for (const source of readable) {
|
|
178
|
+
if (source.metadata.size > maxBytes - totalBytes - reserved ||
|
|
179
|
+
source.metadata.size > this.serverMaxFileBytes)
|
|
180
|
+
throw new ServiceError('SCAN_BYTE_LIMIT', 'Scan exceeds the configured byte limit', {
|
|
181
|
+
file: source.relativePath,
|
|
182
|
+
fileBytes: source.metadata.size,
|
|
183
|
+
bytes: totalBytes + reserved,
|
|
184
|
+
limit: maxBytes,
|
|
185
|
+
perFileLimit: this.serverMaxFileBytes,
|
|
186
|
+
});
|
|
187
|
+
reserved += source.metadata.size;
|
|
188
|
+
}
|
|
189
|
+
let readOffset = 0;
|
|
190
|
+
while (readOffset < readable.length) {
|
|
191
|
+
const group = [];
|
|
192
|
+
let groupBytes = 0;
|
|
193
|
+
while (readOffset < readable.length) {
|
|
194
|
+
const source = readable[readOffset];
|
|
195
|
+
if (group.length > 0 && groupBytes + source.metadata.size > SCAN_READ_BATCH_BYTES)
|
|
196
|
+
break;
|
|
197
|
+
group.push(source);
|
|
198
|
+
groupBytes += source.metadata.size;
|
|
199
|
+
readOffset++;
|
|
200
|
+
}
|
|
201
|
+
const reads = await Promise.allSettled(group.map(async (source) => {
|
|
202
|
+
let bytes;
|
|
203
|
+
try {
|
|
204
|
+
bytes = await readBoundedFile(source.handle, source.metadata.size, options.signal);
|
|
205
|
+
}
|
|
206
|
+
catch (error) {
|
|
207
|
+
if (error instanceof ServiceError && error.code === 'SCAN_BYTE_LIMIT')
|
|
208
|
+
throw new ServiceError('SCAN_SOURCE_CHANGED', 'Source grew while it was being scanned; retry a focused query', { file: source.relativePath });
|
|
209
|
+
throw error;
|
|
210
|
+
}
|
|
211
|
+
if (bytes.length !== source.metadata.size)
|
|
212
|
+
throw new ServiceError('SCAN_SOURCE_CHANGED', 'Source changed while it was being scanned; retry a focused query', { file: source.relativePath });
|
|
213
|
+
await verifySource(root, source);
|
|
214
|
+
options.signal?.throwIfAborted();
|
|
215
|
+
return bytes;
|
|
216
|
+
}));
|
|
217
|
+
const readFailure = reads.find((entry) => entry.status === 'rejected');
|
|
218
|
+
if (readFailure?.status === 'rejected')
|
|
219
|
+
throw readFailure.reason;
|
|
220
|
+
for (const [index, read] of reads.entries()) {
|
|
221
|
+
if (read.status !== 'fulfilled')
|
|
222
|
+
continue;
|
|
223
|
+
const source = group[index];
|
|
224
|
+
const cacheKey = this.cacheKey(source.absolutePath);
|
|
225
|
+
const observed = read.value;
|
|
226
|
+
const sha256 = sha256Bytes(observed);
|
|
227
|
+
const cached = this.#sourceCache.get(cacheKey);
|
|
228
|
+
const bytes = cached?.sha256 === sha256 && cached.bytes.equals(observed)
|
|
229
|
+
? cached.bytes
|
|
230
|
+
: observed;
|
|
231
|
+
this.retainSource(cacheKey, { sha256, bytes });
|
|
232
|
+
totalBytes += bytes.length;
|
|
233
|
+
result.push({
|
|
234
|
+
absolutePath: source.absolutePath,
|
|
235
|
+
displayPath: `${rootLabel(root)}:${source.relativePath}`,
|
|
236
|
+
relativePath: source.relativePath,
|
|
237
|
+
rootKind: root.kind,
|
|
238
|
+
loadOrder: root.loadOrder,
|
|
239
|
+
size: bytes.length,
|
|
240
|
+
modifiedMs: source.metadata.mtimeMs,
|
|
241
|
+
sha256,
|
|
242
|
+
bytes,
|
|
243
|
+
});
|
|
244
|
+
}
|
|
137
245
|
}
|
|
138
|
-
const cached = this.#sourceCache.get(cacheKey);
|
|
139
|
-
// Metadata is not content identity (including on aliased/networked
|
|
140
|
-
// filesystems). Verify bytes before reusing parsed/indexed facts.
|
|
141
|
-
const observed = await readBoundedFile(handle, Math.min(remaining, this.serverMaxFileBytes), options.signal);
|
|
142
|
-
const sha256 = sha256Bytes(observed);
|
|
143
|
-
const bytes = cached?.sha256 === sha256 && cached.bytes.equals(observed) ? cached.bytes : observed;
|
|
144
|
-
this.retainSource(cacheKey, {
|
|
145
|
-
sha256,
|
|
146
|
-
bytes,
|
|
147
|
-
});
|
|
148
|
-
totalBytes += bytes.length;
|
|
149
|
-
const scanned = {
|
|
150
|
-
absolutePath,
|
|
151
|
-
displayPath: `${rootLabel(root)}:${relativePath}`,
|
|
152
|
-
relativePath,
|
|
153
|
-
rootKind: root.kind,
|
|
154
|
-
loadOrder: root.loadOrder,
|
|
155
|
-
size: bytes.length,
|
|
156
|
-
modifiedMs: metadata.mtimeMs,
|
|
157
|
-
sha256,
|
|
158
|
-
bytes,
|
|
159
|
-
};
|
|
160
|
-
result.push(scanned);
|
|
161
246
|
}
|
|
162
247
|
finally {
|
|
163
|
-
await handle.close();
|
|
248
|
+
await Promise.all(opened.map(({ handle }) => handle.close()));
|
|
164
249
|
}
|
|
165
250
|
}
|
|
166
251
|
}
|
|
252
|
+
options.signal?.throwIfAborted();
|
|
167
253
|
result.sort((left, right) => left.loadOrder - right.loadOrder || compareCodeUnits(left.relativePath, right.relativePath));
|
|
168
254
|
const groups = new Map();
|
|
169
255
|
for (const file of result) {
|
|
@@ -51,6 +51,10 @@ export class SourceDocumentCache {
|
|
|
51
51
|
this.#entries.set(key, encoded);
|
|
52
52
|
return decodedDocument(encoded);
|
|
53
53
|
}
|
|
54
|
+
/** Availability check without deserialization or cache-hit accounting. */
|
|
55
|
+
has(key) {
|
|
56
|
+
return this.#entries.has(key);
|
|
57
|
+
}
|
|
54
58
|
put(key, document) {
|
|
55
59
|
// Avoid allocating a large serialized copy just to discover that it cannot
|
|
56
60
|
// be retained. This conservative estimate affects caching, never parsing.
|