hoi4-agent-tools 3.10.0 → 4.0.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 (150) hide show
  1. package/CHANGELOG.md +109 -0
  2. package/README.md +115 -147
  3. package/dist/bin/http.js +10 -0
  4. package/dist/bin/setup.js +3 -2
  5. package/dist/bin/stdio.js +10 -0
  6. package/dist/hoi4_agent_tools/core/artifacts.js +40 -5
  7. package/dist/hoi4_agent_tools/core/canonical.js +18 -4
  8. package/dist/hoi4_agent_tools/core/configuration.js +7 -0
  9. package/dist/hoi4_agent_tools/core/domain-scan-patterns.js +12 -0
  10. package/dist/hoi4_agent_tools/core/domain-tools.js +65 -0
  11. package/dist/hoi4_agent_tools/core/domain-validation.js +5 -2
  12. package/dist/hoi4_agent_tools/core/engine.js +29 -2
  13. package/dist/hoi4_agent_tools/core/index.js +2 -2
  14. package/dist/hoi4_agent_tools/core/job-executor.js +14 -1
  15. package/dist/hoi4_agent_tools/core/job-operation-registration.js +15 -7
  16. package/dist/hoi4_agent_tools/core/job-store.js +78 -5
  17. package/dist/hoi4_agent_tools/core/job-transactions.js +31 -2
  18. package/dist/hoi4_agent_tools/core/job-worker-host.js +215 -116
  19. package/dist/hoi4_agent_tools/core/job-worker.js +90 -36
  20. package/dist/hoi4_agent_tools/core/operation-result.js +123 -10
  21. package/dist/hoi4_agent_tools/core/operation-tasks.js +49 -89
  22. package/dist/hoi4_agent_tools/core/persistent-analysis-cache.js +25 -21
  23. package/dist/hoi4_agent_tools/core/server-registry.js +22 -5
  24. package/dist/hoi4_agent_tools/core/shared-request-capacity.js +26 -1
  25. package/dist/hoi4_agent_tools/core/source/cache.js +20 -10
  26. package/dist/hoi4_agent_tools/core/source/lexer.js +11 -3
  27. package/dist/hoi4_agent_tools/core/transactions.js +114 -23
  28. package/dist/hoi4_agent_tools/core/workspace.js +84 -8
  29. package/dist/hoi4_agent_tools/decision/ai.js +1 -1
  30. package/dist/hoi4_agent_tools/decision/service.js +28 -0
  31. package/dist/hoi4_agent_tools/decision/source-inventory.js +7 -5
  32. package/dist/hoi4_agent_tools/event/job-operations.js +9 -5
  33. package/dist/hoi4_agent_tools/event/operations.js +14 -1
  34. package/dist/hoi4_agent_tools/focus/job-operations.js +1 -2
  35. package/dist/hoi4_agent_tools/focus/tool-operations.js +1 -1
  36. package/dist/hoi4_agent_tools/gui/validators.js +8 -6
  37. package/dist/hoi4_agent_tools/map/bmp.js +1 -1
  38. package/dist/hoi4_agent_tools/map/derived.js +748 -0
  39. package/dist/hoi4_agent_tools/map/job-operations.js +7 -3
  40. package/dist/hoi4_agent_tools/map/model.js +103 -0
  41. package/dist/hoi4_agent_tools/map/operations.js +427 -58
  42. package/dist/hoi4_agent_tools/map/references.js +483 -0
  43. package/dist/hoi4_agent_tools/map/service.js +136 -61
  44. package/dist/hoi4_agent_tools/map/tool-operations.js +2 -1
  45. package/dist/hoi4_agent_tools/map/validation.js +3 -0
  46. package/dist/hoi4_agent_tools/map/world.js +1280 -0
  47. package/dist/hoi4_agent_tools/mcp/server/argument-errors.js +72 -0
  48. package/dist/hoi4_agent_tools/mcp/server/create-modern-operations.js +23 -5
  49. package/dist/hoi4_agent_tools/mcp/server/create.js +7 -0
  50. package/dist/hoi4_agent_tools/mcp/server/request-lifecycle.js +24 -30
  51. package/dist/hoi4_agent_tools/mcp/server/tool-listing.js +45 -0
  52. package/dist/hoi4_agent_tools/mcp/tools/analysis.js +25 -5
  53. package/dist/hoi4_agent_tools/mcp/tools/event.js +18 -29
  54. package/dist/hoi4_agent_tools/mcp/tools/focus.js +4 -12
  55. package/dist/hoi4_agent_tools/mcp/tools/gui.js +3 -3
  56. package/dist/hoi4_agent_tools/mcp/tools/job.js +2 -2
  57. package/dist/hoi4_agent_tools/mcp/tools/map.js +13 -16
  58. package/dist/hoi4_agent_tools/mcp/tools/probability.js +42 -48
  59. package/dist/hoi4_agent_tools/mcp/tools/reference.js +22 -5
  60. package/dist/hoi4_agent_tools/mcp/tools/technology.js +16 -29
  61. package/dist/hoi4_agent_tools/mcp/transports/http.js +28 -4
  62. package/dist/hoi4_agent_tools/mcp/transports/stdio-lifetime.js +31 -8
  63. package/dist/hoi4_agent_tools/probability/job-operations.js +4 -9
  64. package/dist/hoi4_agent_tools/probability/operations.js +175 -13
  65. package/dist/hoi4_agent_tools/probability/source-analysis.js +24 -1
  66. package/dist/hoi4_agent_tools/reference/error-log.js +359 -0
  67. package/dist/hoi4_agent_tools/reference/known-mistakes.js +307 -0
  68. package/dist/hoi4_agent_tools/reference/known-mistakes.json +154 -0
  69. package/dist/hoi4_agent_tools/reference/mod-index.js +101 -0
  70. package/dist/hoi4_agent_tools/reference/operations.js +88 -19
  71. package/dist/hoi4_agent_tools/reference/player-text.js +4 -3
  72. package/dist/hoi4_agent_tools/reference/script-validation.js +19 -1
  73. package/dist/hoi4_agent_tools/reference/service.js +6 -0
  74. package/dist/hoi4_agent_tools/schemas/event.js +78 -19
  75. package/dist/hoi4_agent_tools/schemas/focus-requests.js +10 -0
  76. package/dist/hoi4_agent_tools/schemas/lenient-arguments.js +266 -0
  77. package/dist/hoi4_agent_tools/schemas/map-requests.js +63 -20
  78. package/dist/hoi4_agent_tools/schemas/map.js +122 -3
  79. package/dist/hoi4_agent_tools/schemas/mod-tools.js +181 -0
  80. package/dist/hoi4_agent_tools/schemas/probability-requests.js +119 -9
  81. package/dist/hoi4_agent_tools/schemas/reference.js +13 -0
  82. package/dist/hoi4_agent_tools/schemas/scenarios.js +1 -3
  83. package/dist/hoi4_agent_tools/schemas/script-validation.js +13 -6
  84. package/dist/hoi4_agent_tools/schemas/technology.js +79 -21
  85. package/dist/hoi4_agent_tools/technology/job-operations.js +3 -4
  86. package/dist/hoi4_agent_tools/technology/operations.js +14 -1
  87. package/dist/hoi4_agent_tools/version.js +1 -1
  88. package/docs/README.md +7 -4
  89. package/docs/analysis.md +13 -0
  90. package/docs/configuration.md +281 -0
  91. package/docs/development.md +24 -2
  92. package/docs/events.md +27 -3
  93. package/docs/examples.md +6 -6
  94. package/docs/focus.md +125 -4
  95. package/docs/gui.md +98 -17
  96. package/docs/helper-expansion.md +8 -8
  97. package/docs/http.md +3 -1
  98. package/docs/jobs.md +19 -13
  99. package/docs/map.md +197 -6
  100. package/docs/mechanics.md +4 -0
  101. package/docs/probability.md +42 -15
  102. package/docs/reference.md +71 -4
  103. package/docs/setup.md +44 -11
  104. package/docs/technology.md +26 -1
  105. package/docs/third-party-notices.md +11 -0
  106. package/docs/tools.md +151 -42
  107. package/docs/troubleshooting.md +61 -0
  108. package/examples/clients/codex-config.toml +5 -2
  109. package/examples/clients/generic.json +2 -2
  110. package/examples/config/config.json +7 -0
  111. package/npm-shrinkwrap.json +151 -156
  112. package/package.json +13 -8
  113. package/schemas/configuration.schema.json +144 -128
  114. package/schemas/continuous-focus-palette.schema.json +1 -1
  115. package/schemas/custom-weighted-pool.schema.json +1 -1
  116. package/schemas/decision-inspect-input.schema.json +1 -1
  117. package/schemas/error-log-input.schema.json +55 -0
  118. package/schemas/event-feature-manifest.schema.json +1 -1
  119. package/schemas/focus-plan.schema.json +1 -1
  120. package/schemas/focus-planning-sidecar.schema.json +1 -1
  121. package/schemas/gui-animation-source.schema.json +1 -1
  122. package/schemas/gui-helper.schema.json +1 -1
  123. package/schemas/gui-scenario.schema.json +1 -1
  124. package/schemas/helper-expansion-request.schema.json +1 -1
  125. package/schemas/helper-expansion-summary.schema.json +1 -1
  126. package/schemas/impact-inspect-input.schema.json +1 -1
  127. package/schemas/map-operation.schema.json +597 -126
  128. package/schemas/mechanic-test-input.schema.json +1 -1
  129. package/schemas/mod-index-input.schema.json +51 -0
  130. package/schemas/operation-result.schema.json +2 -2
  131. package/schemas/package-check-input.schema.json +1 -1
  132. package/schemas/package-manifest.schema.json +1 -1
  133. package/schemas/probability-analysis-result.schema.json +1 -1
  134. package/schemas/probability-compare-input.schema.json +1 -1
  135. package/schemas/probability-evaluate-input.schema.json +1 -1
  136. package/schemas/probability-inspect-input.schema.json +1 -1
  137. package/schemas/probability-render-input.schema.json +1 -1
  138. package/schemas/probability-scenario-set.schema.json +1 -1
  139. package/schemas/probability-sequence-input.schema.json +1 -1
  140. package/schemas/probability-simulate-input.schema.json +1 -1
  141. package/schemas/probability-sweep-input.schema.json +1 -1
  142. package/schemas/reference-context-input.schema.json +1 -1
  143. package/schemas/reference-read-input.schema.json +1 -1
  144. package/schemas/reference-search-input.schema.json +1 -1
  145. package/schemas/scenario-suite.schema.json +3 -5
  146. package/schemas/scenario-test-input.schema.json +3 -5
  147. package/schemas/script-validate-input.schema.json +1 -1
  148. package/schemas/shared-scenario.schema.json +1 -1
  149. package/schemas/source-lookup-input.schema.json +1 -1
  150. package/server.json +3 -3
@@ -78,6 +78,18 @@ export function countryAssetDiscoveryPatterns(workspace) {
78
78
  ...under(roots.gfx, '**/*.gfx'),
79
79
  ]);
80
80
  }
81
+ /**
82
+ * The changed files of a map rewrite plus the province definitions they refer to. A state file
83
+ * lists provinces that `map/definition.csv` defines, usually in the base game, so a scan of the
84
+ * changed files alone would report every listed province as unresolved.
85
+ */
86
+ export function mapChangeScanPatterns(relativePaths) {
87
+ return uniquePatterns([
88
+ ...exactChangedFilePatterns(relativePaths),
89
+ 'map/default.map',
90
+ 'map/*.csv',
91
+ ]);
92
+ }
81
93
  export function exactChangedFilePatterns(relativePaths) {
82
94
  return uniquePatterns(relativePaths.map((relativePath) => relativePath.replaceAll('\\', '/').replace(/^\.\//u, '')));
83
95
  }
@@ -0,0 +1,65 @@
1
+ import { decisionInspectRequestSchema, impactInspectRequestSchema } from '../schemas/analysis.js';
2
+ import { eventInspectRequestSchema, eventRenderRequestSchema } from '../schemas/event.js';
3
+ import { focusInspectRequestSchema, focusRenderRequestSchema, focusRewriteRequestSchema, } from '../schemas/focus-requests.js';
4
+ import { guiInspectRequestSchema, guiRenderRequestSchema, guiRewriteRequestSchema, } from '../schemas/gui-requests.js';
5
+ import { mapInspectRequestSchema, mapRewriteRequestSchema } from '../schemas/map-requests.js';
6
+ import { probabilityAnalyzeRequestSchema, probabilityInspectRequestSchema, } from '../schemas/probability-requests.js';
7
+ import { mechanicTestRequestSchema, packageCheckRequestSchema, scenarioTestRequestSchema, } from '../schemas/scenarios.js';
8
+ import { technologyInspectRequestSchema, technologyRenderRequestSchema, } from '../schemas/technology.js';
9
+ /**
10
+ * The single list of domain tools that run as durable jobs. Task routing, worker admission,
11
+ * request lifecycles and job validation all read this list, so adding, merging or removing a
12
+ * domain tool happens in one place.
13
+ */
14
+ export const DOMAIN_TOOL_NAMES = [
15
+ 'hoi4.impact_inspect',
16
+ 'hoi4.decision_inspect',
17
+ 'hoi4.mechanic_test',
18
+ 'hoi4.package_check',
19
+ 'hoi4.scenario_test',
20
+ 'hoi4.event_inspect',
21
+ 'hoi4.event_render',
22
+ 'hoi4.tech_inspect',
23
+ 'hoi4.tech_render',
24
+ 'hoi4.probability_inspect',
25
+ 'hoi4.probability_analyze',
26
+ 'hoi4.map_inspect',
27
+ 'hoi4.map_rewrite',
28
+ 'hoi4.gui_inspect',
29
+ 'hoi4.gui_render',
30
+ 'hoi4.gui_rewrite',
31
+ 'hoi4.focus_inspect',
32
+ 'hoi4.focus_render',
33
+ 'hoi4.focus_rewrite',
34
+ ];
35
+ export const DOMAIN_TOOL_ARGUMENT_SCHEMAS = {
36
+ 'hoi4.impact_inspect': impactInspectRequestSchema,
37
+ 'hoi4.decision_inspect': decisionInspectRequestSchema,
38
+ 'hoi4.mechanic_test': mechanicTestRequestSchema,
39
+ 'hoi4.package_check': packageCheckRequestSchema,
40
+ 'hoi4.scenario_test': scenarioTestRequestSchema,
41
+ 'hoi4.event_inspect': eventInspectRequestSchema,
42
+ 'hoi4.event_render': eventRenderRequestSchema,
43
+ 'hoi4.tech_inspect': technologyInspectRequestSchema,
44
+ 'hoi4.tech_render': technologyRenderRequestSchema,
45
+ 'hoi4.probability_inspect': probabilityInspectRequestSchema,
46
+ 'hoi4.probability_analyze': probabilityAnalyzeRequestSchema,
47
+ 'hoi4.map_inspect': mapInspectRequestSchema,
48
+ 'hoi4.map_rewrite': mapRewriteRequestSchema,
49
+ 'hoi4.gui_inspect': guiInspectRequestSchema,
50
+ 'hoi4.gui_render': guiRenderRequestSchema,
51
+ 'hoi4.gui_rewrite': guiRewriteRequestSchema,
52
+ 'hoi4.focus_inspect': focusInspectRequestSchema,
53
+ 'hoi4.focus_render': focusRenderRequestSchema,
54
+ 'hoi4.focus_rewrite': focusRewriteRequestSchema,
55
+ };
56
+ const domainToolNames = new Set(DOMAIN_TOOL_NAMES);
57
+ export function isDomainToolName(name) {
58
+ return domainToolNames.has(name);
59
+ }
60
+ /** Domain tools that change mod source. */
61
+ export const MUTATION_TOOL_NAMES = new Set([
62
+ 'hoi4.gui_rewrite',
63
+ 'hoi4.map_rewrite',
64
+ 'hoi4.focus_rewrite',
65
+ ]);
@@ -1,6 +1,6 @@
1
1
  import { boundedSourceHashEvidence, publicArtifactLink } from './artifacts.js';
2
2
  import { canonicalJson } from './canonical.js';
3
- import { exactChangedFilePatterns, focusDomainScanPatterns } from './domain-scan-patterns.js';
3
+ import { exactChangedFilePatterns, focusDomainScanPatterns, mapChangeScanPatterns, } from './domain-scan-patterns.js';
4
4
  import { parseClausewitz } from './source/index.js';
5
5
  import { TRANSACTION_MAX_DIAGNOSTICS } from './transaction-limits.js';
6
6
  import { importContinuousFocusPalettes, importFocusTrees, layoutFocusTreeAsync, lintContinuousFocusPalette, lintFocusTree, } from '../focus/index.js';
@@ -31,10 +31,13 @@ export async function postValidateTransaction(engine, transaction, principal, si
31
31
  const workspace = engine.resolver.get(transaction.workspaceId, principal);
32
32
  const focusTransaction = transaction.operationKind === 'focus-plan-changes' ||
33
33
  transaction.operationKind === 'continuous-focus-plan-changes';
34
+ const changedPaths = transaction.files.map(({ relativePath }) => relativePath);
34
35
  const snapshot = await engine.scan(transaction.workspaceId, {
35
36
  patterns: focusTransaction
36
37
  ? focusDomainScanPatterns(workspace)
37
- : exactChangedFilePatterns(transaction.files.map(({ relativePath }) => relativePath)),
38
+ : transaction.operationKind === 'agent-nudger-map-changes'
39
+ ? mapChangeScanPatterns(changedPaths)
40
+ : exactChangedFilePatterns(changedPaths),
38
41
  }, principal, signal);
39
42
  const changed = new Set(transaction.files.map(({ relativePath }) => sourcePathComparisonKey(relativePath)));
40
43
  const diagnostics = snapshot.diagnostics.filter(({ location }) => {
@@ -9,6 +9,7 @@ import { TransactionManager, transactionRootFingerprint } from './transactions.j
9
9
  import { RequestScheduler } from './request-scheduler.js';
10
10
  import { SharedRequestCapacity } from './shared-request-capacity.js';
11
11
  import { JobStore } from './job-store.js';
12
+ import { ServiceError } from './result.js';
12
13
  /**
13
14
  * Completed scans kept per workspace. One tool call often scans several pattern sets (a GUI
14
15
  * preview scans layouts, linked definitions and localisation), so a single slot made every
@@ -130,8 +131,21 @@ export class CoreEngine {
130
131
  }
131
132
  async initialize() {
132
133
  for (const workspace of this.resolver.list()) {
133
- // Recovery repairs an interrupted internal rewrite before the workspace is exposed.
134
- await this.transactions.recover(workspace.id);
134
+ // Recovery repairs an interrupted internal rewrite before the workspace is exposed. A
135
+ // journal that cannot be recovered blocks rewrites of its own workspace, not the server.
136
+ try {
137
+ await this.transactions.recover(workspace.id);
138
+ }
139
+ catch (error) {
140
+ this.transactions.recordRecoveryFailure(workspace.id, error);
141
+ process.stderr.write(`${JSON.stringify({
142
+ level: 'error',
143
+ event: 'transaction_recovery_blocked',
144
+ workspaceId: workspace.id,
145
+ code: error instanceof ServiceError ? error.code : 'TRANSACTION_RECOVERY_FAILED',
146
+ message: error instanceof Error ? error.message : String(error),
147
+ })}\n`);
148
+ }
135
149
  }
136
150
  }
137
151
  status(workspaceId, principal) {
@@ -160,6 +174,7 @@ export class CoreEngine {
160
174
  }
161
175
  async scan(workspaceId, options = {}, principal, signal) {
162
176
  signal?.throwIfAborted();
177
+ await this.resolver.refreshDescriptors(workspaceId, principal);
163
178
  const workspace = this.resolver.get(workspaceId, principal);
164
179
  const patterns = options.patterns ?? defaultPatterns(workspace);
165
180
  const ignore = options.ignore;
@@ -238,6 +253,18 @@ export class CoreEngine {
238
253
  const retained = [...this.#scanCache.keys()].filter((key) => key.startsWith(`${workspaceId}:`));
239
254
  for (const key of retained.slice(0, -SCAN_CACHE_ENTRIES_PER_WORKSPACE))
240
255
  this.#scanCache.delete(key);
256
+ // Snapshots hold every file's bytes; keep all of them within twice one scan's limit.
257
+ const limit = this.resolver.config().scanMaxBytes * 2;
258
+ const bytes = (cached) => cached.files.reduce((sum, { size }) => sum + size, 0);
259
+ let retainedBytes = [...this.#scanCache.values()].reduce((sum, cached) => sum + bytes(cached), 0);
260
+ for (const [key, cached] of this.#scanCache) {
261
+ if (retainedBytes <= limit)
262
+ break;
263
+ if (key === cacheKey)
264
+ continue;
265
+ this.#scanCache.delete(key);
266
+ retainedBytes -= bytes(cached);
267
+ }
241
268
  }
242
269
  return snapshot;
243
270
  });
@@ -450,7 +450,7 @@ export class SymbolIndex {
450
450
  return {
451
451
  symbols: this.symbols.slice(...range.symbols),
452
452
  references: this.references.slice(...range.references),
453
- diagnostics: this.diagnostics.slice(...range.diagnostics),
453
+ diagnostics: range.diagnostics,
454
454
  complete: true,
455
455
  };
456
456
  }
@@ -466,7 +466,7 @@ export class SymbolIndex {
466
466
  const record = () => this.#fileRanges.set(address, {
467
467
  symbols: [symbols, this.symbols.length],
468
468
  references: [references, this.references.length],
469
- diagnostics: [diagnostics, this.diagnostics.length],
469
+ diagnostics: this.diagnostics.slice(diagnostics),
470
470
  });
471
471
  const reused = (previous === undefined ? undefined : previous.#segmentAt(address)) ?? cache?.get(address);
472
472
  if (reused?.complete === true &&
@@ -67,7 +67,12 @@ export class JobExecutor {
67
67
  this.jobs = jobs;
68
68
  this.operations = operations;
69
69
  }
70
- async run(workspaceId, id, principal) {
70
+ /**
71
+ * `deadlineMs` (epoch milliseconds) stops a read-only operation that is still running then
72
+ * and records JOB_DEADLINE_EXCEEDED as the job's owner. An operation that never yields cannot
73
+ * observe it; its supervising host stops the process instead.
74
+ */
75
+ async run(workspaceId, id, principal, options = {}) {
71
76
  const initial = await this.jobs.get(workspaceId, id, principal);
72
77
  if (['completed', 'failed', 'cancelled'].includes(initial.status))
73
78
  return initial;
@@ -101,6 +106,12 @@ export class JobExecutor {
101
106
  const controller = new AbortController();
102
107
  const watchStop = new AbortController();
103
108
  let watchFailure;
109
+ const deadlineTimer = options.deadlineMs === undefined || operation.mutation
110
+ ? undefined
111
+ : setTimeout(() => {
112
+ watchFailure = new ServiceError('JOB_DEADLINE_EXCEEDED', 'The job ran past its deadline and was stopped; narrow its selector or limits, or raise jobDeadlineSeconds');
113
+ controller.abort(watchFailure);
114
+ }, Math.max(0, options.deadlineMs - Date.now()));
104
115
  const watch = (async () => {
105
116
  while (!watchStop.signal.aborted) {
106
117
  const current = await this.jobs.get(workspaceId, id, principal);
@@ -183,6 +194,8 @@ export class JobExecutor {
183
194
  });
184
195
  }
185
196
  finally {
197
+ if (deadlineTimer !== undefined)
198
+ clearTimeout(deadlineTimer);
186
199
  watchStop.abort();
187
200
  await watch;
188
201
  }
@@ -1,5 +1,19 @@
1
1
  import { JobOperations } from './job-executor.js';
2
2
  import { ServiceError } from './result.js';
3
+ const analysisTools = [
4
+ 'hoi4.impact_inspect',
5
+ 'hoi4.decision_inspect',
6
+ 'hoi4.mechanic_test',
7
+ 'hoi4.package_check',
8
+ 'hoi4.scenario_test',
9
+ ];
10
+ /** The domain whose registration serves a tool, so a warm worker registers each domain once. */
11
+ export function workerDomain(toolName) {
12
+ if (analysisTools.includes(toolName))
13
+ return 'analysis';
14
+ const match = /^hoi4\.([a-z]+)_/u.exec(toolName);
15
+ return match?.[1] ?? toolName;
16
+ }
3
17
  /** Load one trusted domain after the authenticated job identifies its operation. */
4
18
  export async function registerWorkerOperations(engine, toolName) {
5
19
  const operations = new JobOperations();
@@ -15,13 +29,7 @@ export async function registerWorkerOperations(engine, toolName) {
15
29
  (await import('../probability/job-operations.js')).registerProbabilityJobs(operations, engine);
16
30
  else if (toolName.startsWith('hoi4.tech_'))
17
31
  (await import('../technology/job-operations.js')).registerTechnologyJobs(operations, engine);
18
- else if ([
19
- 'hoi4.impact_inspect',
20
- 'hoi4.decision_inspect',
21
- 'hoi4.mechanic_test',
22
- 'hoi4.package_check',
23
- 'hoi4.scenario_test',
24
- ].includes(toolName))
32
+ else if (analysisTools.includes(toolName))
25
33
  (await import('./analysis-job-operations.js')).registerAnalysisJobs(operations, engine);
26
34
  else
27
35
  throw new ServiceError('JOB_OPERATION_UNAVAILABLE', 'No trusted worker domain matches this job');
@@ -251,6 +251,8 @@ export async function publishJobRecord(temporary, target, options = {}) {
251
251
  const { publish = rename, ...retry } = options;
252
252
  await retryWindowsSharing(() => publish(temporary, target), retry);
253
253
  }
254
+ const JOB_STORE_LOCK_WAIT_MS = 120_000;
255
+ const JOB_STORE_LEASE_MAX_AGE_MS = 600_000;
254
256
  /** Internal durable job records. Callers must obtain scopes from the authorized workspace resolver. */
255
257
  export class JobStore {
256
258
  state;
@@ -263,6 +265,22 @@ export class JobStore {
263
265
  this.lock = lock;
264
266
  this.maxRecordBytes = maxRecordBytes;
265
267
  }
268
+ /**
269
+ * Run under the store lock, waiting at most JOB_STORE_LOCK_WAIT_MS for it. A lock left by a
270
+ * crashed process whose pid was reused looks held forever; a bounded wait reports it instead
271
+ * of hanging every job operation. The wait bound never interrupts the locked action.
272
+ */
273
+ async locked(signal, action) {
274
+ const bounded = AbortSignal.any([signal, AbortSignal.timeout(JOB_STORE_LOCK_WAIT_MS)]);
275
+ try {
276
+ return await this.lock.run(bounded, action);
277
+ }
278
+ catch (error) {
279
+ if (bounded.aborted && !signal.aborted)
280
+ throw new ServiceError('JOB_STORE_BUSY', `The job store lock stayed held for ${JOB_STORE_LOCK_WAIT_MS / 1000} seconds; a crashed server process may still hold it`);
281
+ throw error;
282
+ }
283
+ }
266
284
  static async create(state, maxRecordBytes = 4 * 1024 * 1024) {
267
285
  if (!Number.isSafeInteger(maxRecordBytes) || maxRecordBytes < 1024)
268
286
  throw new RangeError('Job record budget must be an integer of at least 1024 bytes');
@@ -271,7 +289,10 @@ export class JobStore {
271
289
  await mkdir(root, { recursive: true, mode: 0o700 });
272
290
  await assertUnlinked(path.join(state.root, 'jobs'));
273
291
  await containedGeneratedPath(state.root, 'jobs');
274
- return new JobStore(state, root, new SharedRequestCapacity(root, 1), maxRecordBytes);
292
+ return new JobStore(state, root,
293
+ // Store operations hold the lock briefly, so an old lease is a crashed holder's even
294
+ // when its pid now belongs to another process.
295
+ new SharedRequestCapacity(root, 1, { leaseMaxAgeMs: JOB_STORE_LEASE_MAX_AGE_MS }), maxRecordBytes);
275
296
  }
276
297
  async submit(scopeInput, requestInput, signal = new AbortController().signal) {
277
298
  const scope = scopeSchema.parse(scopeInput);
@@ -287,7 +308,7 @@ export class JobStore {
287
308
  const legacyId = request.requestKey === undefined
288
309
  ? undefined
289
310
  : `job_${hashCanonical({ scope, key: request.requestKey })}`;
290
- return this.lock.run(signal, async () => {
311
+ return this.locked(signal, async () => {
291
312
  const current = await this.read(scope, id);
292
313
  // Never lose an authenticated retry receipt merely because its identifier predates
293
314
  // secret-derived IDs. Read both under the publication lock and fail closed on damage.
@@ -351,7 +372,7 @@ export class JobStore {
351
372
  async removeTerminal(scopeInput, idInput, signal = new AbortController().signal) {
352
373
  const scope = scopeSchema.parse(scopeInput);
353
374
  const id = jobId.parse(idInput);
354
- return this.lock.run(signal, async () => {
375
+ return this.locked(signal, async () => {
355
376
  const record = await this.read(scope, id);
356
377
  if (record === undefined)
357
378
  return false;
@@ -372,7 +393,7 @@ export class JobStore {
372
393
  /** Claim execution once; an interrupted owner must be proven dead, never merely slow. */
373
394
  async claim(scope, id, ownerInput, signal = new AbortController().signal) {
374
395
  const owner = ownerSchema.parse(ownerInput);
375
- return this.lock.run(signal, async () => {
396
+ return this.locked(signal, async () => {
376
397
  const current = await this.get(scope, id, signal);
377
398
  if (!['queued', 'running', 'reconciling'].includes(current.status))
378
399
  throw new ServiceError('JOB_TERMINAL', 'The job already has a terminal outcome');
@@ -458,7 +479,7 @@ export class JobStore {
458
479
  }
459
480
  async update(scope, id, expectedRevision, update, signal = new AbortController().signal) {
460
481
  const patch = updateSchema.parse(update);
461
- return this.lock.run(signal, async () => {
482
+ return this.locked(signal, async () => {
462
483
  const current = await this.get(scope, id, signal);
463
484
  if (current.revision !== expectedRevision)
464
485
  throw new ServiceError('JOB_REVISION_CONFLICT', 'Job state changed; retrieve its current revision before updating');
@@ -490,6 +511,58 @@ export class JobStore {
490
511
  });
491
512
  }
492
513
  /** Operator-internal eviction guard; never returns another principal's job data. */
514
+ /**
515
+ * Remove expired, finished read-only records across every scope, inspecting at most
516
+ * `limit` directory entries. Records are otherwise removed only when they are read again,
517
+ * so results nobody polls would accumulate without bound.
518
+ */
519
+ async sweepExpired(limit = 5_000, signal = new AbortController().signal) {
520
+ let inspected = 0;
521
+ let removed = 0;
522
+ const terminal = (record) => record !== undefined &&
523
+ !record.request.mutation &&
524
+ ['completed', 'cancelled', 'failed'].includes(record.status) &&
525
+ jobRetentionExpired(record);
526
+ await assertUnlinked(this.root);
527
+ for await (const directory of await opendir(this.root)) {
528
+ if (++inspected > limit)
529
+ break;
530
+ signal.throwIfAborted();
531
+ if (directory.name === 'request-capacity' ||
532
+ directory.isSymbolicLink() ||
533
+ !directory.isDirectory() ||
534
+ !digest.safeParse(directory.name).success)
535
+ continue;
536
+ const scopeRoot = await containedGeneratedPath(this.root, directory.name);
537
+ for await (const entry of await opendir(scopeRoot)) {
538
+ if (++inspected > limit)
539
+ break;
540
+ const id = entry.name.replace(/\.json$/u, '');
541
+ if (!entry.isFile() || !entry.name.endsWith('.json') || !jobId.safeParse(id).success)
542
+ continue;
543
+ const file = await containedGeneratedPath(scopeRoot, entry.name);
544
+ const read = () => this.readEnvelope(file, id, directory.name).catch(() => undefined);
545
+ if (!terminal(await read()))
546
+ continue;
547
+ const deleted = await this.locked(signal, async () => {
548
+ if (!terminal(await read()))
549
+ return false;
550
+ try {
551
+ await retryWindowsSharing(() => unlink(file), { signal });
552
+ return true;
553
+ }
554
+ catch (error) {
555
+ if (error.code === 'ENOENT')
556
+ return false;
557
+ throw error;
558
+ }
559
+ });
560
+ if (deleted)
561
+ removed += 1;
562
+ }
563
+ }
564
+ return removed;
565
+ }
493
566
  async checkpointResources(workspace, signal) {
494
567
  const resources = new Set();
495
568
  let inspected = 0;
@@ -55,7 +55,7 @@ export class JobTransactions {
55
55
  return this.fail(job, ownerToken, transaction.rollbackStatus === 'applied'
56
56
  ? 'JOB_REWRITE_RESTORED'
57
57
  : 'JOB_REWRITE_RECOVERY_REQUIRED', transaction.rollbackStatus === 'applied'
58
- ? 'The interrupted rewrite was restored; it was not replayed'
58
+ ? `The interrupted rewrite was restored; it was not replayed${failureCause(undefined, transaction)}`
59
59
  : 'The transaction could not establish a completed or restored outcome', principal);
60
60
  throw new ServiceError('JOB_TRANSACTION_UNRESOLVED', 'The transaction has no stable outcome after recovery');
61
61
  }
@@ -101,7 +101,7 @@ export class JobTransactions {
101
101
  if (outcome.state === 'applied')
102
102
  return this.complete(current, ownerToken, { transaction: outcome, outcome: 'applied', artifacts: [] }, true, principal, current.writeRecipe ?? recipe, completion);
103
103
  if (outcome.state === 'rolled_back')
104
- return this.fail(current, ownerToken, 'JOB_REWRITE_RESTORED', 'The rewrite failed and its original source was restored', principal);
104
+ return this.fail(current, ownerToken, 'JOB_REWRITE_RESTORED', `The rewrite failed and its original source was restored${failureCause(error, outcome)}`, principal);
105
105
  if (outcome.state === 'failed')
106
106
  return this.fail(current, ownerToken, 'JOB_REWRITE_RECOVERY_REQUIRED', 'The transaction could not establish a completed or restored outcome', principal);
107
107
  }
@@ -124,3 +124,32 @@ export class JobTransactions {
124
124
  return this.jobs.store.updateOwned(this.jobs.scope(job.scope.workspaceId, principal), job.id, ownerToken, { status: 'failed', failure: { code, message } });
125
125
  }
126
126
  }
127
+ /**
128
+ * Why the write stopped: the error code and message of a service error, else only the error
129
+ * kind, followed by the first check or diagnostic of the transaction that failed validation.
130
+ */
131
+ function failureCause(error, transaction) {
132
+ const recorded = transaction.failure === undefined
133
+ ? ''
134
+ : `${transaction.failure.code}: ${transaction.failure.message}`;
135
+ const cause = error instanceof ServiceError
136
+ ? `${error.code}: ${error.message}`
137
+ : error instanceof Error
138
+ ? error.name
139
+ : recorded;
140
+ const failedCheck = transaction.validation.checks.find(({ passed }) => !passed);
141
+ const blocking = transaction.diagnostics.find(({ severity }) => severity === 'error' || severity === 'blocker');
142
+ // The blocking diagnostic comes first: it is the part the agent can act on.
143
+ const text = [
144
+ blocking === undefined
145
+ ? ''
146
+ : `${blocking.code}: ${blocking.message}${blocking.location === undefined
147
+ ? ''
148
+ : ` at ${blocking.location.path}:${blocking.location.start.line}`}`,
149
+ failedCheck?.message ?? '',
150
+ cause,
151
+ ]
152
+ .filter((part) => part !== '')
153
+ .join('; ');
154
+ return text === '' ? '' : ` (${text})`.slice(0, 700);
155
+ }