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.
Files changed (85) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/README.md +2 -0
  3. package/dist/hoi4_agent_tools/core/bounded-source.js +210 -0
  4. package/dist/hoi4_agent_tools/core/diagnostic-rollup.js +91 -0
  5. package/dist/hoi4_agent_tools/core/domain-validation.js +12 -0
  6. package/dist/hoi4_agent_tools/core/engine.js +2 -1
  7. package/dist/hoi4_agent_tools/core/job-executor.js +9 -0
  8. package/dist/hoi4_agent_tools/core/managed-outputs.js +72 -0
  9. package/dist/hoi4_agent_tools/core/operation-result.js +28 -2
  10. package/dist/hoi4_agent_tools/core/scanner.js +17 -0
  11. package/dist/hoi4_agent_tools/core/server-state.js +9 -1
  12. package/dist/hoi4_agent_tools/core/temporary-directories.js +5 -1
  13. package/dist/hoi4_agent_tools/core/transaction-canonical.js +43 -0
  14. package/dist/hoi4_agent_tools/core/transaction-limits.js +7 -2
  15. package/dist/hoi4_agent_tools/core/transactions.js +355 -111
  16. package/dist/hoi4_agent_tools/gui/layout.js +61 -25
  17. package/dist/hoi4_agent_tools/gui/native-state.js +40 -1
  18. package/dist/hoi4_agent_tools/gui/source-graph.js +9 -6
  19. package/dist/hoi4_agent_tools/gui/studio.js +4 -1
  20. package/dist/hoi4_agent_tools/map/derived.js +21 -6
  21. package/dist/hoi4_agent_tools/map/job-operations.js +9 -3
  22. package/dist/hoi4_agent_tools/map/operations.js +72 -21
  23. package/dist/hoi4_agent_tools/map/reference-labels.js +238 -0
  24. package/dist/hoi4_agent_tools/map/reference-preparation.js +247 -0
  25. package/dist/hoi4_agent_tools/map/reference-world.js +1097 -0
  26. package/dist/hoi4_agent_tools/map/render.js +9 -1
  27. package/dist/hoi4_agent_tools/map/service.js +222 -18
  28. package/dist/hoi4_agent_tools/map/tool-operations.js +71 -2
  29. package/dist/hoi4_agent_tools/map/world-context.js +148 -0
  30. package/dist/hoi4_agent_tools/map/world-drainage.js +273 -0
  31. package/dist/hoi4_agent_tools/map/world-outputs.js +55 -0
  32. package/dist/hoi4_agent_tools/map/world-preview-style.js +157 -0
  33. package/dist/hoi4_agent_tools/map/world-preview.js +230 -0
  34. package/dist/hoi4_agent_tools/map/world-provinces.js +524 -0
  35. package/dist/hoi4_agent_tools/map/world.js +374 -253
  36. package/dist/hoi4_agent_tools/mcp/tools/map.js +15 -2
  37. package/dist/hoi4_agent_tools/schemas/map-requests.js +4 -0
  38. package/dist/hoi4_agent_tools/schemas/map.js +103 -10
  39. package/dist/hoi4_agent_tools/schemas/transaction.js +20 -3
  40. package/dist/hoi4_agent_tools/version.js +2 -2
  41. package/docs/gui.md +6 -2
  42. package/docs/images/reference-maps/political.png +0 -0
  43. package/docs/images/reference-maps/sketch.png +0 -0
  44. package/docs/map.md +45 -0
  45. package/docs/third-party-notices.md +6 -0
  46. package/npm-shrinkwrap.json +124 -2
  47. package/package.json +4 -1
  48. package/schemas/configuration.schema.json +1 -1
  49. package/schemas/continuous-focus-palette.schema.json +1 -1
  50. package/schemas/custom-weighted-pool.schema.json +1 -1
  51. package/schemas/decision-inspect-input.schema.json +1 -1
  52. package/schemas/error-log-input.schema.json +1 -1
  53. package/schemas/event-feature-manifest.schema.json +1 -1
  54. package/schemas/focus-plan.schema.json +1 -1
  55. package/schemas/focus-planning-sidecar.schema.json +1 -1
  56. package/schemas/gui-animation-source.schema.json +1 -1
  57. package/schemas/gui-helper.schema.json +1 -1
  58. package/schemas/gui-scenario.schema.json +1 -1
  59. package/schemas/helper-expansion-request.schema.json +1 -1
  60. package/schemas/helper-expansion-summary.schema.json +1 -1
  61. package/schemas/impact-inspect-input.schema.json +1 -1
  62. package/schemas/map-operation.schema.json +373 -70
  63. package/schemas/mechanic-test-input.schema.json +1 -1
  64. package/schemas/mod-index-input.schema.json +1 -1
  65. package/schemas/operation-result.schema.json +1 -1
  66. package/schemas/package-check-input.schema.json +1 -1
  67. package/schemas/package-manifest.schema.json +1 -1
  68. package/schemas/probability-analysis-result.schema.json +1 -1
  69. package/schemas/probability-compare-input.schema.json +1 -1
  70. package/schemas/probability-evaluate-input.schema.json +1 -1
  71. package/schemas/probability-inspect-input.schema.json +1 -1
  72. package/schemas/probability-render-input.schema.json +1 -1
  73. package/schemas/probability-scenario-set.schema.json +1 -1
  74. package/schemas/probability-sequence-input.schema.json +1 -1
  75. package/schemas/probability-simulate-input.schema.json +1 -1
  76. package/schemas/probability-sweep-input.schema.json +1 -1
  77. package/schemas/reference-context-input.schema.json +1 -1
  78. package/schemas/reference-read-input.schema.json +1 -1
  79. package/schemas/reference-search-input.schema.json +1 -1
  80. package/schemas/scenario-suite.schema.json +1 -1
  81. package/schemas/scenario-test-input.schema.json +1 -1
  82. package/schemas/script-validate-input.schema.json +1 -1
  83. package/schemas/shared-scenario.schema.json +1 -1
  84. package/schemas/source-lookup-input.schema.json +1 -1
  85. 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 = 536_870_912, maxJournals = 128, serverState = resolver.serverState()) {
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 (const change of sortedChanges) {
244
+ for (let offset = 0; offset < sortedChanges.length; offset += transactionVerificationConcurrency) {
211
245
  input.signal?.throwIfAborted();
212
- if (seen.has(change.relativePath)) {
213
- throw new ServiceError('TRANSACTION_DUPLICATE_FILE', `Transaction changes a file more than once: ${change.relativePath}`);
214
- }
215
- seen.add(change.relativePath);
216
- const resolved = await this.resolver.resolvePath(input.workspaceId, change.relativePath, 'write', ['mod'], input.principal);
217
- const targetKey = process.platform === 'win32' ? resolved.path.toLocaleLowerCase('en-US') : resolved.path;
218
- const targetAlias = seenTargets.get(targetKey);
219
- if (targetAlias !== undefined) {
220
- throw new ServiceError('TRANSACTION_DUPLICATE_FILE', `Transaction path aliases target the same file: ${targetAlias} and ${change.relativePath}`);
221
- }
222
- seenTargets.set(targetKey, change.relativePath);
223
- if (!isWithin(workspace.modRoot, resolved.path)) {
224
- throw new ServiceError('TRANSACTION_PATH_OUTSIDE_MOD', 'Transaction targets a path outside the mod root');
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 + files.length > TRANSACTION_MAX_ARTIFACTS) {
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
- return this.artifacts.withAtomicWrites(workspace, diffWrites, async (storedDiffs) => {
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 = files.map((file, index) => ({
327
- ...file,
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
- }, input.signal);
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
- for (const [index, file] of manifest.files.entries()) {
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 { path: target } = await this.resolver.resolvePath(workspaceId, file.relativePath, 'write', ['mod'], options.principal);
406
- await mkdir(path.dirname(target), { recursive: true });
407
- const staged = file.afterBlob === null ? null : `${target}.hoi4-agent-${transactionId}.stage`;
408
- const backup = `${target}.hoi4-agent-${transactionId}.backup`;
409
- if (staged !== null && file.afterBlob !== null) {
410
- const bytes = await this.readVerifiedBlob(workspace, manifest, file, 'after');
411
- await durableWrite(staged, bytes);
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
- for (const [index, file] of manifest.files.entries()) {
417
- options.signal?.throwIfAborted();
418
- const target = targets[index];
419
- const existsBeforeReplace = await fileExists(target.target);
420
- const actualBeforeReplace = existsBeforeReplace
421
- ? sha256Bytes(await readFile(target.target))
422
- : null;
423
- if (actualBeforeReplace !== file.beforeSha256) {
424
- throw new ServiceError('TRANSACTION_STALE', 'Source changed while transaction output was being staged');
425
- }
426
- if (existsBeforeReplace) {
427
- await rename(target.target, target.backup);
428
- // An edit between the hash check and the rename would otherwise be discarded with
429
- // the backup after a successful apply.
430
- if (sha256Bytes(await readFile(target.backup)) !== file.beforeSha256) {
431
- await rename(target.backup, target.target);
432
- throw new ServiceError('TRANSACTION_STALE', 'Source changed while transaction output was being staged');
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
- await options.hooks?.afterBackup?.(file.relativePath, index);
436
- if (target.staged !== null) {
437
- if (await fileExists(target.target)) {
438
- throw new ServiceError('TRANSACTION_STALE', 'Source target was recreated during atomic replacement');
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 untouched = !manifest.appliedFiles.includes(file.relativePath) && !backupExists;
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
- for (const file of manifest.files) {
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
- if (only !== undefined && !only.has(file.relativePath))
804
- continue;
805
- const { path: target } = await this.resolver.resolvePath(workspace.id, file.relativePath, 'write', ['mod'], principal);
806
- const exists = await fileExists(target);
807
- const actual = exists ? sha256Bytes(await readFile(target)) : null;
808
- const expected = phase === 'before' ? file.beforeSha256 : file.afterSha256;
809
- if (actual !== expected) {
810
- throw new ServiceError(phase === 'before' ? 'TRANSACTION_STALE' : 'TRANSACTION_WRITE_MISMATCH', `${file.relativePath} does not match the ${phase} hash`, { relativePath: file.relativePath, expected, actual });
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 (const dependency of manifest.readDependencies) {
1014
+ for (let offset = 0; offset < manifest.readDependencies.length; offset += transactionVerificationConcurrency) {
816
1015
  signal?.throwIfAborted();
817
- let actual;
818
- try {
819
- const resolved = await this.resolver.resolvePathInRoot(workspace.id, dependency.relativePath, dependency.rootKind, dependency.loadOrder, principal);
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 = sha256Bytes(bytes);
825
- }
826
- catch (error) {
827
- if (error instanceof ServiceError && error.code.startsWith('PATH_'))
828
- actual = null;
829
- else
830
- throw error;
831
- }
832
- if (actual !== dependency.sha256) {
833
- throw new ServiceError('TRANSACTION_SOURCE_STALE', `${dependency.relativePath} changed in a source root after planning`, {
834
- relativePath: dependency.relativePath,
835
- rootKind: dependency.rootKind,
836
- loadOrder: dependency.loadOrder,
837
- expected: dependency.sha256,
838
- actual,
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
- for (const [hash, bytes] of blobs) {
878
- await durableWrite(await containedGeneratedPath(blobsDirectory, hash), bytes);
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 = structuredClone(manifest);
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
- this.authenticateManifest(candidate);
1132
- const serializedCandidate = `${canonicalJson(candidate)}\n`;
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
  }