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
@@ -4,7 +4,20 @@ import { ServiceError, MAX_INLINE_ARTIFACT_LINKS } from './result.js';
4
4
  export { MAX_INLINE_ARTIFACT_LINKS } from './result.js';
5
5
  import { diagnosticSchema } from '../schemas/common.js';
6
6
  import { artifactLinkSchema, validationSummarySchema } from '../schemas/transaction.js';
7
- export const MAX_INLINE_FILES_SCANNED = 64;
7
+ // Agents act on the mod's own files; a long inventory of scanned game files is linked evidence.
8
+ export const MAX_INLINE_FILES_SCANNED = 12;
9
+ /**
10
+ * Resource links shown beside the summary. Every artifact stays in `structuredContent.artifacts`,
11
+ * which clients show as well, so a long gallery is not listed twice; images and reports come first.
12
+ */
13
+ export const MAX_CONTENT_RESOURCE_LINKS = 8;
14
+ const linkPriority = (mimeType) => mimeType === 'image/png'
15
+ ? 0
16
+ : mimeType === 'text/html'
17
+ ? 2
18
+ : mimeType === 'image/svg+xml'
19
+ ? 3
20
+ : 1;
8
21
  export const MAX_INLINE_PROPOSED_FILES = 64;
9
22
  export const MAX_INLINE_CHANGED_FILES = 64;
10
23
  export const MAX_INLINE_DIAGNOSTICS = 20;
@@ -73,7 +86,12 @@ export function strictOperationResultSchema(successDataSchema) {
73
86
  }
74
87
  /** Keep source inventories compact; complete inventories belong in linked resources. */
75
88
  export function setInlineFilesScanned(result, files) {
76
- result.filesScanned = files.slice(0, MAX_INLINE_FILES_SCANNED);
89
+ // Mod files first: they are the ones an agent can change.
90
+ const ordered = [
91
+ ...files.filter((file) => file.startsWith('mod:')),
92
+ ...files.filter((file) => !file.startsWith('mod:')),
93
+ ];
94
+ result.filesScanned = ordered.slice(0, MAX_INLINE_FILES_SCANNED);
77
95
  if (files.length <= MAX_INLINE_FILES_SCANNED)
78
96
  return;
79
97
  const truncation = {
@@ -103,6 +121,40 @@ function preserveDeferredFilesDiagnostic(result) {
103
121
  diagnostics: [deferred, ...result.diagnostics].slice(0, MAX_INLINE_DIAGNOSTICS),
104
122
  };
105
123
  }
124
+ /**
125
+ * When a result is too large, keep the first few diagnostics with their code, message and
126
+ * location only; the linked evidence holds the rest.
127
+ */
128
+ function essentialDiagnostics(diagnostics) {
129
+ const kept = [];
130
+ let bytes = 0;
131
+ for (const diagnostic of diagnostics) {
132
+ if (diagnostic.code === 'MCP_RESPONSE_TRUNCATED' || kept.length >= 5)
133
+ continue;
134
+ const essential = {
135
+ code: diagnostic.code.slice(0, 256),
136
+ severity: diagnostic.severity,
137
+ category: diagnostic.category,
138
+ message: diagnostic.message.slice(0, 300),
139
+ ...(diagnostic.location === undefined
140
+ ? {}
141
+ : {
142
+ location: {
143
+ ...diagnostic.location,
144
+ path: diagnostic.location.path.slice(0, 512),
145
+ ...(diagnostic.location.symbol === undefined
146
+ ? {}
147
+ : { symbol: diagnostic.location.symbol.slice(0, 128) }),
148
+ },
149
+ }),
150
+ };
151
+ bytes += Buffer.byteLength(JSON.stringify(essential), 'utf8');
152
+ if (bytes > 6_000)
153
+ break;
154
+ kept.push(essential);
155
+ }
156
+ return kept;
157
+ }
106
158
  function boundedResult(result, actualBytes) {
107
159
  const hasLinkedEvidence = result.artifacts.length > 0;
108
160
  const truncationDiagnostic = {
@@ -124,7 +176,7 @@ function boundedResult(result, actualBytes) {
124
176
  filesScanned: [],
125
177
  proposedFiles: [],
126
178
  changedFiles: [],
127
- diagnostics: [truncationDiagnostic],
179
+ diagnostics: [truncationDiagnostic, ...essentialDiagnostics(result.diagnostics)],
128
180
  artifacts: result.artifacts
129
181
  .slice(0, 1)
130
182
  .map(({ description: _description, ...artifact }) => artifact),
@@ -225,10 +277,28 @@ function jsonBytesWithin(value, budget) {
225
277
  };
226
278
  return visit(value);
227
279
  }
280
+ /** The most frequent diagnostic codes, so an omitted flood is still visible by kind. */
281
+ function diagnosticCodeCounts(diagnostics) {
282
+ const counts = new Map();
283
+ for (const { code } of diagnostics)
284
+ counts.set(code, (counts.get(code) ?? 0) + 1);
285
+ return Object.fromEntries([...counts]
286
+ .sort(([leftCode, left], [rightCode, right]) => right - left || (leftCode < rightCode ? -1 : leftCode > rightCode ? 1 : 0))
287
+ .slice(0, 12)
288
+ .map(([code, count]) => [code.slice(0, 256), count]));
289
+ }
228
290
  function compactDetails(details) {
229
291
  return jsonBytesWithin(details, 2_048) ? details : { truncated: true };
230
292
  }
231
- function compactDescriptions(result) {
293
+ /** A passed check that only says its problem was not found carries nothing to act on. */
294
+ function withoutCleanChecks(result) {
295
+ const checks = result.validation.checks.filter((check) => !(check.passed && check.message.endsWith('not detected.')));
296
+ return checks.length === result.validation.checks.length
297
+ ? result
298
+ : { ...result, validation: { ...result.validation, checks } };
299
+ }
300
+ function compactDescriptions(unfiltered) {
301
+ const result = withoutCleanChecks(unfiltered);
232
302
  const compactLocation = (location) => location === undefined
233
303
  ? undefined
234
304
  : {
@@ -268,8 +338,13 @@ function compactDescriptions(result) {
268
338
  const needsTruncationDiagnostic = Object.keys(truncatedCollections).length > 0;
269
339
  const diagnosticSlots = Math.max(0, MAX_INLINE_DIAGNOSTICS - (needsTruncationDiagnostic ? 1 : 0));
270
340
  const hardDiagnostics = result.diagnostics.filter(({ severity }) => severity === 'error' || severity === 'blocker');
271
- const priorityDiagnostics = result.diagnostics.filter(({ code, severity }) => severity === 'error' || severity === 'blocker' || code === 'MCP_INLINE_FILES_TRUNCATED');
272
- const otherDiagnostics = result.diagnostics.filter((diagnostic) => !priorityDiagnostics.includes(diagnostic));
341
+ // Within each class, diagnostics in the mod's own files come before game or dependency ones.
342
+ const modFirst = (diagnostics) => [
343
+ ...diagnostics.filter(({ location }) => location?.path.startsWith('mod:') === true),
344
+ ...diagnostics.filter(({ location }) => location?.path.startsWith('mod:') !== true),
345
+ ];
346
+ const priorityDiagnostics = modFirst(result.diagnostics.filter(({ code, severity }) => severity === 'error' || severity === 'blocker' || code === 'MCP_INLINE_FILES_TRUNCATED'));
347
+ const otherDiagnostics = modFirst(result.diagnostics.filter((diagnostic) => !priorityDiagnostics.includes(diagnostic)));
273
348
  const retainedPriorityDiagnostics = priorityDiagnostics.slice(0, diagnosticSlots);
274
349
  const retainedDiagnostics = [
275
350
  ...retainedPriorityDiagnostics,
@@ -305,6 +380,9 @@ function compactDescriptions(result) {
305
380
  collections: truncatedCollections,
306
381
  diagnosticsReturned: retainedDiagnostics.length,
307
382
  omittedHardDiagnostics,
383
+ ...(result.diagnostics.length > retainedDiagnostics.length
384
+ ? { codeCounts: diagnosticCodeCounts(result.diagnostics) }
385
+ : {}),
308
386
  },
309
387
  }
310
388
  : undefined;
@@ -340,7 +418,13 @@ function compactDescriptions(result) {
340
418
  }
341
419
  function buildToolResult(selected) {
342
420
  const structuredContent = operationResultSchema.parse(selected);
343
- const links = selected.artifacts.map((artifact) => ({
421
+ const shown = selected.artifacts
422
+ .map((artifact, order) => ({ artifact, order }))
423
+ .sort((left, right) => linkPriority(left.artifact.mimeType) - linkPriority(right.artifact.mimeType) ||
424
+ left.order - right.order)
425
+ .slice(0, MAX_CONTENT_RESOURCE_LINKS)
426
+ .map(({ artifact }) => artifact);
427
+ const links = shown.map((artifact) => ({
344
428
  type: 'resource_link',
345
429
  uri: artifact.uri,
346
430
  name: artifact.name,
@@ -348,11 +432,27 @@ function buildToolResult(selected) {
348
432
  ...(artifact.description === undefined ? {} : { description: artifact.description }),
349
433
  ...(artifact.size === undefined ? {} : { size: artifact.size }),
350
434
  }));
435
+ // Clients show an error's text, not its structured content, so an error or a blocked
436
+ // result carries what went wrong and how to proceed in the text as well.
351
437
  const summary = canonicalJson({
352
438
  status: selected.status,
353
439
  code: selected.code,
354
440
  workspaceId: selected.workspaceId,
355
441
  artifactCount: selected.artifacts.length,
442
+ ...(selected.artifacts.length > shown.length
443
+ ? { artifactsListedInStructuredContent: selected.artifacts.length }
444
+ : {}),
445
+ ...(selected.status === 'ok' || selected.blockers.length === 0
446
+ ? {}
447
+ : {
448
+ blockers: selected.blockers.slice(0, 3).map(({ code, message, details }) => ({
449
+ code,
450
+ message,
451
+ ...(details === undefined || Object.keys(details).length === 0
452
+ ? {}
453
+ : { details: compactDetails(details) }),
454
+ })),
455
+ }),
356
456
  });
357
457
  return {
358
458
  content: [{ type: 'text', text: summary }, ...links],
@@ -378,10 +478,23 @@ export function toolResult(result) {
378
478
  }
379
479
  return output;
380
480
  }
481
+ /** Paths in a message can name private folders; the file name is enough to act on. */
482
+ function withoutDirectories(message) {
483
+ return message
484
+ .replace(/(?:[A-Za-z]:)?[\\/](?:[^\s'"`:\\/]+[\\/])+([^\s'"`:\\/]+)/gu, '$1')
485
+ .slice(0, 300);
486
+ }
487
+ /**
488
+ * An unexpected failure is a server defect. Name the error kind and its path-free message so
489
+ * the caller can tell a malformed source from a bug, and say what to do next.
490
+ */
491
+ export function internalServiceError(error) {
492
+ const kind = error instanceof Error ? error.name : typeof error;
493
+ const message = error instanceof Error ? withoutDirectories(error.message) : '';
494
+ return new ServiceError('INTERNAL_ERROR', `Unexpected ${kind}${message.length > 0 ? `: ${message}` : ''}. Retry once with a narrower selector; if it repeats, report this message with the tool name and arguments.`);
495
+ }
381
496
  export function errorResult(error, workspaceId = '', context = {}) {
382
- const serviceError = error instanceof ServiceError
383
- ? error
384
- : new ServiceError('INTERNAL_ERROR', 'Unexpected internal error');
497
+ const serviceError = error instanceof ServiceError ? error : internalServiceError(error);
385
498
  const internalWriteError = serviceError.code.startsWith('TRANSACTION_');
386
499
  const publicCode = internalWriteError
387
500
  ? `REWRITE_${serviceError.code.slice('TRANSACTION_'.length)}`
@@ -1,12 +1,6 @@
1
1
  import { z } from 'zod/v4';
2
- import { decisionInspectRequestSchema, impactInspectRequestSchema } from '../schemas/analysis.js';
3
- import { mechanicTestRequestSchema, packageCheckRequestSchema, scenarioTestRequestSchema, } from '../schemas/scenarios.js';
4
- import { eventCompareRequestSchema, eventInspectRequestSchema, eventRenderRequestSchema, } from '../schemas/event.js';
5
- import { focusInspectRequestSchema, focusRenderRequestSchema, focusRewriteRequestSchema, } from '../schemas/focus-requests.js';
6
- import { guiInspectRequestSchema, guiRenderRequestSchema, guiRewriteRequestSchema, } from '../schemas/gui-requests.js';
7
- import { mapInspectRequestSchema, mapRenderRequestSchema, mapRewriteRequestSchema, } from '../schemas/map-requests.js';
8
- import { probabilityCompareRequestSchema, probabilityEvaluateRequestSchema, probabilityInspectRequestSchema, probabilityRenderRequestSchema, probabilitySequenceRequestSchema, probabilitySimulateRequestSchema, probabilitySweepRequestSchema, } from '../schemas/probability-requests.js';
9
- import { technologyCompareRequestSchema, technologyInspectRequestSchema, technologyRenderRequestSchema, } from '../schemas/technology.js';
2
+ import { DOMAIN_TOOL_ARGUMENT_SCHEMAS, DOMAIN_TOOL_NAMES, MUTATION_TOOL_NAMES, } from './domain-tools.js';
3
+ import { normalizeToolArguments } from '../schemas/lenient-arguments.js';
10
4
  import { currentJobOwner, jobOwnerLiveness, jobRetentionExpired, } from './job-store.js';
11
5
  import { JobService } from './job-service.js';
12
6
  import { JobWorkerHost, workerFailure } from './job-worker-host.js';
@@ -26,78 +20,18 @@ const authenticatedTaskIdSchema = z
26
20
  .regex(/^[a-z][a-z0-9_-]{0,63}:job_[a-f0-9]{64}\.[a-f0-9]{64}$/u);
27
21
  export const operationTaskCallSchema = z
28
22
  .object({
29
- name: z.enum([
30
- 'hoi4.impact_inspect',
31
- 'hoi4.decision_inspect',
32
- 'hoi4.mechanic_test',
33
- 'hoi4.package_check',
34
- 'hoi4.scenario_test',
35
- 'hoi4.event_inspect',
36
- 'hoi4.event_render',
37
- 'hoi4.event_compare',
38
- 'hoi4.tech_inspect',
39
- 'hoi4.tech_render',
40
- 'hoi4.tech_compare',
41
- 'hoi4.probability_inspect',
42
- 'hoi4.probability_evaluate',
43
- 'hoi4.probability_sweep',
44
- 'hoi4.probability_simulate',
45
- 'hoi4.probability_sequence',
46
- 'hoi4.probability_compare',
47
- 'hoi4.probability_render',
48
- 'hoi4.map_inspect',
49
- 'hoi4.map_render',
50
- 'hoi4.map_rewrite',
51
- 'hoi4.gui_inspect',
52
- 'hoi4.gui_render',
53
- 'hoi4.gui_rewrite',
54
- 'hoi4.focus_inspect',
55
- 'hoi4.focus_render',
56
- 'hoi4.focus_raster',
57
- 'hoi4.focus_rewrite',
58
- ]),
23
+ name: z.enum(DOMAIN_TOOL_NAMES),
59
24
  arguments: z.record(z.string(), z.json()),
60
25
  })
61
26
  .strict();
62
- const taskArgumentSchemas = {
63
- 'hoi4.impact_inspect': impactInspectRequestSchema,
64
- 'hoi4.decision_inspect': decisionInspectRequestSchema,
65
- 'hoi4.mechanic_test': mechanicTestRequestSchema,
66
- 'hoi4.package_check': packageCheckRequestSchema,
67
- 'hoi4.scenario_test': scenarioTestRequestSchema,
68
- 'hoi4.event_inspect': eventInspectRequestSchema,
69
- 'hoi4.event_render': eventRenderRequestSchema,
70
- 'hoi4.event_compare': eventCompareRequestSchema,
71
- 'hoi4.tech_inspect': technologyInspectRequestSchema,
72
- 'hoi4.tech_render': technologyRenderRequestSchema,
73
- 'hoi4.tech_compare': technologyCompareRequestSchema,
74
- 'hoi4.probability_inspect': probabilityInspectRequestSchema,
75
- 'hoi4.probability_evaluate': probabilityEvaluateRequestSchema,
76
- 'hoi4.probability_sweep': probabilitySweepRequestSchema,
77
- 'hoi4.probability_simulate': probabilitySimulateRequestSchema,
78
- 'hoi4.probability_sequence': probabilitySequenceRequestSchema,
79
- 'hoi4.probability_compare': probabilityCompareRequestSchema,
80
- 'hoi4.probability_render': probabilityRenderRequestSchema,
81
- 'hoi4.map_inspect': mapInspectRequestSchema,
82
- 'hoi4.map_render': mapRenderRequestSchema,
83
- 'hoi4.map_rewrite': mapRewriteRequestSchema,
84
- 'hoi4.gui_inspect': guiInspectRequestSchema,
85
- 'hoi4.gui_render': guiRenderRequestSchema,
86
- 'hoi4.gui_rewrite': guiRewriteRequestSchema,
87
- 'hoi4.focus_inspect': focusInspectRequestSchema,
88
- 'hoi4.focus_render': focusRenderRequestSchema,
89
- 'hoi4.focus_raster': focusRenderRequestSchema,
90
- 'hoi4.focus_rewrite': focusRewriteRequestSchema,
91
- };
27
+ const taskArgumentSchemas = DOMAIN_TOOL_ARGUMENT_SCHEMAS;
92
28
  const sourceResolvingTools = new Set([
93
29
  'hoi4.focus_inspect',
94
30
  'hoi4.focus_render',
95
- 'hoi4.focus_raster',
96
31
  'hoi4.focus_rewrite',
97
32
  ]);
98
- const mutationTools = new Set(['hoi4.gui_rewrite', 'hoi4.map_rewrite', 'hoi4.focus_rewrite']);
99
33
  export function isMutationTask(name) {
100
- return mutationTools.has(name);
34
+ return MUTATION_TOOL_NAMES.has(name);
101
35
  }
102
36
  function taskRetention(ttl) {
103
37
  if (ttl === undefined || ttl === null)
@@ -133,26 +67,43 @@ export function operationTaskMessage(record) {
133
67
  return 'Cancellation requested';
134
68
  return undefined;
135
69
  }
136
- /** Protocol-independent task execution, ownership, retention, and recovery over durable jobs. */
70
+ const engineTaskStates = new WeakMap();
71
+ const REJECTED_TASK_RETENTION_MS = 300_000;
72
+ /** Let an engine's idle warm workers exit, for example when its last client disconnects. */
73
+ export async function releaseIdleWorkers(engine) {
74
+ const worker = engineTaskStates.get(engine)?.worker;
75
+ if (worker === undefined)
76
+ return;
77
+ (await worker.catch(() => undefined))?.releaseIdleWorkers();
78
+ }
137
79
  export class OperationTaskService {
138
80
  engine;
139
- jobsPromise;
140
- workerPromise;
141
- launches = new Map();
81
+ state;
142
82
  constructor(engine) {
143
83
  this.engine = engine;
84
+ const state = engineTaskStates.get(engine) ?? { launches: new Map() };
85
+ engineTaskStates.set(engine, state);
86
+ this.state = state;
87
+ }
88
+ get launches() {
89
+ return this.state.launches;
144
90
  }
145
91
  jobs() {
146
- this.jobsPromise ??= JobService.create(this.engine.resolver);
147
- return this.jobsPromise;
92
+ // The first use per engine also sweeps results nobody will read again.
93
+ this.state.jobs ??= JobService.create(this.engine.resolver).then(async (jobs) => {
94
+ await jobs.store.sweepExpired().catch(() => 0);
95
+ return jobs;
96
+ });
97
+ return this.state.jobs;
148
98
  }
149
99
  worker() {
150
- this.workerPromise ??= this.jobs().then((jobs) => JobWorkerHost.create(this.engine, jobs));
151
- return this.workerPromise;
100
+ this.state.worker ??= this.jobs().then((jobs) => JobWorkerHost.create(this.engine, jobs));
101
+ return this.state.worker;
152
102
  }
153
103
  async submit(input, context, options) {
154
104
  const parsed = operationTaskCallSchema.parse(input);
155
- const validatedArguments = taskArgumentSchemas[parsed.name].parse(parsed.arguments);
105
+ // The same shorthand normalization as the transport, for callers that submit directly.
106
+ const validatedArguments = taskArgumentSchemas[parsed.name].parse(normalizeToolArguments(parsed.name, parsed.arguments));
156
107
  const requestSignal = options.signal;
157
108
  const mutation = isMutationTask(parsed.name);
158
109
  const explicitTask = options.background;
@@ -201,16 +152,24 @@ export class OperationTaskService {
201
152
  pollInterval: TASK_POLL_INTERVAL,
202
153
  },
203
154
  }, context.principal, requestSignal);
155
+ let cancel;
204
156
  if (!explicitTask && requestSignal !== undefined) {
205
157
  if (requestSignal.aborted) {
206
158
  return jobs.cancel(workspaceId, record.id, context.principal);
207
159
  }
208
- const cancel = () => {
160
+ cancel = () => {
209
161
  void jobs.cancel(workspaceId, record.id, context.principal).catch(() => undefined);
210
162
  };
211
163
  requestSignal.addEventListener('abort', cancel, { once: true });
212
164
  }
213
- await this.ensureExecution(record, context.principal);
165
+ this.ensureExecution(record, context.principal);
166
+ // A finished job needs no cancellation; release the listener with its execution.
167
+ if (cancel !== undefined && requestSignal !== undefined) {
168
+ const listener = cancel;
169
+ const release = () => requestSignal.removeEventListener('abort', listener);
170
+ // Release on rejection too; the launch handles its own failure where it is created.
171
+ void this.launches.get(operationTaskId(record))?.then(release, release);
172
+ }
214
173
  return record;
215
174
  }
216
175
  async get(value, context, options = {}) {
@@ -237,7 +196,7 @@ export class OperationTaskService {
237
196
  // Verify before resuming: a malformed or guessed reference must not admit execution.
238
197
  return this.resume(record, context, options);
239
198
  }
240
- async resume(record, context, options) {
199
+ resume(record, context, options) {
241
200
  if (record?.request.protocolTask === undefined)
242
201
  return null;
243
202
  if (options.resume === false)
@@ -247,7 +206,7 @@ export class OperationTaskService {
247
206
  context.scopes !== undefined &&
248
207
  !context.scopes.includes('hoi4:write'))
249
208
  return record;
250
- await this.ensureExecution(record, context.principal);
209
+ this.ensureExecution(record, context.principal);
251
210
  return record;
252
211
  }
253
212
  async assertPublishedResult(value, status, result, context) {
@@ -351,7 +310,8 @@ export class OperationTaskService {
351
310
  mutation: false,
352
311
  requestKey: `rejected:${secureId('request')}`,
353
312
  protocolTask: {
354
- ttl: taskRetention(options.ttlMs),
313
+ // A refusal is read once; it need not outlive a short retry window.
314
+ ttl: Math.min(taskRetention(options.ttlMs), REJECTED_TASK_RETENTION_MS),
355
315
  pollInterval: TASK_POLL_INTERVAL,
356
316
  },
357
317
  }, context.principal);
@@ -363,7 +323,7 @@ export class OperationTaskService {
363
323
  });
364
324
  return completed;
365
325
  }
366
- async ensureExecution(record, principal) {
326
+ ensureExecution(record, principal) {
367
327
  if (['completed', 'failed', 'cancelled'].includes(record.status))
368
328
  return;
369
329
  if (record.status !== 'queued' &&
@@ -372,9 +332,9 @@ export class OperationTaskService {
372
332
  const key = operationTaskId(record);
373
333
  if (this.launches.has(key))
374
334
  return;
375
- const worker = await this.worker();
376
- const launched = worker
377
- .run(record.scope.workspaceId, record.id, principal)
335
+ // Registered before any await, so a concurrent poll cannot start a second launcher.
336
+ const launched = this.worker()
337
+ .then((worker) => worker.run(record.scope.workspaceId, record.id, principal))
378
338
  .then(() => undefined)
379
339
  .catch(async (error) => {
380
340
  const jobs = await this.jobs();
@@ -11,7 +11,7 @@ const digestSchema = z.string().regex(/^[a-f0-9]{64}$/u);
11
11
  const entryKindSchema = z.enum(['source-document', 'index-segment']);
12
12
  const unsignedEntrySchema = z
13
13
  .object({
14
- version: z.literal(1),
14
+ version: z.literal(2),
15
15
  kind: entryKindSchema,
16
16
  scopeHash: digestSchema,
17
17
  address: digestSchema,
@@ -23,6 +23,15 @@ const unsignedEntrySchema = z
23
23
  .strict();
24
24
  const entrySchema = unsignedEntrySchema.extend({ authenticationTag: digestSchema }).strict();
25
25
  const entryFilePattern = /^(?<address>[a-f0-9]{64})\.json$/u;
26
+ /**
27
+ * What the authentication tag covers: every field but the payload bytes, which the tag binds
28
+ * through their SHA-256. A reader hashes the payload once and compares it, so the megabytes of
29
+ * a parsed document do not pass through canonical JSON and the HMAC as well.
30
+ */
31
+ function entryIdentity(entry) {
32
+ const { payload: _payload, ...identity } = entry;
33
+ return { kind: 'analysis-cache-entry.v2', entry: identity };
34
+ }
26
35
  function sourceAddress(key) {
27
36
  return hashCanonical({
28
37
  version: 'persistent-source-document.v1',
@@ -114,10 +123,7 @@ export class PersistentAnalysisCache {
114
123
  const payload = await this.read(scopeHash, 'source-document', documentAddress, signal);
115
124
  if (payload !== undefined) {
116
125
  try {
117
- documents.importEncoded(key, payload, {
118
- bytes: file.bytes,
119
- sourcePath: file.displayPath,
120
- });
126
+ documents.importEncoded(key, payload);
121
127
  }
122
128
  catch {
123
129
  this.#invalidEntries += 1;
@@ -172,22 +178,23 @@ export class PersistentAnalysisCache {
172
178
  }
173
179
  const key = sourceDocumentCacheKey(file.bytes, file.displayPath);
174
180
  const documentAddress = sourceAddress(key);
175
- const document = documents.exportEncoded(key);
176
- if (document !== undefined &&
177
- (!sourceEntries.has(documentAddress) ||
178
- this.#invalidAddresses.has(`${scopeHash}\0source-document\0${documentAddress}`))) {
179
- if (await retain('source-document', documentAddress, document)) {
181
+ // Stored entries are skipped before their payload is exported, which copies it.
182
+ if (!sourceEntries.has(documentAddress) ||
183
+ this.#invalidAddresses.has(`${scopeHash}\0source-document\0${documentAddress}`)) {
184
+ const document = documents.exportEncoded(key);
185
+ if (document !== undefined &&
186
+ (await retain('source-document', documentAddress, document))) {
180
187
  sourceEntries.add(documentAddress);
181
188
  }
182
189
  }
183
190
  for (const provinceDefinition of [false, true]) {
184
191
  const address = indexSegmentAddress(file, provinceDefinition);
185
- const segment = segments.exportEncoded(address);
186
- if (segment === undefined ||
187
- (indexEntries.has(address) &&
188
- !this.#invalidAddresses.has(`${scopeHash}\0index-segment\0${address}`)))
192
+ if (indexEntries.has(address) &&
193
+ !this.#invalidAddresses.has(`${scopeHash}\0index-segment\0${address}`))
189
194
  continue;
190
- if (await retain('index-segment', address, Buffer.from(segment))) {
195
+ const segment = segments.exportEncoded(address);
196
+ if (segment !== undefined &&
197
+ (await retain('index-segment', address, Buffer.from(segment)))) {
191
198
  indexEntries.add(address);
192
199
  }
193
200
  }
@@ -254,7 +261,7 @@ export class PersistentAnalysisCache {
254
261
  if (entry.scopeHash !== scopeHash ||
255
262
  entry.kind !== kind ||
256
263
  entry.address !== address ||
257
- !this.state.verifyJournal({ kind: 'analysis-cache-entry.v1', entry }, authenticationTag))
264
+ !this.state.verifyJournal(entryIdentity(entry), authenticationTag))
258
265
  throw new Error('Persistent analysis cache authentication or identity is invalid');
259
266
  const payload = Buffer.from(entry.payload, 'base64');
260
267
  if (payload.length > this.maxSingleBytes || sha256Bytes(payload) !== entry.payloadHash)
@@ -289,7 +296,7 @@ export class PersistentAnalysisCache {
289
296
  const target = await containedGeneratedPath(directory, `${digestSchema.parse(address)}.json`);
290
297
  const invalidAddress = `${scopeHash}\0${kind}\0${address}`;
291
298
  const unsigned = {
292
- version: 1,
299
+ version: 2,
293
300
  kind,
294
301
  scopeHash,
295
302
  address,
@@ -300,10 +307,7 @@ export class PersistentAnalysisCache {
300
307
  };
301
308
  const bytes = Buffer.from(canonicalJson({
302
309
  ...unsigned,
303
- authenticationTag: this.state.authenticateJournal({
304
- kind: 'analysis-cache-entry.v1',
305
- entry: unsigned,
306
- }),
310
+ authenticationTag: this.state.authenticateJournal(entryIdentity(unsigned)),
307
311
  }));
308
312
  if (bytes.length > Math.ceil((this.maxSingleBytes * 4) / 3) + 4096) {
309
313
  this.#oversized += 1;