hoi4-agent-tools 3.9.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 (157) hide show
  1. package/CHANGELOG.md +118 -0
  2. package/README.md +115 -147
  3. package/dist/bin/http.js +12 -0
  4. package/dist/bin/setup.js +3 -2
  5. package/dist/bin/stdio.js +12 -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 +56 -10
  13. package/dist/hoi4_agent_tools/core/index.js +45 -20
  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 +155 -0
  24. package/dist/hoi4_agent_tools/core/server-status-resource.js +22 -0
  25. package/dist/hoi4_agent_tools/core/shared-request-capacity.js +26 -1
  26. package/dist/hoi4_agent_tools/core/source/cache.js +108 -29
  27. package/dist/hoi4_agent_tools/core/source/lexer.js +11 -3
  28. package/dist/hoi4_agent_tools/core/transactions.js +114 -23
  29. package/dist/hoi4_agent_tools/core/workspace.js +84 -8
  30. package/dist/hoi4_agent_tools/decision/ai.js +1 -1
  31. package/dist/hoi4_agent_tools/decision/service.js +28 -0
  32. package/dist/hoi4_agent_tools/decision/source-inventory.js +7 -5
  33. package/dist/hoi4_agent_tools/event/job-operations.js +9 -5
  34. package/dist/hoi4_agent_tools/event/operations.js +14 -1
  35. package/dist/hoi4_agent_tools/event/service.js +7 -0
  36. package/dist/hoi4_agent_tools/focus/job-operations.js +1 -2
  37. package/dist/hoi4_agent_tools/focus/tool-operations.js +1 -1
  38. package/dist/hoi4_agent_tools/gui/scenario-generator.js +11 -3
  39. package/dist/hoi4_agent_tools/gui/studio.js +20 -7
  40. package/dist/hoi4_agent_tools/gui/validators.js +8 -6
  41. package/dist/hoi4_agent_tools/map/bmp.js +1 -1
  42. package/dist/hoi4_agent_tools/map/derived.js +748 -0
  43. package/dist/hoi4_agent_tools/map/job-operations.js +7 -3
  44. package/dist/hoi4_agent_tools/map/model.js +103 -0
  45. package/dist/hoi4_agent_tools/map/operations.js +427 -58
  46. package/dist/hoi4_agent_tools/map/references.js +483 -0
  47. package/dist/hoi4_agent_tools/map/service.js +136 -61
  48. package/dist/hoi4_agent_tools/map/tool-operations.js +2 -1
  49. package/dist/hoi4_agent_tools/map/validation.js +3 -0
  50. package/dist/hoi4_agent_tools/map/world.js +1280 -0
  51. package/dist/hoi4_agent_tools/mcp/resources/register-modern.js +13 -1
  52. package/dist/hoi4_agent_tools/mcp/resources/register.js +6 -0
  53. package/dist/hoi4_agent_tools/mcp/server/argument-errors.js +72 -0
  54. package/dist/hoi4_agent_tools/mcp/server/create-modern-operations.js +23 -5
  55. package/dist/hoi4_agent_tools/mcp/server/create.js +7 -0
  56. package/dist/hoi4_agent_tools/mcp/server/request-lifecycle.js +24 -30
  57. package/dist/hoi4_agent_tools/mcp/server/tool-listing.js +45 -0
  58. package/dist/hoi4_agent_tools/mcp/tools/analysis.js +25 -5
  59. package/dist/hoi4_agent_tools/mcp/tools/event.js +18 -29
  60. package/dist/hoi4_agent_tools/mcp/tools/focus.js +4 -12
  61. package/dist/hoi4_agent_tools/mcp/tools/gui.js +3 -3
  62. package/dist/hoi4_agent_tools/mcp/tools/job.js +2 -2
  63. package/dist/hoi4_agent_tools/mcp/tools/map.js +13 -16
  64. package/dist/hoi4_agent_tools/mcp/tools/probability.js +42 -48
  65. package/dist/hoi4_agent_tools/mcp/tools/reference.js +22 -5
  66. package/dist/hoi4_agent_tools/mcp/tools/technology.js +16 -26
  67. package/dist/hoi4_agent_tools/mcp/transports/http.js +28 -4
  68. package/dist/hoi4_agent_tools/mcp/transports/stdio-lifetime.js +37 -18
  69. package/dist/hoi4_agent_tools/probability/job-operations.js +4 -9
  70. package/dist/hoi4_agent_tools/probability/operations.js +175 -13
  71. package/dist/hoi4_agent_tools/probability/source-analysis.js +24 -1
  72. package/dist/hoi4_agent_tools/reference/error-log.js +359 -0
  73. package/dist/hoi4_agent_tools/reference/known-mistakes.js +307 -0
  74. package/dist/hoi4_agent_tools/reference/known-mistakes.json +154 -0
  75. package/dist/hoi4_agent_tools/reference/mod-index.js +101 -0
  76. package/dist/hoi4_agent_tools/reference/operations.js +88 -19
  77. package/dist/hoi4_agent_tools/reference/player-text.js +4 -3
  78. package/dist/hoi4_agent_tools/reference/script-validation.js +19 -1
  79. package/dist/hoi4_agent_tools/reference/service.js +6 -0
  80. package/dist/hoi4_agent_tools/schemas/event.js +78 -19
  81. package/dist/hoi4_agent_tools/schemas/focus-requests.js +10 -0
  82. package/dist/hoi4_agent_tools/schemas/lenient-arguments.js +266 -0
  83. package/dist/hoi4_agent_tools/schemas/map-requests.js +63 -20
  84. package/dist/hoi4_agent_tools/schemas/map.js +122 -3
  85. package/dist/hoi4_agent_tools/schemas/mod-tools.js +181 -0
  86. package/dist/hoi4_agent_tools/schemas/probability-requests.js +119 -9
  87. package/dist/hoi4_agent_tools/schemas/reference.js +13 -0
  88. package/dist/hoi4_agent_tools/schemas/scenarios.js +1 -3
  89. package/dist/hoi4_agent_tools/schemas/script-validation.js +13 -6
  90. package/dist/hoi4_agent_tools/schemas/technology.js +83 -23
  91. package/dist/hoi4_agent_tools/technology/job-operations.js +3 -4
  92. package/dist/hoi4_agent_tools/technology/operations.js +14 -1
  93. package/dist/hoi4_agent_tools/technology/service.js +7 -0
  94. package/dist/hoi4_agent_tools/version.js +1 -1
  95. package/docs/README.md +7 -4
  96. package/docs/analysis.md +13 -0
  97. package/docs/configuration.md +281 -0
  98. package/docs/development.md +24 -2
  99. package/docs/events.md +27 -3
  100. package/docs/examples.md +6 -6
  101. package/docs/focus.md +125 -4
  102. package/docs/gui.md +98 -17
  103. package/docs/helper-expansion.md +8 -8
  104. package/docs/http.md +3 -1
  105. package/docs/jobs.md +19 -13
  106. package/docs/map.md +197 -6
  107. package/docs/mechanics.md +4 -0
  108. package/docs/probability.md +42 -15
  109. package/docs/reference.md +71 -4
  110. package/docs/setup.md +45 -11
  111. package/docs/technology.md +26 -1
  112. package/docs/third-party-notices.md +11 -0
  113. package/docs/tools.md +151 -42
  114. package/docs/troubleshooting.md +61 -0
  115. package/examples/clients/codex-config.toml +5 -2
  116. package/examples/clients/generic.json +2 -2
  117. package/examples/config/config.json +7 -0
  118. package/npm-shrinkwrap.json +161 -162
  119. package/package.json +13 -8
  120. package/schemas/configuration.schema.json +144 -128
  121. package/schemas/continuous-focus-palette.schema.json +1 -1
  122. package/schemas/custom-weighted-pool.schema.json +1 -1
  123. package/schemas/decision-inspect-input.schema.json +1 -1
  124. package/schemas/error-log-input.schema.json +55 -0
  125. package/schemas/event-feature-manifest.schema.json +1 -1
  126. package/schemas/focus-plan.schema.json +1 -1
  127. package/schemas/focus-planning-sidecar.schema.json +1 -1
  128. package/schemas/gui-animation-source.schema.json +1 -1
  129. package/schemas/gui-helper.schema.json +1 -1
  130. package/schemas/gui-scenario.schema.json +1 -1
  131. package/schemas/helper-expansion-request.schema.json +1 -1
  132. package/schemas/helper-expansion-summary.schema.json +1 -1
  133. package/schemas/impact-inspect-input.schema.json +1 -1
  134. package/schemas/map-operation.schema.json +597 -126
  135. package/schemas/mechanic-test-input.schema.json +1 -1
  136. package/schemas/mod-index-input.schema.json +51 -0
  137. package/schemas/operation-result.schema.json +2 -2
  138. package/schemas/package-check-input.schema.json +1 -1
  139. package/schemas/package-manifest.schema.json +1 -1
  140. package/schemas/probability-analysis-result.schema.json +1 -1
  141. package/schemas/probability-compare-input.schema.json +1 -1
  142. package/schemas/probability-evaluate-input.schema.json +1 -1
  143. package/schemas/probability-inspect-input.schema.json +1 -1
  144. package/schemas/probability-render-input.schema.json +1 -1
  145. package/schemas/probability-scenario-set.schema.json +1 -1
  146. package/schemas/probability-sequence-input.schema.json +1 -1
  147. package/schemas/probability-simulate-input.schema.json +1 -1
  148. package/schemas/probability-sweep-input.schema.json +1 -1
  149. package/schemas/reference-context-input.schema.json +1 -1
  150. package/schemas/reference-read-input.schema.json +1 -1
  151. package/schemas/reference-search-input.schema.json +1 -1
  152. package/schemas/scenario-suite.schema.json +3 -5
  153. package/schemas/scenario-test-input.schema.json +3 -5
  154. package/schemas/script-validate-input.schema.json +1 -1
  155. package/schemas/shared-scenario.schema.json +1 -1
  156. package/schemas/source-lookup-input.schema.json +1 -1
  157. package/server.json +3 -3
@@ -16,10 +16,24 @@ export function canonicalize(value) {
16
16
  return value.map(canonicalize);
17
17
  if (typeof value === 'object') {
18
18
  const object = value;
19
- return Object.fromEntries(Object.keys(object)
20
- .sort(compareCodeUnits)
21
- .filter((key) => object[key] !== undefined)
22
- .map((key) => [key, canonicalize(object[key])]));
19
+ // Filling the copy directly avoids an entry array per member; a plain object lists integer
20
+ // keys first and the rest in insertion (here code unit) order, the same as before.
21
+ const copy = {};
22
+ for (const key of Object.keys(object).sort(compareCodeUnits)) {
23
+ const member = object[key];
24
+ if (member === undefined)
25
+ continue;
26
+ if (key === '__proto__')
27
+ Object.defineProperty(copy, key, {
28
+ value: canonicalize(member),
29
+ enumerable: true,
30
+ writable: true,
31
+ configurable: true,
32
+ });
33
+ else
34
+ copy[key] = canonicalize(member);
35
+ }
36
+ return copy;
23
37
  }
24
38
  throw new TypeError(`Canonical JSON cannot contain ${typeof value}`);
25
39
  }
@@ -155,6 +155,11 @@ export const serverConfigurationSchema = z
155
155
  stdioIdleExitMinutes: z.number().int().min(0).max(10_080).default(0),
156
156
  /** V8 heap ceiling for each job worker in MiB; unset keeps Node's default. */
157
157
  jobWorkerMaxHeapMiB: z.number().int().min(256).max(65_536).optional(),
158
+ /**
159
+ * A job worker stays warm this many seconds after a job, so the next job reuses its loaded
160
+ * indexes instead of scanning cold; 0 starts a fresh worker for every job.
161
+ */
162
+ jobWorkerIdleSeconds: z.number().int().min(0).max(3_600).default(180),
158
163
  scanMaxBytes: z
159
164
  .number()
160
165
  .int()
@@ -182,6 +187,8 @@ export const serverConfigurationSchema = z
182
187
  .default(134_217_728),
183
188
  modRoots: z.array(z.string().min(1)).max(WORKSPACE_MAX_SOURCE_ROOTS).default([]),
184
189
  gameRoot: z.string().min(1).optional(),
190
+ /** The game's user-data folder holding logs/ and mod/; derived from the mod folder when unset. */
191
+ gameUserRoot: z.string().min(1).optional(),
185
192
  workspaceStorageRoot: z.string().min(1).optional(),
186
193
  storageRoots: z.array(z.string().min(1)).max(WORKSPACE_MAX_SOURCE_ROOTS).default([]),
187
194
  workspaces: z.array(workspaceRegistrationSchema).max(WORKSPACE_MAX_REGISTRATIONS).default([]),
@@ -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,13 @@ 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';
13
+ /**
14
+ * Completed scans kept per workspace. One tool call often scans several pattern sets (a GUI
15
+ * preview scans layouts, linked definitions and localisation), so a single slot made every
16
+ * repeat call rescan and reindex each set.
17
+ */
18
+ const SCAN_CACHE_ENTRIES_PER_WORKSPACE = 4;
12
19
  const RECOVERY_TTL_SECONDS = 3_600;
13
20
  const RECOVERY_MAX_BYTES = 536_870_912;
14
21
  const RECOVERY_MAX_RECORDS = 128;
@@ -124,8 +131,21 @@ export class CoreEngine {
124
131
  }
125
132
  async initialize() {
126
133
  for (const workspace of this.resolver.list()) {
127
- // Recovery repairs an interrupted internal rewrite before the workspace is exposed.
128
- 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
+ }
129
149
  }
130
150
  }
131
151
  status(workspaceId, principal) {
@@ -154,6 +174,7 @@ export class CoreEngine {
154
174
  }
155
175
  async scan(workspaceId, options = {}, principal, signal) {
156
176
  signal?.throwIfAborted();
177
+ await this.resolver.refreshDescriptors(workspaceId, principal);
157
178
  const workspace = this.resolver.get(workspaceId, principal);
158
179
  const patterns = options.patterns ?? defaultPatterns(workspace);
159
180
  const ignore = options.ignore;
@@ -167,9 +188,6 @@ export class CoreEngine {
167
188
  })}`;
168
189
  let flight = this.#scanFlights.get(requestKey);
169
190
  if (flight === undefined) {
170
- for (const key of this.#scanCache.keys())
171
- if (key.startsWith(`${workspaceId}:`) && !key.startsWith(`${requestKey}:`))
172
- this.#scanCache.delete(key);
173
191
  const controller = new AbortController();
174
192
  const promise = (async () => {
175
193
  return this.withScanAdmission(controller.signal, async () => {
@@ -193,8 +211,11 @@ export class CoreEngine {
193
211
  })));
194
212
  const cacheKey = `${requestKey}:${revision}`;
195
213
  const cached = this.#scanCache.get(cacheKey);
196
- if (cached !== undefined)
214
+ if (cached !== undefined) {
215
+ this.#scanCache.delete(cacheKey);
216
+ this.#scanCache.set(cacheKey, cached);
197
217
  return cached;
218
+ }
198
219
  const persistentAnalysisCache = await this.persistentAnalysisCache;
199
220
  const analysisScope = {
200
221
  workspaceIdentity: workspace.workspaceIdentity,
@@ -202,7 +223,12 @@ export class CoreEngine {
202
223
  principal: principal ?? null,
203
224
  };
204
225
  await persistentAnalysisCache?.hydrate(analysisScope, files, sourceDocuments, this.indexSegments, controller.signal);
205
- const index = await SymbolIndex.buildAsync(files, controller.signal, this.indexSegments);
226
+ // The previous revision of the same request still holds every unchanged file's
227
+ // records, so only edited files are parsed and indexed again.
228
+ const previous = [...this.#scanCache]
229
+ .filter(([key]) => key.startsWith(`${requestKey}:`))
230
+ .at(-1)?.[1].index;
231
+ const index = await SymbolIndex.buildAsync(files, controller.signal, this.indexSegments, previous);
206
232
  await persistentAnalysisCache?.persist(analysisScope, files, sourceDocuments, this.indexSegments, controller.signal);
207
233
  const snapshot = {
208
234
  workspaceId,
@@ -215,11 +241,30 @@ export class CoreEngine {
215
241
  diagnostics: index.diagnostics,
216
242
  };
217
243
  if ((this.#scanGenerations.get(workspaceId) ?? 0) === generation) {
244
+ // Replace this request's earlier revision and drop earlier generations; keep the
245
+ // most recent other requests of this workspace.
218
246
  for (const key of this.#scanCache.keys()) {
219
- if (key.startsWith(`${workspaceId}:`))
247
+ if (key.startsWith(`${requestKey}:`) ||
248
+ (key.startsWith(`${workspaceId}:`) &&
249
+ !key.startsWith(`${workspaceId}:${generation}:`)))
220
250
  this.#scanCache.delete(key);
221
251
  }
222
252
  this.#scanCache.set(cacheKey, snapshot);
253
+ const retained = [...this.#scanCache.keys()].filter((key) => key.startsWith(`${workspaceId}:`));
254
+ for (const key of retained.slice(0, -SCAN_CACHE_ENTRIES_PER_WORKSPACE))
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
+ }
223
268
  }
224
269
  return snapshot;
225
270
  });
@@ -384,7 +429,8 @@ export class CoreEngine {
384
429
  request.run();
385
430
  }
386
431
  }
387
- indexFiles(files) {
388
- return SymbolIndex.build(files, this.indexSegments);
432
+ /** `previous` supplies unchanged files' records from an earlier index of the same sources. */
433
+ indexFiles(files, previous) {
434
+ return SymbolIndex.build(files, this.indexSegments, previous);
389
435
  }
390
436
  }
@@ -1,7 +1,7 @@
1
1
  import { compareCodeUnits } from './canonical.js';
2
2
  import path from 'node:path';
3
3
  import { sortDiagnostics } from './diagnostics.js';
4
- import { indexSegmentAddress } from './index-segments.js';
4
+ import { indexSegmentAddress, } from './index-segments.js';
5
5
  import { parseClausewitz, parseLocalisation, assignments, childBlocks, firstScalar, locationFor, sourcePartialLimitDiagnostics, SOURCE_MAX_BYTES, SOURCE_MAX_NESTING, nodeLocation, } from './source/index.js';
6
6
  const spriteDefinitionKeys = new Set([
7
7
  'spritetype',
@@ -261,6 +261,11 @@ export class SymbolIndex {
261
261
  files = new Map();
262
262
  #active = new Map();
263
263
  #definitionFiles = new Set();
264
+ /**
265
+ * Where each completely indexed file's records sit in this index, by segment address, so a
266
+ * rebuild after an edit reuses unchanged files instead of parsing and indexing them again.
267
+ */
268
+ #fileRanges = new Map();
264
269
  #indexNestingBlocked = new Set();
265
270
  #skippedSourcePaths = new Set();
266
271
  #skippedPossibleSymbolKinds = new Set();
@@ -271,7 +276,7 @@ export class SymbolIndex {
271
276
  #referenceLimitReported = false;
272
277
  #diagnosticsTruncated = false;
273
278
  #indexTableBlocked = new Set();
274
- static build(files, segments) {
279
+ static build(files, segments, previous) {
275
280
  const index = new SymbolIndex();
276
281
  const definitionSelection = selectedDefinitionFiles(files);
277
282
  for (const displayPath of definitionSelection.selected)
@@ -281,13 +286,13 @@ export class SymbolIndex {
281
286
  }
282
287
  for (const file of [...files].sort((a, b) => a.loadOrder - b.loadOrder || compareCodeUnits(a.displayPath, b.displayPath))) {
283
288
  index.files.set(file.displayPath, file);
284
- index.indexFileWithSegments(file, segments);
289
+ index.indexFileWithSegments(file, segments, previous);
285
290
  }
286
291
  index.finalize();
287
292
  return index;
288
293
  }
289
294
  /** Yield between source files so protocol cancellation and progress remain responsive. */
290
- static async buildAsync(files, signal, segments) {
295
+ static async buildAsync(files, signal, segments, previous) {
291
296
  signal?.throwIfAborted();
292
297
  const index = new SymbolIndex();
293
298
  const definitionSelection = selectedDefinitionFiles(files);
@@ -300,7 +305,7 @@ export class SymbolIndex {
300
305
  for (const file of [...files].sort((a, b) => a.loadOrder - b.loadOrder || compareCodeUnits(a.displayPath, b.displayPath))) {
301
306
  signal?.throwIfAborted();
302
307
  index.files.set(file.displayPath, file);
303
- index.indexFileWithSegments(file, segments);
308
+ index.indexFileWithSegments(file, segments, previous);
304
309
  if (performance.now() - yieldedAt >= 20) {
305
310
  await new Promise((resolve) => setImmediate(resolve));
306
311
  signal?.throwIfAborted();
@@ -437,31 +442,50 @@ export class SymbolIndex {
437
442
  for (const diagnostic of diagnostics)
438
443
  this.addDiagnostic(diagnostic);
439
444
  }
440
- indexFileWithSegments(file, cache) {
441
- if (cache === undefined) {
445
+ /** The records one completely indexed file contributed to this index, if it was recorded. */
446
+ #segmentAt(address) {
447
+ const range = this.#fileRanges.get(address);
448
+ if (range === undefined)
449
+ return undefined;
450
+ return {
451
+ symbols: this.symbols.slice(...range.symbols),
452
+ references: this.references.slice(...range.references),
453
+ diagnostics: range.diagnostics,
454
+ complete: true,
455
+ };
456
+ }
457
+ indexFileWithSegments(file, cache, previous) {
458
+ if (cache === undefined && previous === undefined) {
442
459
  this.indexFile(file);
443
460
  return;
444
461
  }
445
462
  const address = indexSegmentAddress(file, this.#definitionFiles.has(file.displayPath));
446
- const cached = cache.get(address);
447
- if (cached?.complete === true &&
448
- this.symbols.length + cached.symbols.length <= INDEX_RECORD_LIMIT &&
449
- this.references.length + cached.references.length <= INDEX_RECORD_LIMIT &&
450
- this.diagnostics.length + cached.diagnostics.length <= INDEX_DIAGNOSTIC_LIMIT) {
463
+ const symbols = this.symbols.length;
464
+ const references = this.references.length;
465
+ const diagnostics = this.diagnostics.length;
466
+ const record = () => this.#fileRanges.set(address, {
467
+ symbols: [symbols, this.symbols.length],
468
+ references: [references, this.references.length],
469
+ diagnostics: this.diagnostics.slice(diagnostics),
470
+ });
471
+ const reused = (previous === undefined ? undefined : previous.#segmentAt(address)) ?? cache?.get(address);
472
+ if (reused?.complete === true &&
473
+ this.symbols.length + reused.symbols.length <= INDEX_RECORD_LIMIT &&
474
+ this.references.length + reused.references.length <= INDEX_RECORD_LIMIT &&
475
+ this.diagnostics.length + reused.diagnostics.length <= INDEX_DIAGNOSTIC_LIMIT) {
451
476
  this.#currentFileShadowed = file.shadowedBy !== undefined;
452
- for (const symbol of cached.symbols)
477
+ for (const symbol of reused.symbols)
453
478
  this.addSymbol(symbol);
454
- for (const reference of cached.references)
479
+ for (const reference of reused.references)
455
480
  this.addReference(reference);
456
- this.addDiagnostics(cached.diagnostics);
481
+ this.addDiagnostics(reused.diagnostics);
482
+ if (!this.#diagnosticsTruncated)
483
+ record();
457
484
  return;
458
485
  }
459
- const symbols = this.symbols.length;
460
- const references = this.references.length;
461
- const diagnostics = this.diagnostics.length;
462
486
  this.indexFile(file);
463
487
  // Incomplete files and aggregate ceilings retain the full builder's exact diagnostic
464
- // ordering and coverage bookkeeping. They are never cached as complete segments.
488
+ // ordering and coverage bookkeeping. They are never reused as complete segments.
465
489
  if (this.#indexTableBlocked.has(file.displayPath) ||
466
490
  this.#skippedSourcePaths.has(file.displayPath) ||
467
491
  this.#indexNestingBlocked.has(file.displayPath) ||
@@ -469,7 +493,8 @@ export class SymbolIndex {
469
493
  this.#referenceLimitReported ||
470
494
  this.#diagnosticsTruncated)
471
495
  return;
472
- cache.put(address, {
496
+ record();
497
+ cache?.put(address, {
473
498
  symbols: this.symbols.slice(symbols),
474
499
  references: this.references.slice(references),
475
500
  diagnostics: this.diagnostics.slice(diagnostics),
@@ -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;