hoi4-agent-tools 3.0.9 → 3.2.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 +32 -1
- package/README.md +10 -2
- package/dist/bin/stdio.js +23 -12
- package/dist/hoi4_agent_tools/core/analysis-checkpoints.js +82 -0
- package/dist/hoi4_agent_tools/core/analysis-evidence.js +9 -0
- package/dist/hoi4_agent_tools/core/analysis-job-operations.js +38 -0
- package/dist/hoi4_agent_tools/core/artifact-resource.js +173 -0
- package/dist/hoi4_agent_tools/core/artifacts.js +84 -2
- package/dist/hoi4_agent_tools/core/condition-evaluator.js +16 -0
- package/dist/hoi4_agent_tools/core/dependency-pages.js +216 -0
- package/dist/hoi4_agent_tools/core/dependency-walk.js +266 -0
- package/dist/hoi4_agent_tools/core/domain-validation.js +188 -0
- package/dist/hoi4_agent_tools/core/engine.js +48 -5
- package/dist/hoi4_agent_tools/core/helper-expansion.js +186 -0
- package/dist/hoi4_agent_tools/core/impact-comparison.js +49 -0
- package/dist/hoi4_agent_tools/core/impact-graph.js +301 -0
- package/dist/hoi4_agent_tools/core/impact-semantic.js +256 -0
- package/dist/hoi4_agent_tools/core/index-segments.js +137 -0
- package/dist/hoi4_agent_tools/core/index.js +47 -4
- package/dist/hoi4_agent_tools/core/job-controls.js +109 -0
- package/dist/hoi4_agent_tools/core/job-executor.js +190 -0
- package/dist/hoi4_agent_tools/core/job-service.js +112 -0
- package/dist/hoi4_agent_tools/core/job-store.js +641 -0
- package/dist/hoi4_agent_tools/core/job-transactions.js +126 -0
- package/dist/hoi4_agent_tools/core/job-worker-host.js +202 -0
- package/dist/hoi4_agent_tools/core/job-worker.js +64 -0
- package/dist/hoi4_agent_tools/core/operation-context.js +31 -0
- package/dist/hoi4_agent_tools/core/operation-result.js +412 -0
- package/dist/hoi4_agent_tools/core/operation-tasks.js +387 -0
- package/dist/hoi4_agent_tools/core/persistent-analysis-cache.js +383 -0
- package/dist/hoi4_agent_tools/core/result.js +1 -0
- package/dist/hoi4_agent_tools/core/scanner.js +15 -75
- package/dist/hoi4_agent_tools/core/semantic-dependencies.js +233 -0
- package/dist/hoi4_agent_tools/core/shared-request-capacity.js +43 -11
- package/dist/hoi4_agent_tools/core/source/cache.js +107 -0
- package/dist/hoi4_agent_tools/core/source/parser.js +11 -1
- package/dist/hoi4_agent_tools/core/source-overlay.js +125 -0
- package/dist/hoi4_agent_tools/core/transaction-execution.js +134 -0
- package/dist/hoi4_agent_tools/core/transactions.js +80 -27
- package/dist/hoi4_agent_tools/decision/ai.js +104 -0
- package/dist/hoi4_agent_tools/decision/comparison.js +106 -0
- package/dist/hoi4_agent_tools/decision/cost.js +177 -0
- package/dist/hoi4_agent_tools/decision/evaluation.js +256 -0
- package/dist/hoi4_agent_tools/decision/lifecycle.js +114 -0
- package/dist/hoi4_agent_tools/decision/service.js +172 -0
- package/dist/hoi4_agent_tools/decision/source-inventory.js +182 -0
- package/dist/hoi4_agent_tools/event/graph.js +214 -290
- package/dist/hoi4_agent_tools/event/helper-expansion.js +33 -0
- package/dist/hoi4_agent_tools/event/job-operations.js +51 -0
- package/dist/hoi4_agent_tools/event/operations.js +179 -0
- package/dist/hoi4_agent_tools/event/service.js +67 -15
- package/dist/hoi4_agent_tools/event/source-analysis.js +5 -4
- package/dist/hoi4_agent_tools/focus/job-operations.js +77 -0
- package/dist/hoi4_agent_tools/focus/tool-operations.js +669 -0
- package/dist/hoi4_agent_tools/focus/tool-support.js +123 -0
- package/dist/hoi4_agent_tools/gui/job-operations.js +81 -0
- package/dist/hoi4_agent_tools/gui/tool-operations.js +270 -0
- package/dist/hoi4_agent_tools/impact/scenario-suites.js +74 -0
- package/dist/hoi4_agent_tools/impact/service.js +127 -0
- package/dist/hoi4_agent_tools/map/job-operations.js +76 -0
- package/dist/hoi4_agent_tools/map/tool-operations.js +493 -0
- package/dist/hoi4_agent_tools/mcp/prompts/probability.js +25 -0
- package/dist/hoi4_agent_tools/mcp/prompts/register-modern.js +29 -0
- package/dist/hoi4_agent_tools/mcp/resources/register-modern.js +34 -0
- package/dist/hoi4_agent_tools/mcp/resources/register.js +4 -150
- package/dist/hoi4_agent_tools/mcp/server/base-tools.js +3 -219
- package/dist/hoi4_agent_tools/mcp/server/create-modern-operations.js +188 -0
- package/dist/hoi4_agent_tools/mcp/server/create.js +18 -6
- package/dist/hoi4_agent_tools/mcp/server/instructions.js +2 -0
- package/dist/hoi4_agent_tools/mcp/server/job-task-store.js +67 -0
- package/dist/hoi4_agent_tools/mcp/server/legacy-task-tools.js +20 -0
- package/dist/hoi4_agent_tools/mcp/server/modern-tasks.js +168 -0
- package/dist/hoi4_agent_tools/mcp/server/progress.js +17 -15
- package/dist/hoi4_agent_tools/mcp/server/request-lifecycle.js +45 -0
- package/dist/hoi4_agent_tools/mcp/server/result.js +2 -412
- package/dist/hoi4_agent_tools/mcp/server/task-request-context.js +9 -0
- package/dist/hoi4_agent_tools/mcp/server/task-tool-catalog.js +16 -0
- package/dist/hoi4_agent_tools/mcp/server/task-tool-definition.js +1 -0
- package/dist/hoi4_agent_tools/mcp/server/transaction-execution.js +1 -123
- package/dist/hoi4_agent_tools/mcp/tools/analysis.js +65 -0
- package/dist/hoi4_agent_tools/mcp/tools/chaosx.js +38 -21
- package/dist/hoi4_agent_tools/mcp/tools/event.js +24 -315
- package/dist/hoi4_agent_tools/mcp/tools/focus.js +34 -1039
- package/dist/hoi4_agent_tools/mcp/tools/gui.js +19 -504
- package/dist/hoi4_agent_tools/mcp/tools/job.js +46 -0
- package/dist/hoi4_agent_tools/mcp/tools/map.js +21 -600
- package/dist/hoi4_agent_tools/mcp/tools/probability.js +39 -463
- package/dist/hoi4_agent_tools/mcp/tools/technology.js +40 -304
- package/dist/hoi4_agent_tools/mcp/transports/http.js +58 -1
- package/dist/hoi4_agent_tools/mcp/transports/modern-task-routing.js +109 -0
- package/dist/hoi4_agent_tools/mcp/transports/negotiated-stdio.js +121 -0
- package/dist/hoi4_agent_tools/probability/job-operations.js +58 -0
- package/dist/hoi4_agent_tools/probability/operations.js +183 -0
- package/dist/hoi4_agent_tools/probability/service.js +24 -3
- package/dist/hoi4_agent_tools/schemas/analysis.js +228 -0
- package/dist/hoi4_agent_tools/schemas/common.js +2 -0
- package/dist/hoi4_agent_tools/schemas/event.js +72 -1
- package/dist/hoi4_agent_tools/schemas/focus-requests.js +189 -0
- package/dist/hoi4_agent_tools/schemas/gui-requests.js +193 -0
- package/dist/hoi4_agent_tools/schemas/helper-expansion.js +58 -0
- package/dist/hoi4_agent_tools/schemas/map-requests.js +85 -0
- package/dist/hoi4_agent_tools/schemas/probability-requests.js +168 -0
- package/dist/hoi4_agent_tools/schemas/technology.js +90 -1
- package/dist/hoi4_agent_tools/schemas/transaction.js +1 -0
- package/dist/hoi4_agent_tools/technology/graph.js +83 -78
- package/dist/hoi4_agent_tools/technology/helper-expansion.js +33 -0
- package/dist/hoi4_agent_tools/technology/job-operations.js +60 -0
- package/dist/hoi4_agent_tools/technology/operations.js +125 -0
- package/dist/hoi4_agent_tools/technology/service.js +89 -15
- package/dist/hoi4_agent_tools/technology/source-analysis.js +2 -1
- package/dist/hoi4_agent_tools/version.js +1 -1
- package/docs/README.md +3 -0
- package/docs/analysis.md +100 -0
- package/docs/development.md +2 -0
- package/docs/events.md +7 -2
- package/docs/helper-expansion.md +78 -0
- package/docs/http.md +2 -0
- package/docs/jobs.md +86 -0
- package/docs/technology.md +6 -2
- package/package.json +15 -6
- 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 +290 -0
- 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 +1 -1
- package/schemas/helper-expansion-request.schema.json +44 -0
- package/schemas/helper-expansion-summary.schema.json +70 -0
- package/schemas/impact-inspect-input.schema.json +203 -0
- package/schemas/map-operation.schema.json +1 -1
- package/schemas/operation-result.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/server.json +3 -3
|
@@ -0,0 +1,383 @@
|
|
|
1
|
+
import { link, lstat, mkdir, open, opendir, readdir, unlink } from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { z } from 'zod/v4';
|
|
4
|
+
import { canonicalJson, hashCanonical, secureId, sha256Bytes } from './canonical.js';
|
|
5
|
+
import { indexSegmentAddress } from './index-segments.js';
|
|
6
|
+
import { SharedRequestCapacity } from './shared-request-capacity.js';
|
|
7
|
+
import { sourceDocumentCacheKey } from './source/cache.js';
|
|
8
|
+
import { containedGeneratedPath } from './workspace.js';
|
|
9
|
+
import { PACKAGE_VERSION } from '../version.js';
|
|
10
|
+
const digestSchema = z.string().regex(/^[a-f0-9]{64}$/u);
|
|
11
|
+
const entryKindSchema = z.enum(['source-document', 'index-segment']);
|
|
12
|
+
const unsignedEntrySchema = z
|
|
13
|
+
.object({
|
|
14
|
+
version: z.literal(1),
|
|
15
|
+
kind: entryKindSchema,
|
|
16
|
+
scopeHash: digestSchema,
|
|
17
|
+
address: digestSchema,
|
|
18
|
+
toolVersion: z.literal(PACKAGE_VERSION),
|
|
19
|
+
runtime: z.string().min(1),
|
|
20
|
+
payloadHash: digestSchema,
|
|
21
|
+
payload: z.string(),
|
|
22
|
+
})
|
|
23
|
+
.strict();
|
|
24
|
+
const entrySchema = unsignedEntrySchema.extend({ authenticationTag: digestSchema }).strict();
|
|
25
|
+
const entryFilePattern = /^(?<address>[a-f0-9]{64})\.json$/u;
|
|
26
|
+
function sourceAddress(key) {
|
|
27
|
+
return hashCanonical({
|
|
28
|
+
version: 'persistent-source-document.v1',
|
|
29
|
+
toolVersion: PACKAGE_VERSION,
|
|
30
|
+
runtime: `${process.versions.node}\0${process.versions.v8}`,
|
|
31
|
+
key,
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
function cacheScopeHash(scope) {
|
|
35
|
+
return hashCanonical({ version: 'analysis-cache-scope.v1', ...scope });
|
|
36
|
+
}
|
|
37
|
+
async function assertUnlinked(candidate) {
|
|
38
|
+
try {
|
|
39
|
+
if ((await lstat(candidate)).isSymbolicLink())
|
|
40
|
+
throw new Error('Persistent analysis cache cannot use symbolic links or junctions');
|
|
41
|
+
}
|
|
42
|
+
catch (error) {
|
|
43
|
+
if (error.code !== 'ENOENT')
|
|
44
|
+
throw error;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Internal same-version cache shared by authorized local processes.
|
|
49
|
+
* Entries are immutable, authenticated, scope-separated, and never public artifacts.
|
|
50
|
+
*/
|
|
51
|
+
export class PersistentAnalysisCache {
|
|
52
|
+
state;
|
|
53
|
+
root;
|
|
54
|
+
maxBytes;
|
|
55
|
+
maxEntries;
|
|
56
|
+
maxSingleBytes;
|
|
57
|
+
#lock;
|
|
58
|
+
#hits = 0;
|
|
59
|
+
#misses = 0;
|
|
60
|
+
#writes = 0;
|
|
61
|
+
#retainedEntries = 0;
|
|
62
|
+
#retainedBytes = 0;
|
|
63
|
+
#evictions = 0;
|
|
64
|
+
#invalidEntries = 0;
|
|
65
|
+
#oversized = 0;
|
|
66
|
+
#invalidAddresses = new Set();
|
|
67
|
+
constructor(state, root, maxBytes, maxEntries, maxSingleBytes) {
|
|
68
|
+
this.state = state;
|
|
69
|
+
this.root = root;
|
|
70
|
+
this.maxBytes = maxBytes;
|
|
71
|
+
this.maxEntries = maxEntries;
|
|
72
|
+
this.maxSingleBytes = maxSingleBytes;
|
|
73
|
+
this.#lock = new SharedRequestCapacity(root, 1);
|
|
74
|
+
}
|
|
75
|
+
static async create(state, options = {}) {
|
|
76
|
+
const maxBytes = options.maxBytes ?? 268_435_456;
|
|
77
|
+
const maxEntries = options.maxEntries ?? 50_000;
|
|
78
|
+
const maxSingleBytes = options.maxSingleBytes ?? 16_777_216;
|
|
79
|
+
for (const [name, value] of [
|
|
80
|
+
['byte budget', maxBytes],
|
|
81
|
+
['entry budget', maxEntries],
|
|
82
|
+
['single-entry budget', maxSingleBytes],
|
|
83
|
+
]) {
|
|
84
|
+
if (!Number.isSafeInteger(value) || value < 0)
|
|
85
|
+
throw new RangeError(`Persistent analysis cache ${name} must be a non-negative integer`);
|
|
86
|
+
}
|
|
87
|
+
await assertUnlinked(path.join(state.root, 'analysis-cache'));
|
|
88
|
+
const root = await containedGeneratedPath(state.root, 'analysis-cache');
|
|
89
|
+
await mkdir(root, { recursive: true, mode: 0o700 });
|
|
90
|
+
await assertUnlinked(root);
|
|
91
|
+
return new PersistentAnalysisCache(state, root, maxBytes, maxEntries, maxSingleBytes);
|
|
92
|
+
}
|
|
93
|
+
async hydrate(scope, files, documents, segments, signal) {
|
|
94
|
+
signal?.throwIfAborted();
|
|
95
|
+
const scopeHash = cacheScopeHash(scope);
|
|
96
|
+
const [sourceEntries, indexEntries] = await Promise.all([
|
|
97
|
+
this.inventory(scopeHash, 'source-document', signal),
|
|
98
|
+
this.inventory(scopeHash, 'index-segment', signal),
|
|
99
|
+
]);
|
|
100
|
+
for (const file of files) {
|
|
101
|
+
signal?.throwIfAborted();
|
|
102
|
+
const key = sourceDocumentCacheKey(file.bytes, file.displayPath);
|
|
103
|
+
const documentAddress = sourceAddress(key);
|
|
104
|
+
if (sourceEntries.has(documentAddress)) {
|
|
105
|
+
const payload = await this.read(scopeHash, 'source-document', documentAddress, signal);
|
|
106
|
+
if (payload !== undefined) {
|
|
107
|
+
try {
|
|
108
|
+
documents.importEncoded(key, payload, {
|
|
109
|
+
bytes: file.bytes,
|
|
110
|
+
sourcePath: file.displayPath,
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
catch {
|
|
114
|
+
this.#invalidEntries += 1;
|
|
115
|
+
this.#invalidAddresses.add(`${scopeHash}\0source-document\0${documentAddress}`);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
for (const provinceDefinition of [false, true]) {
|
|
120
|
+
const address = indexSegmentAddress(file, provinceDefinition);
|
|
121
|
+
if (!indexEntries.has(address))
|
|
122
|
+
continue;
|
|
123
|
+
const payload = await this.read(scopeHash, 'index-segment', address, signal);
|
|
124
|
+
if (payload === undefined)
|
|
125
|
+
continue;
|
|
126
|
+
try {
|
|
127
|
+
segments.importEncoded(address, payload.toString('utf8'));
|
|
128
|
+
}
|
|
129
|
+
catch {
|
|
130
|
+
this.#invalidEntries += 1;
|
|
131
|
+
this.#invalidAddresses.add(`${scopeHash}\0index-segment\0${address}`);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
async persist(scope, files, documents, segments, signal = new AbortController().signal) {
|
|
137
|
+
const scopeHash = cacheScopeHash(scope);
|
|
138
|
+
await this.#lock.run(signal, async () => {
|
|
139
|
+
const [sourceEntries, indexEntries] = await Promise.all([
|
|
140
|
+
this.inventory(scopeHash, 'source-document', signal),
|
|
141
|
+
this.inventory(scopeHash, 'index-segment', signal),
|
|
142
|
+
]);
|
|
143
|
+
for (const file of files) {
|
|
144
|
+
signal.throwIfAborted();
|
|
145
|
+
const key = sourceDocumentCacheKey(file.bytes, file.displayPath);
|
|
146
|
+
const documentAddress = sourceAddress(key);
|
|
147
|
+
const document = documents.exportEncoded(key);
|
|
148
|
+
if (document !== undefined &&
|
|
149
|
+
(!sourceEntries.has(documentAddress) ||
|
|
150
|
+
this.#invalidAddresses.has(`${scopeHash}\0source-document\0${documentAddress}`))) {
|
|
151
|
+
if (await this.write(scopeHash, 'source-document', documentAddress, document, signal)) {
|
|
152
|
+
sourceEntries.add(documentAddress);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
for (const provinceDefinition of [false, true]) {
|
|
156
|
+
const address = indexSegmentAddress(file, provinceDefinition);
|
|
157
|
+
const segment = segments.exportEncoded(address);
|
|
158
|
+
if (segment === undefined ||
|
|
159
|
+
(indexEntries.has(address) &&
|
|
160
|
+
!this.#invalidAddresses.has(`${scopeHash}\0index-segment\0${address}`)))
|
|
161
|
+
continue;
|
|
162
|
+
if (await this.write(scopeHash, 'index-segment', address, Buffer.from(segment), signal)) {
|
|
163
|
+
indexEntries.add(address);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
await this.prune(signal);
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
statistics() {
|
|
171
|
+
return {
|
|
172
|
+
hits: this.#hits,
|
|
173
|
+
misses: this.#misses,
|
|
174
|
+
writes: this.#writes,
|
|
175
|
+
retainedEntries: this.#retainedEntries,
|
|
176
|
+
retainedBytes: this.#retainedBytes,
|
|
177
|
+
evictions: this.#evictions,
|
|
178
|
+
invalidEntries: this.#invalidEntries,
|
|
179
|
+
oversized: this.#oversized,
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
async directory(scopeHash, kind) {
|
|
183
|
+
await assertUnlinked(this.root);
|
|
184
|
+
await assertUnlinked(path.join(this.root, scopeHash));
|
|
185
|
+
await assertUnlinked(path.join(this.root, scopeHash, kind));
|
|
186
|
+
return containedGeneratedPath(this.root, scopeHash, kind);
|
|
187
|
+
}
|
|
188
|
+
async inventory(scopeHash, kind, signal) {
|
|
189
|
+
const directory = await this.directory(scopeHash, kind);
|
|
190
|
+
const entries = new Set();
|
|
191
|
+
let opened;
|
|
192
|
+
try {
|
|
193
|
+
opened = await opendir(directory);
|
|
194
|
+
}
|
|
195
|
+
catch (error) {
|
|
196
|
+
if (error.code === 'ENOENT')
|
|
197
|
+
return entries;
|
|
198
|
+
throw error;
|
|
199
|
+
}
|
|
200
|
+
for await (const entry of opened) {
|
|
201
|
+
signal?.throwIfAborted();
|
|
202
|
+
const match = entryFilePattern.exec(entry.name);
|
|
203
|
+
if (entry.isFile() && !entry.isSymbolicLink() && match?.groups?.address !== undefined) {
|
|
204
|
+
entries.add(match.groups.address);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
return entries;
|
|
208
|
+
}
|
|
209
|
+
async read(scopeHash, kind, address, signal) {
|
|
210
|
+
signal?.throwIfAborted();
|
|
211
|
+
const directory = await this.directory(scopeHash, kind);
|
|
212
|
+
const file = await containedGeneratedPath(directory, `${digestSchema.parse(address)}.json`);
|
|
213
|
+
const invalidAddress = `${scopeHash}\0${kind}\0${address}`;
|
|
214
|
+
let handle;
|
|
215
|
+
try {
|
|
216
|
+
await assertUnlinked(file);
|
|
217
|
+
handle = await open(file, 'r');
|
|
218
|
+
const metadata = await handle.stat();
|
|
219
|
+
if (!metadata.isFile() || metadata.size > Math.ceil((this.maxSingleBytes * 4) / 3) + 4096)
|
|
220
|
+
throw new Error('Persistent analysis cache entry exceeds its storage budget');
|
|
221
|
+
const parsed = entrySchema.parse(JSON.parse(await handle.readFile('utf8')));
|
|
222
|
+
const { authenticationTag, ...entry } = parsed;
|
|
223
|
+
if (entry.scopeHash !== scopeHash ||
|
|
224
|
+
entry.kind !== kind ||
|
|
225
|
+
entry.address !== address ||
|
|
226
|
+
!this.state.verifyJournal({ kind: 'analysis-cache-entry.v1', entry }, authenticationTag))
|
|
227
|
+
throw new Error('Persistent analysis cache authentication or identity is invalid');
|
|
228
|
+
const payload = Buffer.from(entry.payload, 'base64');
|
|
229
|
+
if (payload.length > this.maxSingleBytes || sha256Bytes(payload) !== entry.payloadHash)
|
|
230
|
+
throw new Error('Persistent analysis cache payload is invalid');
|
|
231
|
+
this.#hits += 1;
|
|
232
|
+
this.#invalidAddresses.delete(invalidAddress);
|
|
233
|
+
return payload;
|
|
234
|
+
}
|
|
235
|
+
catch (error) {
|
|
236
|
+
if (error.code === 'ENOENT') {
|
|
237
|
+
this.#misses += 1;
|
|
238
|
+
return undefined;
|
|
239
|
+
}
|
|
240
|
+
this.#invalidEntries += 1;
|
|
241
|
+
this.#invalidAddresses.add(invalidAddress);
|
|
242
|
+
return undefined;
|
|
243
|
+
}
|
|
244
|
+
finally {
|
|
245
|
+
await handle?.close();
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
async write(scopeHash, kind, address, payload, signal) {
|
|
249
|
+
if (this.maxBytes === 0 ||
|
|
250
|
+
this.maxEntries === 0 ||
|
|
251
|
+
this.maxSingleBytes === 0 ||
|
|
252
|
+
payload.length > this.maxSingleBytes) {
|
|
253
|
+
this.#oversized += 1;
|
|
254
|
+
return false;
|
|
255
|
+
}
|
|
256
|
+
const directory = await this.directory(scopeHash, kind);
|
|
257
|
+
await mkdir(directory, { recursive: true, mode: 0o700 });
|
|
258
|
+
const target = await containedGeneratedPath(directory, `${digestSchema.parse(address)}.json`);
|
|
259
|
+
const invalidAddress = `${scopeHash}\0${kind}\0${address}`;
|
|
260
|
+
const unsigned = {
|
|
261
|
+
version: 1,
|
|
262
|
+
kind,
|
|
263
|
+
scopeHash,
|
|
264
|
+
address,
|
|
265
|
+
toolVersion: PACKAGE_VERSION,
|
|
266
|
+
runtime: `${process.versions.node}\0${process.versions.v8}`,
|
|
267
|
+
payloadHash: sha256Bytes(payload),
|
|
268
|
+
payload: payload.toString('base64'),
|
|
269
|
+
};
|
|
270
|
+
const bytes = Buffer.from(canonicalJson({
|
|
271
|
+
...unsigned,
|
|
272
|
+
authenticationTag: this.state.authenticateJournal({
|
|
273
|
+
kind: 'analysis-cache-entry.v1',
|
|
274
|
+
entry: unsigned,
|
|
275
|
+
}),
|
|
276
|
+
}));
|
|
277
|
+
if (bytes.length > Math.ceil((this.maxSingleBytes * 4) / 3) + 4096) {
|
|
278
|
+
this.#oversized += 1;
|
|
279
|
+
return false;
|
|
280
|
+
}
|
|
281
|
+
const repair = this.#invalidAddresses.has(invalidAddress);
|
|
282
|
+
const existing = repair ? undefined : await this.read(scopeHash, kind, address, signal);
|
|
283
|
+
if (existing !== undefined) {
|
|
284
|
+
if (!existing.equals(payload))
|
|
285
|
+
this.#invalidEntries += 1;
|
|
286
|
+
return false;
|
|
287
|
+
}
|
|
288
|
+
if (repair || this.#invalidAddresses.has(invalidAddress)) {
|
|
289
|
+
await unlink(target).catch((error) => {
|
|
290
|
+
if (error.code !== 'ENOENT')
|
|
291
|
+
throw error;
|
|
292
|
+
});
|
|
293
|
+
}
|
|
294
|
+
await assertUnlinked(target);
|
|
295
|
+
const temporary = await containedGeneratedPath(directory, `.${secureId('analysis')}.tmp`);
|
|
296
|
+
let handle;
|
|
297
|
+
try {
|
|
298
|
+
handle = await open(temporary, 'wx', 0o600);
|
|
299
|
+
await handle.writeFile(bytes);
|
|
300
|
+
await handle.sync();
|
|
301
|
+
await handle.close();
|
|
302
|
+
handle = undefined;
|
|
303
|
+
signal.throwIfAborted();
|
|
304
|
+
try {
|
|
305
|
+
await link(temporary, target);
|
|
306
|
+
this.#writes += 1;
|
|
307
|
+
this.#invalidAddresses.delete(invalidAddress);
|
|
308
|
+
return true;
|
|
309
|
+
}
|
|
310
|
+
catch (error) {
|
|
311
|
+
if (error.code !== 'EEXIST')
|
|
312
|
+
throw error;
|
|
313
|
+
const winner = await this.read(scopeHash, kind, address, signal);
|
|
314
|
+
if (winner?.equals(payload) === true)
|
|
315
|
+
return false;
|
|
316
|
+
this.#invalidEntries += 1;
|
|
317
|
+
return false;
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
finally {
|
|
321
|
+
await handle?.close();
|
|
322
|
+
await unlink(temporary).catch((error) => {
|
|
323
|
+
if (error.code !== 'ENOENT')
|
|
324
|
+
throw error;
|
|
325
|
+
});
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
async prune(signal) {
|
|
329
|
+
const retained = [];
|
|
330
|
+
let scopes;
|
|
331
|
+
try {
|
|
332
|
+
scopes = await readdir(this.root);
|
|
333
|
+
}
|
|
334
|
+
catch (error) {
|
|
335
|
+
if (error.code === 'ENOENT')
|
|
336
|
+
return;
|
|
337
|
+
throw error;
|
|
338
|
+
}
|
|
339
|
+
for (const scopeHash of scopes) {
|
|
340
|
+
if (!digestSchema.safeParse(scopeHash).success)
|
|
341
|
+
continue;
|
|
342
|
+
for (const kind of entryKindSchema.options) {
|
|
343
|
+
const directory = await this.directory(scopeHash, kind);
|
|
344
|
+
let opened;
|
|
345
|
+
try {
|
|
346
|
+
opened = await opendir(directory);
|
|
347
|
+
}
|
|
348
|
+
catch (error) {
|
|
349
|
+
if (error.code === 'ENOENT')
|
|
350
|
+
continue;
|
|
351
|
+
throw error;
|
|
352
|
+
}
|
|
353
|
+
for await (const entry of opened) {
|
|
354
|
+
signal.throwIfAborted();
|
|
355
|
+
if (!entry.isFile() || entry.isSymbolicLink() || !entryFilePattern.test(entry.name))
|
|
356
|
+
continue;
|
|
357
|
+
const file = await containedGeneratedPath(directory, entry.name);
|
|
358
|
+
const metadata = await lstat(file);
|
|
359
|
+
retained.push({ file, bytes: metadata.size, modifiedMs: metadata.mtimeMs });
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
retained.sort((left, right) => left.modifiedMs - right.modifiedMs || left.file.localeCompare(right.file, 'en-US'));
|
|
364
|
+
let retainedBytes = retained.reduce((total, entry) => total + entry.bytes, 0);
|
|
365
|
+
let retainedEntries = retained.length;
|
|
366
|
+
for (const entry of retained) {
|
|
367
|
+
if (retainedBytes <= this.maxBytes && retainedEntries <= this.maxEntries)
|
|
368
|
+
break;
|
|
369
|
+
try {
|
|
370
|
+
await unlink(entry.file);
|
|
371
|
+
retainedBytes -= entry.bytes;
|
|
372
|
+
retainedEntries -= 1;
|
|
373
|
+
this.#evictions += 1;
|
|
374
|
+
}
|
|
375
|
+
catch (error) {
|
|
376
|
+
if (!['ENOENT', 'EPERM', 'EBUSY', 'EACCES'].includes(error.code ?? ''))
|
|
377
|
+
throw error;
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
this.#retainedBytes = retainedBytes;
|
|
381
|
+
this.#retainedEntries = retainedEntries;
|
|
382
|
+
}
|
|
383
|
+
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { open } from 'node:fs/promises';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import fg from 'fast-glob';
|
|
4
|
-
import { compareCodeUnits,
|
|
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
7
|
function normalizeRelative(value) {
|
|
@@ -27,17 +27,13 @@ export class WorkspaceScanner {
|
|
|
27
27
|
serverMaxBytes;
|
|
28
28
|
serverMaxFileBytes;
|
|
29
29
|
#sourceCache = new Map();
|
|
30
|
-
#gameScanCache = new Map();
|
|
31
30
|
#sourceCacheBytes = 0;
|
|
32
|
-
#gameScanCacheBytes = 0;
|
|
33
31
|
#sourceCacheMaxBytes;
|
|
34
|
-
#gameScanCacheMaxBytes;
|
|
35
32
|
constructor(serverMaxFiles = DEFAULT_SCAN_MAX_FILES, serverMaxBytes = DEFAULT_SCAN_MAX_BYTES, serverMaxFileBytes = DEFAULT_SCAN_MAX_FILE_BYTES) {
|
|
36
33
|
this.serverMaxFiles = serverMaxFiles;
|
|
37
34
|
this.serverMaxBytes = serverMaxBytes;
|
|
38
35
|
this.serverMaxFileBytes = serverMaxFileBytes;
|
|
39
36
|
this.#sourceCacheMaxBytes = Math.max(1, Math.min(134_217_728, serverMaxBytes));
|
|
40
|
-
this.#gameScanCacheMaxBytes = Math.min(134_217_728, serverMaxBytes);
|
|
41
37
|
}
|
|
42
38
|
cacheKey(absolutePath) {
|
|
43
39
|
const resolved = path.resolve(absolutePath);
|
|
@@ -48,9 +44,11 @@ export class WorkspaceScanner {
|
|
|
48
44
|
if (previous !== undefined)
|
|
49
45
|
this.#sourceCacheBytes -= previous.bytes.length;
|
|
50
46
|
this.#sourceCache.delete(key);
|
|
47
|
+
if (source.bytes.length > this.#sourceCacheMaxBytes)
|
|
48
|
+
return;
|
|
51
49
|
this.#sourceCache.set(key, source);
|
|
52
50
|
this.#sourceCacheBytes += source.bytes.length;
|
|
53
|
-
while (this.#sourceCacheBytes > this.#sourceCacheMaxBytes
|
|
51
|
+
while (this.#sourceCacheBytes > this.#sourceCacheMaxBytes || this.#sourceCache.size > 16_384) {
|
|
54
52
|
const oldest = this.#sourceCache.entries().next().value;
|
|
55
53
|
if (oldest === undefined)
|
|
56
54
|
break;
|
|
@@ -58,46 +56,20 @@ export class WorkspaceScanner {
|
|
|
58
56
|
this.#sourceCacheBytes -= oldest[1].bytes.length;
|
|
59
57
|
}
|
|
60
58
|
}
|
|
61
|
-
gameScanKey(workspace, root, options) {
|
|
62
|
-
return hashCanonical({
|
|
63
|
-
workspaceId: workspace.id,
|
|
64
|
-
root: root.path,
|
|
65
|
-
patterns: [...options.patterns].sort(compareCodeUnits),
|
|
66
|
-
ignore: [...(options.ignore ?? ['**/.hoi4-agent/**'])].sort(compareCodeUnits),
|
|
67
|
-
});
|
|
68
|
-
}
|
|
69
|
-
retainGameScan(key, files) {
|
|
70
|
-
const bytes = files.reduce((total, file) => total + file.size, 0);
|
|
71
|
-
const previous = this.#gameScanCache.get(key);
|
|
72
|
-
if (previous !== undefined)
|
|
73
|
-
this.#gameScanCacheBytes -= previous.bytes;
|
|
74
|
-
this.#gameScanCache.delete(key);
|
|
75
|
-
if (bytes > this.#gameScanCacheMaxBytes)
|
|
76
|
-
return;
|
|
77
|
-
this.#gameScanCache.set(key, { files: files.map((file) => ({ ...file })), bytes });
|
|
78
|
-
this.#gameScanCacheBytes += bytes;
|
|
79
|
-
while (this.#gameScanCache.size > 8 || this.#gameScanCacheBytes > this.#gameScanCacheMaxBytes) {
|
|
80
|
-
const oldest = this.#gameScanCache.entries().next().value;
|
|
81
|
-
if (oldest === undefined)
|
|
82
|
-
break;
|
|
83
|
-
this.#gameScanCache.delete(oldest[0]);
|
|
84
|
-
this.#gameScanCacheBytes -= oldest[1].bytes;
|
|
85
|
-
}
|
|
86
|
-
}
|
|
87
59
|
/** Release retained source buffers after an idle MCP analysis batch. */
|
|
88
60
|
clearCaches() {
|
|
89
61
|
this.#sourceCache.clear();
|
|
90
62
|
this.#sourceCacheBytes = 0;
|
|
91
|
-
this.#gameScanCache.clear();
|
|
92
|
-
this.#gameScanCacheBytes = 0;
|
|
93
63
|
}
|
|
94
64
|
/** Cache accounting used by lifecycle and regression tests. */
|
|
95
65
|
cacheUsage() {
|
|
96
66
|
return {
|
|
97
67
|
sourceBytes: this.#sourceCacheBytes,
|
|
98
68
|
sourceEntries: this.#sourceCache.size,
|
|
99
|
-
|
|
100
|
-
|
|
69
|
+
// Retained for callers of the existing accounting interface. Inventories
|
|
70
|
+
// are no longer cached independently of verified source bytes.
|
|
71
|
+
gameScanBytes: 0,
|
|
72
|
+
gameScanEntries: 0,
|
|
101
73
|
};
|
|
102
74
|
}
|
|
103
75
|
async scan(workspace, options) {
|
|
@@ -124,29 +96,8 @@ export class WorkspaceScanner {
|
|
|
124
96
|
let enumeratedFiles = 0;
|
|
125
97
|
for (const root of roots) {
|
|
126
98
|
options.signal?.throwIfAborted();
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
if (cachedGameFiles !== undefined && gameScanKey !== undefined) {
|
|
130
|
-
this.#gameScanCache.delete(gameScanKey);
|
|
131
|
-
this.#gameScanCache.set(gameScanKey, cachedGameFiles);
|
|
132
|
-
enumeratedFiles += cachedGameFiles.files.length;
|
|
133
|
-
if (enumeratedFiles > maxFiles)
|
|
134
|
-
throw new ServiceError('SCAN_FILE_LIMIT', 'Scan exceeds the configured file limit', {
|
|
135
|
-
files: enumeratedFiles,
|
|
136
|
-
limit: maxFiles,
|
|
137
|
-
});
|
|
138
|
-
for (const file of cachedGameFiles.files) {
|
|
139
|
-
totalBytes += file.size;
|
|
140
|
-
if (totalBytes > maxBytes)
|
|
141
|
-
throw new ServiceError('SCAN_BYTE_LIMIT', 'Scan exceeds the configured byte limit', {
|
|
142
|
-
bytes: totalBytes,
|
|
143
|
-
limit: maxBytes,
|
|
144
|
-
});
|
|
145
|
-
result.push({ ...file });
|
|
146
|
-
}
|
|
147
|
-
continue;
|
|
148
|
-
}
|
|
149
|
-
const rootFiles = [];
|
|
99
|
+
// Re-enumerate every root: installing content or changing load order can
|
|
100
|
+
// invalidate a vanilla inventory just as it can a mod inventory.
|
|
150
101
|
const matches = fg.stream(options.patterns, {
|
|
151
102
|
cwd: root.path,
|
|
152
103
|
onlyFiles: true,
|
|
@@ -185,20 +136,12 @@ export class WorkspaceScanner {
|
|
|
185
136
|
});
|
|
186
137
|
}
|
|
187
138
|
const cached = this.#sourceCache.get(cacheKey);
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
: undefined;
|
|
194
|
-
const bytes = retained?.bytes ??
|
|
195
|
-
(await readBoundedFile(handle, Math.min(remaining, this.serverMaxFileBytes), options.signal));
|
|
196
|
-
const sha256 = retained?.sha256 ?? sha256Bytes(bytes);
|
|
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;
|
|
197
144
|
this.retainSource(cacheKey, {
|
|
198
|
-
size: metadata.size,
|
|
199
|
-
modifiedMs: metadata.mtimeMs,
|
|
200
|
-
changedMs: metadata.ctimeMs,
|
|
201
|
-
inode: metadata.ino,
|
|
202
145
|
sha256,
|
|
203
146
|
bytes,
|
|
204
147
|
});
|
|
@@ -215,14 +158,11 @@ export class WorkspaceScanner {
|
|
|
215
158
|
bytes,
|
|
216
159
|
};
|
|
217
160
|
result.push(scanned);
|
|
218
|
-
rootFiles.push(scanned);
|
|
219
161
|
}
|
|
220
162
|
finally {
|
|
221
163
|
await handle.close();
|
|
222
164
|
}
|
|
223
165
|
}
|
|
224
|
-
if (gameScanKey !== undefined)
|
|
225
|
-
this.retainGameScan(gameScanKey, rootFiles);
|
|
226
166
|
}
|
|
227
167
|
result.sort((left, right) => left.loadOrder - right.loadOrder || compareCodeUnits(left.relativePath, right.relativePath));
|
|
228
168
|
const groups = new Map();
|