hoi4-agent-tools 4.0.4 → 4.1.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 +20 -0
- package/README.md +2 -0
- package/dist/hoi4_agent_tools/core/bounded-source.js +210 -0
- package/dist/hoi4_agent_tools/core/diagnostic-rollup.js +91 -0
- package/dist/hoi4_agent_tools/core/domain-validation.js +12 -0
- package/dist/hoi4_agent_tools/core/engine.js +2 -1
- package/dist/hoi4_agent_tools/core/job-executor.js +9 -0
- package/dist/hoi4_agent_tools/core/managed-outputs.js +72 -0
- package/dist/hoi4_agent_tools/core/operation-result.js +28 -2
- package/dist/hoi4_agent_tools/core/scanner.js +17 -0
- package/dist/hoi4_agent_tools/core/server-state.js +9 -1
- package/dist/hoi4_agent_tools/core/temporary-directories.js +5 -1
- package/dist/hoi4_agent_tools/core/transaction-canonical.js +43 -0
- package/dist/hoi4_agent_tools/core/transaction-limits.js +7 -2
- package/dist/hoi4_agent_tools/core/transactions.js +355 -111
- package/dist/hoi4_agent_tools/gui/layout.js +61 -25
- package/dist/hoi4_agent_tools/gui/native-state.js +40 -1
- package/dist/hoi4_agent_tools/gui/source-graph.js +9 -6
- package/dist/hoi4_agent_tools/gui/studio.js +4 -1
- package/dist/hoi4_agent_tools/map/derived.js +21 -6
- package/dist/hoi4_agent_tools/map/job-operations.js +9 -3
- package/dist/hoi4_agent_tools/map/operations.js +72 -21
- package/dist/hoi4_agent_tools/map/reference-labels.js +238 -0
- package/dist/hoi4_agent_tools/map/reference-preparation.js +247 -0
- package/dist/hoi4_agent_tools/map/reference-world.js +1097 -0
- package/dist/hoi4_agent_tools/map/render.js +9 -1
- package/dist/hoi4_agent_tools/map/service.js +222 -18
- package/dist/hoi4_agent_tools/map/tool-operations.js +71 -2
- package/dist/hoi4_agent_tools/map/world-context.js +148 -0
- package/dist/hoi4_agent_tools/map/world-drainage.js +273 -0
- package/dist/hoi4_agent_tools/map/world-outputs.js +55 -0
- package/dist/hoi4_agent_tools/map/world-preview-style.js +157 -0
- package/dist/hoi4_agent_tools/map/world-preview.js +230 -0
- package/dist/hoi4_agent_tools/map/world-provinces.js +524 -0
- package/dist/hoi4_agent_tools/map/world.js +374 -253
- package/dist/hoi4_agent_tools/mcp/tools/map.js +15 -2
- package/dist/hoi4_agent_tools/schemas/map-requests.js +4 -0
- package/dist/hoi4_agent_tools/schemas/map.js +103 -10
- package/dist/hoi4_agent_tools/schemas/transaction.js +20 -3
- package/dist/hoi4_agent_tools/version.js +2 -2
- package/docs/gui.md +6 -2
- package/docs/images/reference-maps/political.png +0 -0
- package/docs/images/reference-maps/sketch.png +0 -0
- package/docs/map.md +45 -0
- package/docs/third-party-notices.md +6 -0
- package/npm-shrinkwrap.json +124 -2
- package/package.json +4 -1
- 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/error-log-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 +1 -1
- 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 +373 -70
- package/schemas/mechanic-test-input.schema.json +1 -1
- package/schemas/mod-index-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 +1 -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 +1 -1
- package/schemas/shared-scenario.schema.json +1 -1
- package/schemas/source-lookup-input.schema.json +1 -1
- package/server.json +2 -2
|
@@ -1,17 +1,19 @@
|
|
|
1
1
|
import { randomUUID } from 'node:crypto';
|
|
2
|
-
import { access, mkdir, open, readFile, readdir, rename, rm, stat, unlink } from 'node:fs/promises';
|
|
2
|
+
import { access, lstat, mkdir, open, readFile, readdir, rename, rm, stat, unlink, } from 'node:fs/promises';
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import { constants } from 'node:fs';
|
|
5
5
|
import { hostname } from 'node:os';
|
|
6
|
-
import { ArtifactStore, publicArtifactLink } from './artifacts.js';
|
|
6
|
+
import { ArtifactStore, publicArtifactLink, } from './artifacts.js';
|
|
7
7
|
import { compareCodeUnits, canonicalJson, hashCanonical, secureId, sha256Bytes, } from './canonical.js';
|
|
8
8
|
import { binaryDiff, unifiedTextDiff } from './diff.js';
|
|
9
|
+
import { hashSourceBytes, prepareSource, verifySource } from './scanner.js';
|
|
9
10
|
import { ServiceError } from './result.js';
|
|
10
11
|
import { decodeSource } from './source/encoding.js';
|
|
11
12
|
import { containedGeneratedPath, isWithin, resolvedWorkspaceIdentity } from './workspace.js';
|
|
12
|
-
import { TRANSACTION_MAX_ARTIFACTS, TRANSACTION_MAX_DIAGNOSTICS, TRANSACTION_MAX_FILES, TRANSACTION_MAX_MANIFEST_BYTES, TRANSACTION_MAX_OPERATIONS, TRANSACTION_MAX_READ_DEPENDENCIES, TRANSACTION_MAX_VALIDATION_CHECKS, } from './transaction-limits.js';
|
|
13
|
+
import { TRANSACTION_MAX_ARTIFACTS, TRANSACTION_MAX_DIAGNOSTICS, TRANSACTION_MAX_FILES, TRANSACTION_MAX_JOURNAL_BYTES, TRANSACTION_MAX_MANIFEST_BYTES, TRANSACTION_MAX_OPERATIONS, TRANSACTION_MAX_PENDING_FILES, TRANSACTION_MAX_READ_DEPENDENCIES, TRANSACTION_MAX_VALIDATION_CHECKS, } from './transaction-limits.js';
|
|
13
14
|
import { PACKAGE_VERSION, TRANSACTION_VERSION } from '../version.js';
|
|
14
15
|
import { transactionManifestSchema } from '../schemas/transaction.js';
|
|
16
|
+
import { TransactionCanonicalSnapshot } from './transaction-canonical.js';
|
|
15
17
|
/**
|
|
16
18
|
* A file name usable as an artifact name: mod files may contain spaces and other characters
|
|
17
19
|
* (`history/countries/AUR - Aurelia.txt`) that artifact names do not allow.
|
|
@@ -41,6 +43,9 @@ export function readDependenciesFromScannedFiles(files) {
|
|
|
41
43
|
const transactionManifestRangeMaxBytes = 1_048_576;
|
|
42
44
|
const transactionManifestCacheMaxBytes = TRANSACTION_MAX_MANIFEST_BYTES;
|
|
43
45
|
const transactionManifestCacheMaxEntries = 8;
|
|
46
|
+
const transactionPerFileDiffLimit = 1_000;
|
|
47
|
+
const transactionStageConcurrency = 16;
|
|
48
|
+
const transactionVerificationConcurrency = 16;
|
|
44
49
|
function transactionManifestFileIdentity(value) {
|
|
45
50
|
return {
|
|
46
51
|
dev: value.dev,
|
|
@@ -72,6 +77,24 @@ async function fileExists(filePath) {
|
|
|
72
77
|
return false;
|
|
73
78
|
}
|
|
74
79
|
}
|
|
80
|
+
async function fileIdentity(filePath) {
|
|
81
|
+
try {
|
|
82
|
+
const metadata = await lstat(filePath, { bigint: true });
|
|
83
|
+
if (!metadata.isFile())
|
|
84
|
+
throw new ServiceError('TRANSACTION_SOURCE_TYPE_INVALID', 'Transaction source or stage is not a regular file');
|
|
85
|
+
return { device: metadata.dev.toString(), inode: metadata.ino.toString() };
|
|
86
|
+
}
|
|
87
|
+
catch (error) {
|
|
88
|
+
if (error.code === 'ENOENT')
|
|
89
|
+
return null;
|
|
90
|
+
throw error;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
function sameFileIdentity(left, right) {
|
|
94
|
+
return left === null || right === null
|
|
95
|
+
? left === right
|
|
96
|
+
: left.device === right.device && left.inode === right.inode;
|
|
97
|
+
}
|
|
75
98
|
function inferMediaType(relativePath) {
|
|
76
99
|
const extension = path.extname(relativePath).toLowerCase();
|
|
77
100
|
if (extension === '.bmp')
|
|
@@ -158,7 +181,7 @@ export class TransactionManager {
|
|
|
158
181
|
serverState;
|
|
159
182
|
#manifestByteCache = new Map();
|
|
160
183
|
#manifestByteCacheBytes = 0;
|
|
161
|
-
constructor(resolver, artifacts = new ArtifactStore(), ttlSeconds = 3600, maxJournalBytes =
|
|
184
|
+
constructor(resolver, artifacts = new ArtifactStore(), ttlSeconds = 3600, maxJournalBytes = TRANSACTION_MAX_JOURNAL_BYTES, maxJournals = 128, serverState = resolver.serverState()) {
|
|
162
185
|
this.resolver = resolver;
|
|
163
186
|
this.artifacts = artifacts;
|
|
164
187
|
this.ttlSeconds = ttlSeconds;
|
|
@@ -197,7 +220,18 @@ export class TransactionManager {
|
|
|
197
220
|
(input.readDependencies?.length ?? 0) > TRANSACTION_MAX_READ_DEPENDENCIES ||
|
|
198
221
|
(input.artifacts?.length ?? 0) > TRANSACTION_MAX_ARTIFACTS ||
|
|
199
222
|
(input.diagnostics?.length ?? 0) > TRANSACTION_MAX_DIAGNOSTICS) {
|
|
200
|
-
throw new ServiceError('TRANSACTION_STRUCTURE_LIMIT', 'Transaction input exceeds the supported review structure limits'
|
|
223
|
+
throw new ServiceError('TRANSACTION_STRUCTURE_LIMIT', 'Transaction input exceeds the supported review structure limits', {
|
|
224
|
+
files: input.changes.length,
|
|
225
|
+
filesLimit: TRANSACTION_MAX_FILES,
|
|
226
|
+
operations: input.operations.length,
|
|
227
|
+
operationsLimit: TRANSACTION_MAX_OPERATIONS,
|
|
228
|
+
readDependencies: input.readDependencies?.length ?? 0,
|
|
229
|
+
readDependenciesLimit: TRANSACTION_MAX_READ_DEPENDENCIES,
|
|
230
|
+
artifacts: input.artifacts?.length ?? 0,
|
|
231
|
+
artifactsLimit: TRANSACTION_MAX_ARTIFACTS,
|
|
232
|
+
diagnostics: input.diagnostics?.length ?? 0,
|
|
233
|
+
diagnosticsLimit: TRANSACTION_MAX_DIAGNOSTICS,
|
|
234
|
+
});
|
|
201
235
|
}
|
|
202
236
|
if (typeof input.validate !== 'function') {
|
|
203
237
|
throw new ServiceError('TRANSACTION_DRY_RUN_VALIDATION_REQUIRED', 'Every transaction plan requires in-memory validation');
|
|
@@ -207,23 +241,30 @@ export class TransactionManager {
|
|
|
207
241
|
const seen = new Set();
|
|
208
242
|
const seenTargets = new Map();
|
|
209
243
|
const resolvedChanges = [];
|
|
210
|
-
for (
|
|
244
|
+
for (let offset = 0; offset < sortedChanges.length; offset += transactionVerificationConcurrency) {
|
|
211
245
|
input.signal?.throwIfAborted();
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
246
|
+
const batch = sortedChanges.slice(offset, offset + transactionVerificationConcurrency);
|
|
247
|
+
const resolvedBatch = await Promise.allSettled(batch.map((change) => this.resolver.resolvePath(input.workspaceId, change.relativePath, 'write', ['mod'], input.principal)));
|
|
248
|
+
for (const [index, change] of batch.entries()) {
|
|
249
|
+
if (seen.has(change.relativePath)) {
|
|
250
|
+
throw new ServiceError('TRANSACTION_DUPLICATE_FILE', `Transaction changes a file more than once: ${change.relativePath}`);
|
|
251
|
+
}
|
|
252
|
+
seen.add(change.relativePath);
|
|
253
|
+
const result = resolvedBatch[index];
|
|
254
|
+
if (result.status === 'rejected')
|
|
255
|
+
throw result.reason;
|
|
256
|
+
const resolved = result.value;
|
|
257
|
+
const targetKey = process.platform === 'win32' ? resolved.path.toLocaleLowerCase('en-US') : resolved.path;
|
|
258
|
+
const targetAlias = seenTargets.get(targetKey);
|
|
259
|
+
if (targetAlias !== undefined) {
|
|
260
|
+
throw new ServiceError('TRANSACTION_DUPLICATE_FILE', `Transaction path aliases target the same file: ${targetAlias} and ${change.relativePath}`);
|
|
261
|
+
}
|
|
262
|
+
seenTargets.set(targetKey, change.relativePath);
|
|
263
|
+
if (!isWithin(workspace.modRoot, resolved.path)) {
|
|
264
|
+
throw new ServiceError('TRANSACTION_PATH_OUTSIDE_MOD', 'Transaction targets a path outside the mod root');
|
|
265
|
+
}
|
|
266
|
+
resolvedChanges.push({ change, resolved });
|
|
225
267
|
}
|
|
226
|
-
resolvedChanges.push({ change, resolved });
|
|
227
268
|
}
|
|
228
269
|
const transactionId = secureId('txn');
|
|
229
270
|
const pendingBlobs = new Map();
|
|
@@ -275,10 +316,19 @@ export class TransactionManager {
|
|
|
275
316
|
throw new ServiceError('TRANSACTION_DRY_RUN_VALIDATION_REQUIRED', 'Dry-run validation must report at least one explicit check');
|
|
276
317
|
}
|
|
277
318
|
const diagnostics = [...(input.diagnostics ?? []), ...validation.diagnostics];
|
|
319
|
+
const bundledDiffs = files.length > transactionPerFileDiffLimit;
|
|
320
|
+
const diffArtifactCount = bundledDiffs ? 1 : files.length;
|
|
278
321
|
if (validation.checks.length > TRANSACTION_MAX_VALIDATION_CHECKS ||
|
|
279
322
|
diagnostics.length > TRANSACTION_MAX_DIAGNOSTICS ||
|
|
280
|
-
suppliedArtifacts.length +
|
|
281
|
-
throw new ServiceError('TRANSACTION_STRUCTURE_LIMIT', 'Transaction validation or evidence exceeds the supported review structure limits'
|
|
323
|
+
suppliedArtifacts.length + diffArtifactCount > TRANSACTION_MAX_ARTIFACTS) {
|
|
324
|
+
throw new ServiceError('TRANSACTION_STRUCTURE_LIMIT', 'Transaction validation or evidence exceeds the supported review structure limits', {
|
|
325
|
+
checks: validation.checks.length,
|
|
326
|
+
checksLimit: TRANSACTION_MAX_VALIDATION_CHECKS,
|
|
327
|
+
diagnostics: diagnostics.length,
|
|
328
|
+
diagnosticsLimit: TRANSACTION_MAX_DIAGNOSTICS,
|
|
329
|
+
artifacts: suppliedArtifacts.length + diffArtifactCount,
|
|
330
|
+
artifactsLimit: TRANSACTION_MAX_ARTIFACTS,
|
|
331
|
+
});
|
|
282
332
|
}
|
|
283
333
|
const passed = !diagnostics.some(({ severity }) => severity === 'error' || severity === 'blocker') &&
|
|
284
334
|
validation.checks.every(({ passed }) => passed);
|
|
@@ -320,13 +370,40 @@ export class TransactionManager {
|
|
|
320
370
|
},
|
|
321
371
|
};
|
|
322
372
|
});
|
|
323
|
-
|
|
373
|
+
const reviewWrites = bundledDiffs
|
|
374
|
+
? [
|
|
375
|
+
{
|
|
376
|
+
name: 'rewrite-source-diffs.json',
|
|
377
|
+
mimeType: 'application/json',
|
|
378
|
+
content: `${canonicalJson({
|
|
379
|
+
schemaVersion: 1,
|
|
380
|
+
type: 'hoi4-agent.source-diff-bundle',
|
|
381
|
+
files: files.map((file, index) => ({
|
|
382
|
+
relativePath: file.relativePath,
|
|
383
|
+
beforeSha256: file.beforeSha256,
|
|
384
|
+
afterSha256: file.afterSha256,
|
|
385
|
+
mediaType: file.mediaType,
|
|
386
|
+
diffMimeType: diffWrites[index].mimeType,
|
|
387
|
+
diff: String(diffWrites[index].content),
|
|
388
|
+
})),
|
|
389
|
+
})}\n`,
|
|
390
|
+
provenance: {
|
|
391
|
+
kind: 'source-diff-bundle',
|
|
392
|
+
toolVersion: PACKAGE_VERSION,
|
|
393
|
+
schemaVersion: 'transaction-diff-bundle.v1',
|
|
394
|
+
sourceHashes: {},
|
|
395
|
+
metadata: { fileCount: files.length, byteExactDiffs: true },
|
|
396
|
+
},
|
|
397
|
+
description: `Complete before/after hashes and review diffs for ${files.length} changed files`,
|
|
398
|
+
},
|
|
399
|
+
]
|
|
400
|
+
: diffWrites;
|
|
401
|
+
const commitReview = async (storedDiffs) => {
|
|
324
402
|
input.signal?.throwIfAborted();
|
|
325
403
|
const diffArtifacts = storedDiffs.map(publicArtifactLink);
|
|
326
|
-
const filesWithDiffs =
|
|
327
|
-
|
|
328
|
-
diffArtifact: diffArtifacts[index]
|
|
329
|
-
}));
|
|
404
|
+
const filesWithDiffs = bundledDiffs
|
|
405
|
+
? files
|
|
406
|
+
: files.map((file, index) => ({ ...file, diffArtifact: diffArtifacts[index] }));
|
|
330
407
|
const artifacts = [...suppliedArtifacts, ...diffArtifacts];
|
|
331
408
|
const now = Date.now();
|
|
332
409
|
const manifestWithoutHashes = {
|
|
@@ -362,7 +439,10 @@ export class TransactionManager {
|
|
|
362
439
|
};
|
|
363
440
|
await this.commitPlannedJournal(workspace, manifest, pendingBlobs);
|
|
364
441
|
return manifest;
|
|
365
|
-
}
|
|
442
|
+
};
|
|
443
|
+
return bundledDiffs
|
|
444
|
+
? this.artifacts.withAtomicChunkedWrites(workspace, reviewWrites, commitReview, input.signal)
|
|
445
|
+
: this.artifacts.withAtomicWrites(workspace, reviewWrites, commitReview, input.signal);
|
|
366
446
|
}
|
|
367
447
|
async apply(workspaceId, transactionId, expectedPlanHash, options) {
|
|
368
448
|
if (options === undefined || typeof options.postValidate !== 'function') {
|
|
@@ -400,48 +480,118 @@ export class TransactionManager {
|
|
|
400
480
|
try {
|
|
401
481
|
manifest.state = 'applying';
|
|
402
482
|
await this.writeManifest(workspace, manifest, options.hooks);
|
|
403
|
-
|
|
483
|
+
// Staging independent targets can share bounded I/O concurrency. Join the entire
|
|
484
|
+
// batch before hooks, replacement, or failure cleanup, so no writer can outlive
|
|
485
|
+
// recovery. Replacement and durable journal advancement remain sequential.
|
|
486
|
+
for (let offset = 0; offset < manifest.files.length; offset += transactionStageConcurrency) {
|
|
404
487
|
options.signal?.throwIfAborted();
|
|
405
|
-
const
|
|
406
|
-
await
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
const
|
|
411
|
-
|
|
488
|
+
const files = manifest.files.slice(offset, offset + transactionStageConcurrency);
|
|
489
|
+
const stagedBatch = await Promise.allSettled(files.map(async (file) => {
|
|
490
|
+
options.signal?.throwIfAborted();
|
|
491
|
+
const { path: target } = await this.resolver.resolvePath(workspaceId, file.relativePath, 'write', ['mod'], options.principal);
|
|
492
|
+
await mkdir(path.dirname(target), { recursive: true });
|
|
493
|
+
const staged = file.afterBlob === null ? null : `${target}.hoi4-agent-${transactionId}.stage`;
|
|
494
|
+
const backup = `${target}.hoi4-agent-${transactionId}.backup`;
|
|
495
|
+
if (staged !== null && file.afterBlob !== null) {
|
|
496
|
+
const bytes = await this.readVerifiedBlob(workspace, manifest, file, 'after');
|
|
497
|
+
await durableWrite(staged, bytes);
|
|
498
|
+
}
|
|
499
|
+
return { target, staged, backup };
|
|
500
|
+
}));
|
|
501
|
+
const rejected = stagedBatch.find((entry) => entry.status === 'rejected');
|
|
502
|
+
if (rejected?.status === 'rejected')
|
|
503
|
+
throw rejected.reason;
|
|
504
|
+
for (const [index, staged] of stagedBatch.entries()) {
|
|
505
|
+
if (staged.status !== 'fulfilled')
|
|
506
|
+
throw new Error('Unreachable rejected staging batch');
|
|
507
|
+
targets.push(staged.value);
|
|
508
|
+
await options.hooks?.afterStage?.(files[index].relativePath, offset + index);
|
|
412
509
|
}
|
|
413
|
-
targets.push({ target, staged, backup });
|
|
414
|
-
await options.hooks?.afterStage?.(file.relativePath, index);
|
|
415
510
|
}
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
511
|
+
{
|
|
512
|
+
const canonicalSnapshot = new TransactionCanonicalSnapshot(manifest, [
|
|
513
|
+
'revision',
|
|
514
|
+
'state',
|
|
515
|
+
'appliedFiles',
|
|
516
|
+
'pendingFiles',
|
|
517
|
+
'failure',
|
|
518
|
+
'integrityHash',
|
|
519
|
+
'authenticationTag',
|
|
520
|
+
]);
|
|
521
|
+
const batchSize = manifest.version >= 2 ? TRANSACTION_MAX_PENDING_FILES : 1;
|
|
522
|
+
for (let offset = 0; offset < manifest.files.length; offset += batchSize) {
|
|
523
|
+
const batch = manifest.files.slice(offset, offset + batchSize);
|
|
524
|
+
if (manifest.version >= 2) {
|
|
525
|
+
const captured = await Promise.allSettled(batch.map(async (file, index) => {
|
|
526
|
+
const target = targets[offset + index];
|
|
527
|
+
if (await fileExists(target.backup))
|
|
528
|
+
throw new ServiceError('TRANSACTION_STALE', 'Unexpected backup exists before replacement');
|
|
529
|
+
const beforeIdentity = await fileIdentity(target.target);
|
|
530
|
+
const stagedIdentity = target.staged === null ? null : await fileIdentity(target.staged);
|
|
531
|
+
if ((beforeIdentity === null) !== (file.beforeSha256 === null) ||
|
|
532
|
+
(stagedIdentity === null) !== (file.afterSha256 === null)) {
|
|
533
|
+
throw new ServiceError('TRANSACTION_STALE', 'Source or stage presence changed before replacement');
|
|
534
|
+
}
|
|
535
|
+
return { relativePath: file.relativePath, beforeIdentity, stagedIdentity };
|
|
536
|
+
}));
|
|
537
|
+
const rejected = captured.find((entry) => entry.status === 'rejected');
|
|
538
|
+
if (rejected?.status === 'rejected')
|
|
539
|
+
throw rejected.reason;
|
|
540
|
+
manifest.pendingFiles = captured.map((entry) => {
|
|
541
|
+
if (entry.status !== 'fulfilled')
|
|
542
|
+
throw new Error('Unreachable rejected intent batch');
|
|
543
|
+
return entry.value;
|
|
544
|
+
});
|
|
545
|
+
await this.writeManifest(workspace, manifest, options.hooks, canonicalSnapshot);
|
|
546
|
+
await options.hooks?.afterBatchIntent?.(batch.map(({ relativePath }) => relativePath), offset / batchSize);
|
|
433
547
|
}
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
548
|
+
for (const [batchIndex, file] of batch.entries()) {
|
|
549
|
+
const index = offset + batchIndex;
|
|
550
|
+
options.signal?.throwIfAborted();
|
|
551
|
+
const target = targets[index];
|
|
552
|
+
const currentPath = await this.resolver.resolvePath(workspaceId, file.relativePath, 'write', ['mod'], options.principal);
|
|
553
|
+
if (currentPath.path !== target.target)
|
|
554
|
+
throw new ServiceError('TRANSACTION_STALE', 'Canonical source path changed before replacement');
|
|
555
|
+
const existsBeforeReplace = await fileExists(target.target);
|
|
556
|
+
const actualBeforeReplace = existsBeforeReplace
|
|
557
|
+
? sha256Bytes(await readFile(target.target))
|
|
558
|
+
: null;
|
|
559
|
+
if (actualBeforeReplace !== file.beforeSha256) {
|
|
560
|
+
throw new ServiceError('TRANSACTION_STALE', 'Source changed while transaction output was being staged');
|
|
561
|
+
}
|
|
562
|
+
const pending = manifest.pendingFiles?.find(({ relativePath }) => relativePath === file.relativePath);
|
|
563
|
+
if (pending !== undefined &&
|
|
564
|
+
(!sameFileIdentity(await fileIdentity(target.target), pending.beforeIdentity) ||
|
|
565
|
+
!sameFileIdentity(target.staged === null ? null : await fileIdentity(target.staged), pending.stagedIdentity))) {
|
|
566
|
+
throw new ServiceError('TRANSACTION_STALE', 'Source or stage identity changed before replacement');
|
|
567
|
+
}
|
|
568
|
+
if (existsBeforeReplace) {
|
|
569
|
+
await rename(target.target, target.backup);
|
|
570
|
+
// An edit between the hash check and the rename would otherwise be discarded with
|
|
571
|
+
// the backup after a successful apply.
|
|
572
|
+
if (sha256Bytes(await readFile(target.backup)) !== file.beforeSha256) {
|
|
573
|
+
await rename(target.backup, target.target);
|
|
574
|
+
throw new ServiceError('TRANSACTION_STALE', 'Source changed while transaction output was being staged');
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
await options.hooks?.afterBackup?.(file.relativePath, index);
|
|
578
|
+
if (target.staged !== null) {
|
|
579
|
+
if (await fileExists(target.target)) {
|
|
580
|
+
throw new ServiceError('TRANSACTION_STALE', 'Source target was recreated during atomic replacement');
|
|
581
|
+
}
|
|
582
|
+
await rename(target.staged, target.target);
|
|
583
|
+
}
|
|
584
|
+
manifest.appliedFiles.push(file.relativePath);
|
|
585
|
+
if (manifest.version < 2)
|
|
586
|
+
await this.writeManifest(workspace, manifest, options.hooks, canonicalSnapshot);
|
|
587
|
+
await options.hooks?.afterReplace?.(file.relativePath, index);
|
|
588
|
+
}
|
|
589
|
+
if (manifest.version >= 2) {
|
|
590
|
+
await options.hooks?.beforeBatchCheckpoint?.(batch.map(({ relativePath }) => relativePath), offset / batchSize);
|
|
591
|
+
manifest.pendingFiles = [];
|
|
592
|
+
await this.writeManifest(workspace, manifest, options.hooks, canonicalSnapshot);
|
|
439
593
|
}
|
|
440
|
-
await rename(target.staged, target.target);
|
|
441
594
|
}
|
|
442
|
-
manifest.appliedFiles.push(file.relativePath);
|
|
443
|
-
await this.writeManifest(workspace, manifest, options.hooks);
|
|
444
|
-
await options.hooks?.afterReplace?.(file.relativePath, index);
|
|
445
595
|
}
|
|
446
596
|
await options.hooks?.beforePostValidation?.();
|
|
447
597
|
const result = await options.postValidate(manifest, options.signal);
|
|
@@ -453,7 +603,14 @@ export class TransactionManager {
|
|
|
453
603
|
TRANSACTION_MAX_VALIDATION_CHECKS ||
|
|
454
604
|
(manifest.executionArtifacts?.length ?? 0) + (result.artifacts?.length ?? 0) >
|
|
455
605
|
TRANSACTION_MAX_ARTIFACTS) {
|
|
456
|
-
throw new ServiceError('TRANSACTION_STRUCTURE_LIMIT', 'Post-write validation exceeds the supported transaction review or artifact limits'
|
|
606
|
+
throw new ServiceError('TRANSACTION_STRUCTURE_LIMIT', 'Post-write validation exceeds the supported transaction review or artifact limits', {
|
|
607
|
+
checks: manifest.validation.checks.length + result.checks.length,
|
|
608
|
+
checksLimit: TRANSACTION_MAX_VALIDATION_CHECKS,
|
|
609
|
+
diagnostics: manifest.diagnostics.length + result.diagnostics.length,
|
|
610
|
+
diagnosticsLimit: TRANSACTION_MAX_DIAGNOSTICS,
|
|
611
|
+
artifacts: (manifest.executionArtifacts?.length ?? 0) + (result.artifacts?.length ?? 0),
|
|
612
|
+
artifactsLimit: TRANSACTION_MAX_ARTIFACTS,
|
|
613
|
+
});
|
|
457
614
|
}
|
|
458
615
|
manifest.diagnostics.push(...result.diagnostics);
|
|
459
616
|
manifest.validation.checks.push(...result.checks);
|
|
@@ -693,6 +850,8 @@ export class TransactionManager {
|
|
|
693
850
|
// Prove that every required rollback source is intact before changing any
|
|
694
851
|
// current file. This keeps a corrupt cache from turning a safe refusal into
|
|
695
852
|
// source loss halfway through rollback.
|
|
853
|
+
const appliedFiles = new Set(manifest.appliedFiles);
|
|
854
|
+
const pendingFiles = new Map((manifest.pendingFiles ?? []).map((pending) => [pending.relativePath, pending]));
|
|
696
855
|
for (const file of manifest.files) {
|
|
697
856
|
const { path: target } = await this.resolver.resolvePath(workspace.id, file.relativePath, 'write', ['mod'], principal);
|
|
698
857
|
const backup = `${target}.hoi4-agent-${manifest.transactionId}.backup`;
|
|
@@ -700,6 +859,9 @@ export class TransactionManager {
|
|
|
700
859
|
const current = (await fileExists(target)) ? await readFile(target) : null;
|
|
701
860
|
const currentHash = current === null ? null : sha256Bytes(current);
|
|
702
861
|
const backupExists = await fileExists(backup);
|
|
862
|
+
const pending = pendingFiles.get(file.relativePath);
|
|
863
|
+
const currentIdentity = pending === undefined ? null : await fileIdentity(target);
|
|
864
|
+
const stagedIdentity = pending === undefined ? null : await fileIdentity(staged);
|
|
703
865
|
let backupValid = false;
|
|
704
866
|
if (backupExists) {
|
|
705
867
|
if (file.beforeSha256 === null) {
|
|
@@ -710,15 +872,33 @@ export class TransactionManager {
|
|
|
710
872
|
throw new ServiceError('TRANSACTION_BLOB_INTEGRITY_FAILED', 'Rollback backup failed integrity validation');
|
|
711
873
|
}
|
|
712
874
|
backupValid = true;
|
|
875
|
+
if (pending !== undefined &&
|
|
876
|
+
!sameFileIdentity(await fileIdentity(backup), pending.beforeIdentity)) {
|
|
877
|
+
throw new ServiceError('TRANSACTION_ROLLBACK_STALE', 'Rollback backup identity differs from its authenticated intent');
|
|
878
|
+
}
|
|
713
879
|
}
|
|
714
880
|
// A file this transaction never replaced keeps whatever it holds now, including an
|
|
715
881
|
// edit made while the transaction ran; it does not block restoring the others.
|
|
716
|
-
const
|
|
882
|
+
const pendingStageIntact = Boolean(pending?.stagedIdentity && sameFileIdentity(stagedIdentity, pending.stagedIdentity));
|
|
883
|
+
const pendingUntouched = pending !== undefined &&
|
|
884
|
+
!appliedFiles.has(file.relativePath) &&
|
|
885
|
+
!backupExists &&
|
|
886
|
+
(file.afterSha256 === null || pendingStageIntact) &&
|
|
887
|
+
!(pending.stagedIdentity !== null &&
|
|
888
|
+
sameFileIdentity(currentIdentity, pending.stagedIdentity));
|
|
889
|
+
const untouched = pending === undefined
|
|
890
|
+
? !appliedFiles.has(file.relativePath) && !backupExists
|
|
891
|
+
: pendingUntouched;
|
|
717
892
|
if (currentHash === file.beforeSha256 || untouched) {
|
|
718
893
|
restorations.push({ file, target, backup, staged, mode: 'keep' });
|
|
719
894
|
continue;
|
|
720
895
|
}
|
|
721
|
-
const matchesAfter = currentHash === file.afterSha256
|
|
896
|
+
const matchesAfter = currentHash === file.afterSha256 &&
|
|
897
|
+
(pending === undefined ||
|
|
898
|
+
(file.afterSha256 === null
|
|
899
|
+
? backupValid
|
|
900
|
+
: stagedIdentity === null &&
|
|
901
|
+
sameFileIdentity(currentIdentity, pending.stagedIdentity)));
|
|
722
902
|
const missingAfterBackup = currentHash === null && file.afterSha256 !== null && backupValid;
|
|
723
903
|
// Versions before the atomic replacement fix could crash after unlinking an
|
|
724
904
|
// applied target but before restoring its persisted before-blob. A durable
|
|
@@ -780,6 +960,8 @@ export class TransactionManager {
|
|
|
780
960
|
}
|
|
781
961
|
await this.verifyCurrentFiles(workspace, manifest, 'before', principal, undefined, new Set(restorations.filter(({ mode }) => mode !== 'keep').map(({ file }) => file.relativePath)));
|
|
782
962
|
manifest.rollbackStatus = 'applied';
|
|
963
|
+
if (manifest.pendingFiles !== undefined)
|
|
964
|
+
manifest.pendingFiles = [];
|
|
783
965
|
}
|
|
784
966
|
safeFailure(error, fallbackCode, message) {
|
|
785
967
|
return {
|
|
@@ -798,46 +980,74 @@ export class TransactionManager {
|
|
|
798
980
|
throw new ServiceError('TRANSACTION_PRINCIPAL_MISMATCH', 'Transaction belongs to another principal');
|
|
799
981
|
}
|
|
800
982
|
async verifyCurrentFiles(workspace, manifest, phase, principal, signal, only) {
|
|
801
|
-
|
|
983
|
+
const selected = only === undefined
|
|
984
|
+
? manifest.files
|
|
985
|
+
: manifest.files.filter(({ relativePath }) => only.has(relativePath));
|
|
986
|
+
for (let offset = 0; offset < selected.length; offset += transactionVerificationConcurrency) {
|
|
802
987
|
signal?.throwIfAborted();
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
988
|
+
const verified = await Promise.allSettled(selected.slice(offset, offset + transactionVerificationConcurrency).map(async (file) => {
|
|
989
|
+
signal?.throwIfAborted();
|
|
990
|
+
const resolved = await this.resolver.resolvePath(workspace.id, file.relativePath, 'write', ['mod'], principal);
|
|
991
|
+
const exists = await fileExists(resolved.path);
|
|
992
|
+
let actual = null;
|
|
993
|
+
if (exists) {
|
|
994
|
+
const source = await prepareSource(resolved.root, path.relative(resolved.root.path, resolved.path), signal);
|
|
995
|
+
try {
|
|
996
|
+
actual = await hashSourceBytes(source, signal);
|
|
997
|
+
await verifySource(resolved.root, source);
|
|
998
|
+
}
|
|
999
|
+
finally {
|
|
1000
|
+
await source.handle.close();
|
|
1001
|
+
}
|
|
1002
|
+
}
|
|
1003
|
+
const expected = phase === 'before' ? file.beforeSha256 : file.afterSha256;
|
|
1004
|
+
if (actual !== expected) {
|
|
1005
|
+
throw new ServiceError(phase === 'before' ? 'TRANSACTION_STALE' : 'TRANSACTION_WRITE_MISMATCH', `${file.relativePath} does not match the ${phase} hash`, { relativePath: file.relativePath, expected, actual });
|
|
1006
|
+
}
|
|
1007
|
+
}));
|
|
1008
|
+
const rejected = verified.find((entry) => entry.status === 'rejected');
|
|
1009
|
+
if (rejected?.status === 'rejected')
|
|
1010
|
+
throw rejected.reason;
|
|
812
1011
|
}
|
|
813
1012
|
}
|
|
814
1013
|
async verifyReadDependencies(workspace, manifest, principal, signal) {
|
|
815
|
-
for (
|
|
1014
|
+
for (let offset = 0; offset < manifest.readDependencies.length; offset += transactionVerificationConcurrency) {
|
|
816
1015
|
signal?.throwIfAborted();
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
const bytes = signal === undefined
|
|
821
|
-
? await readFile(resolved.path)
|
|
822
|
-
: await readFile(resolved.path, { signal });
|
|
1016
|
+
const verified = await Promise.allSettled(manifest.readDependencies
|
|
1017
|
+
.slice(offset, offset + transactionVerificationConcurrency)
|
|
1018
|
+
.map(async (dependency) => {
|
|
823
1019
|
signal?.throwIfAborted();
|
|
824
|
-
actual
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
1020
|
+
let actual;
|
|
1021
|
+
try {
|
|
1022
|
+
const resolved = await this.resolver.resolvePathInRoot(workspace.id, dependency.relativePath, dependency.rootKind, dependency.loadOrder, principal);
|
|
1023
|
+
const source = await prepareSource(resolved.root, dependency.relativePath, signal);
|
|
1024
|
+
try {
|
|
1025
|
+
actual = await hashSourceBytes(source, signal);
|
|
1026
|
+
await verifySource(resolved.root, source);
|
|
1027
|
+
}
|
|
1028
|
+
finally {
|
|
1029
|
+
await source.handle.close();
|
|
1030
|
+
}
|
|
1031
|
+
}
|
|
1032
|
+
catch (error) {
|
|
1033
|
+
if (error instanceof ServiceError && error.code.startsWith('PATH_'))
|
|
1034
|
+
actual = null;
|
|
1035
|
+
else
|
|
1036
|
+
throw error;
|
|
1037
|
+
}
|
|
1038
|
+
if (actual !== dependency.sha256) {
|
|
1039
|
+
throw new ServiceError('TRANSACTION_SOURCE_STALE', `${dependency.relativePath} changed in a source root after planning`, {
|
|
1040
|
+
relativePath: dependency.relativePath,
|
|
1041
|
+
rootKind: dependency.rootKind,
|
|
1042
|
+
loadOrder: dependency.loadOrder,
|
|
1043
|
+
expected: dependency.sha256,
|
|
1044
|
+
actual,
|
|
1045
|
+
});
|
|
1046
|
+
}
|
|
1047
|
+
}));
|
|
1048
|
+
const rejected = verified.find((entry) => entry.status === 'rejected');
|
|
1049
|
+
if (rejected?.status === 'rejected')
|
|
1050
|
+
throw rejected.reason;
|
|
841
1051
|
}
|
|
842
1052
|
}
|
|
843
1053
|
async commitPlannedJournal(workspace, manifest, blobs) {
|
|
@@ -868,14 +1078,27 @@ export class TransactionManager {
|
|
|
868
1078
|
!Number.isSafeInteger(transactionWorkBytes) ||
|
|
869
1079
|
retentionLimitReached() ||
|
|
870
1080
|
transactionWorkBytes > this.maxJournalBytes) {
|
|
871
|
-
throw new ServiceError('TRANSACTION_JOURNAL_LIMIT', 'Transaction journal retention limit has been reached'
|
|
1081
|
+
throw new ServiceError('TRANSACTION_JOURNAL_LIMIT', 'Transaction journal retention limit has been reached', {
|
|
1082
|
+
requestedWorkBytes: transactionWorkBytes,
|
|
1083
|
+
plannedJournalBytes: prospectiveBytes,
|
|
1084
|
+
retainedBytes: usage.bytes,
|
|
1085
|
+
maximumBytes: this.maxJournalBytes,
|
|
1086
|
+
});
|
|
872
1087
|
}
|
|
873
1088
|
const transactionDirectory = await this.transactionDirectory(workspace, manifest.transactionId);
|
|
874
1089
|
try {
|
|
875
1090
|
const blobsDirectory = await containedGeneratedPath(transactionDirectory, 'blobs');
|
|
876
1091
|
await mkdir(blobsDirectory, { recursive: true });
|
|
877
|
-
|
|
878
|
-
|
|
1092
|
+
const entries = [...blobs];
|
|
1093
|
+
for (let offset = 0; offset < entries.length; offset += transactionStageConcurrency) {
|
|
1094
|
+
const prepared = await Promise.allSettled(entries
|
|
1095
|
+
.slice(offset, offset + transactionStageConcurrency)
|
|
1096
|
+
.map(async ([hash, bytes]) => {
|
|
1097
|
+
await durableWrite(await containedGeneratedPath(blobsDirectory, hash), bytes);
|
|
1098
|
+
}));
|
|
1099
|
+
const rejected = prepared.find((entry) => entry.status === 'rejected');
|
|
1100
|
+
if (rejected?.status === 'rejected')
|
|
1101
|
+
throw rejected.reason;
|
|
879
1102
|
}
|
|
880
1103
|
await durableWrite(await containedGeneratedPath(transactionDirectory, 'manifest.json'), serializedManifest);
|
|
881
1104
|
await this.recordInitialManifestHead(workspace, manifest);
|
|
@@ -1084,8 +1307,8 @@ export class TransactionManager {
|
|
|
1084
1307
|
async recordInitialManifestHead(workspace, manifest) {
|
|
1085
1308
|
await this.requireServerState().recordInitialJournalHead(this.journalHeadInput(workspace, manifest));
|
|
1086
1309
|
}
|
|
1087
|
-
async recordManifestSuccessor(workspace, manifest) {
|
|
1088
|
-
await this.requireServerState().recordJournalSuccessor(this.journalHeadInput(workspace, manifest));
|
|
1310
|
+
async recordManifestSuccessor(workspace, manifest, manifestHash) {
|
|
1311
|
+
await this.requireServerState().recordJournalSuccessor(this.journalHeadInput(workspace, manifest, manifestHash));
|
|
1089
1312
|
}
|
|
1090
1313
|
async verifyManifestHead(workspace, manifest, mode, signal, manifestHash) {
|
|
1091
1314
|
const input = this.journalHeadInput(workspace, manifest, manifestHash);
|
|
@@ -1125,11 +1348,20 @@ export class TransactionManager {
|
|
|
1125
1348
|
this.removeManifestByteCache(oldest);
|
|
1126
1349
|
}
|
|
1127
1350
|
}
|
|
1128
|
-
async writeManifest(workspace, manifest, hooks) {
|
|
1129
|
-
const candidate =
|
|
1351
|
+
async writeManifest(workspace, manifest, hooks, canonicalSnapshot) {
|
|
1352
|
+
const candidate = canonicalSnapshot === undefined
|
|
1353
|
+
? structuredClone(manifest)
|
|
1354
|
+
: { ...manifest, appliedFiles: [...manifest.appliedFiles] };
|
|
1130
1355
|
candidate.revision += 1;
|
|
1131
|
-
|
|
1132
|
-
|
|
1356
|
+
if (canonicalSnapshot === undefined) {
|
|
1357
|
+
this.authenticateManifest(candidate);
|
|
1358
|
+
}
|
|
1359
|
+
else {
|
|
1360
|
+
candidate.integrityHash = sha256Bytes(canonicalSnapshot.serialize(candidate, ['integrityHash', 'authenticationTag']));
|
|
1361
|
+
candidate.authenticationTag = this.requireServerState().authenticateCanonicalJournal(canonicalSnapshot.serialize(candidate, ['authenticationTag']));
|
|
1362
|
+
}
|
|
1363
|
+
const canonicalCandidate = canonicalSnapshot?.serialize(candidate) ?? canonicalJson(candidate);
|
|
1364
|
+
const serializedCandidate = `${canonicalCandidate}\n`;
|
|
1133
1365
|
if (Buffer.byteLength(serializedCandidate, 'utf8') > TRANSACTION_MAX_MANIFEST_BYTES) {
|
|
1134
1366
|
throw new ServiceError('TRANSACTION_MANIFEST_LIMIT', 'Transaction manifest exceeds its fixed byte limit');
|
|
1135
1367
|
}
|
|
@@ -1139,7 +1371,7 @@ export class TransactionManager {
|
|
|
1139
1371
|
await durableWrite(await containedGeneratedPath(transactionDirectory, 'manifest.json'), serializedCandidate);
|
|
1140
1372
|
await hooks?.afterManifestCacheWrite?.();
|
|
1141
1373
|
await hooks?.beforeProtectedHeadCommit?.();
|
|
1142
|
-
await this.recordManifestSuccessor(workspace, candidate);
|
|
1374
|
+
await this.recordManifestSuccessor(workspace, candidate, sha256Bytes(canonicalCandidate));
|
|
1143
1375
|
Object.assign(manifest, candidate);
|
|
1144
1376
|
await hooks?.afterProtectedHeadCommit?.();
|
|
1145
1377
|
}
|
|
@@ -1195,6 +1427,18 @@ export class TransactionManager {
|
|
|
1195
1427
|
throw new ServiceError('TRANSACTION_MANIFEST_AUTHENTICATION_FAILED', `Transaction manifest authentication failed: ${transactionId}`);
|
|
1196
1428
|
}
|
|
1197
1429
|
signal?.throwIfAborted();
|
|
1430
|
+
const pendingPaths = new Set();
|
|
1431
|
+
const filesByPath = new Map(manifest.files.map((file) => [file.relativePath, file]));
|
|
1432
|
+
for (const pending of manifest.pendingFiles ?? []) {
|
|
1433
|
+
const file = filesByPath.get(pending.relativePath);
|
|
1434
|
+
if (pendingPaths.has(pending.relativePath) ||
|
|
1435
|
+
file === undefined ||
|
|
1436
|
+
(pending.beforeIdentity === null) !== (file.beforeSha256 === null) ||
|
|
1437
|
+
(pending.stagedIdentity === null) !== (file.afterSha256 === null)) {
|
|
1438
|
+
throw new ServiceError('TRANSACTION_MANIFEST_INVALID', 'Pending replacement intent does not match the authenticated file plan');
|
|
1439
|
+
}
|
|
1440
|
+
pendingPaths.add(pending.relativePath);
|
|
1441
|
+
}
|
|
1198
1442
|
if (manifest.transactionId !== transactionId || manifest.workspaceId !== workspace.id) {
|
|
1199
1443
|
throw new ServiceError('TRANSACTION_WORKSPACE_MISMATCH', 'Transaction manifest identity does not match its workspace or journal directory');
|
|
1200
1444
|
}
|