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.
- package/CHANGELOG.md +109 -0
- package/README.md +115 -147
- package/dist/bin/http.js +10 -0
- package/dist/bin/setup.js +3 -2
- package/dist/bin/stdio.js +10 -0
- package/dist/hoi4_agent_tools/core/artifacts.js +40 -5
- package/dist/hoi4_agent_tools/core/canonical.js +18 -4
- package/dist/hoi4_agent_tools/core/configuration.js +7 -0
- package/dist/hoi4_agent_tools/core/domain-scan-patterns.js +12 -0
- package/dist/hoi4_agent_tools/core/domain-tools.js +65 -0
- package/dist/hoi4_agent_tools/core/domain-validation.js +5 -2
- package/dist/hoi4_agent_tools/core/engine.js +29 -2
- package/dist/hoi4_agent_tools/core/index.js +2 -2
- package/dist/hoi4_agent_tools/core/job-executor.js +14 -1
- package/dist/hoi4_agent_tools/core/job-operation-registration.js +15 -7
- package/dist/hoi4_agent_tools/core/job-store.js +78 -5
- package/dist/hoi4_agent_tools/core/job-transactions.js +31 -2
- package/dist/hoi4_agent_tools/core/job-worker-host.js +215 -116
- package/dist/hoi4_agent_tools/core/job-worker.js +90 -36
- package/dist/hoi4_agent_tools/core/operation-result.js +123 -10
- package/dist/hoi4_agent_tools/core/operation-tasks.js +49 -89
- package/dist/hoi4_agent_tools/core/persistent-analysis-cache.js +25 -21
- package/dist/hoi4_agent_tools/core/server-registry.js +22 -5
- package/dist/hoi4_agent_tools/core/shared-request-capacity.js +26 -1
- package/dist/hoi4_agent_tools/core/source/cache.js +20 -10
- package/dist/hoi4_agent_tools/core/source/lexer.js +11 -3
- package/dist/hoi4_agent_tools/core/transactions.js +114 -23
- package/dist/hoi4_agent_tools/core/workspace.js +84 -8
- package/dist/hoi4_agent_tools/decision/ai.js +1 -1
- package/dist/hoi4_agent_tools/decision/service.js +28 -0
- package/dist/hoi4_agent_tools/decision/source-inventory.js +7 -5
- package/dist/hoi4_agent_tools/event/job-operations.js +9 -5
- package/dist/hoi4_agent_tools/event/operations.js +14 -1
- package/dist/hoi4_agent_tools/focus/job-operations.js +1 -2
- package/dist/hoi4_agent_tools/focus/tool-operations.js +1 -1
- package/dist/hoi4_agent_tools/gui/validators.js +8 -6
- package/dist/hoi4_agent_tools/map/bmp.js +1 -1
- package/dist/hoi4_agent_tools/map/derived.js +748 -0
- package/dist/hoi4_agent_tools/map/job-operations.js +7 -3
- package/dist/hoi4_agent_tools/map/model.js +103 -0
- package/dist/hoi4_agent_tools/map/operations.js +427 -58
- package/dist/hoi4_agent_tools/map/references.js +483 -0
- package/dist/hoi4_agent_tools/map/service.js +136 -61
- package/dist/hoi4_agent_tools/map/tool-operations.js +2 -1
- package/dist/hoi4_agent_tools/map/validation.js +3 -0
- package/dist/hoi4_agent_tools/map/world.js +1280 -0
- package/dist/hoi4_agent_tools/mcp/server/argument-errors.js +72 -0
- package/dist/hoi4_agent_tools/mcp/server/create-modern-operations.js +23 -5
- package/dist/hoi4_agent_tools/mcp/server/create.js +7 -0
- package/dist/hoi4_agent_tools/mcp/server/request-lifecycle.js +24 -30
- package/dist/hoi4_agent_tools/mcp/server/tool-listing.js +45 -0
- package/dist/hoi4_agent_tools/mcp/tools/analysis.js +25 -5
- package/dist/hoi4_agent_tools/mcp/tools/event.js +18 -29
- package/dist/hoi4_agent_tools/mcp/tools/focus.js +4 -12
- package/dist/hoi4_agent_tools/mcp/tools/gui.js +3 -3
- package/dist/hoi4_agent_tools/mcp/tools/job.js +2 -2
- package/dist/hoi4_agent_tools/mcp/tools/map.js +13 -16
- package/dist/hoi4_agent_tools/mcp/tools/probability.js +42 -48
- package/dist/hoi4_agent_tools/mcp/tools/reference.js +22 -5
- package/dist/hoi4_agent_tools/mcp/tools/technology.js +16 -29
- package/dist/hoi4_agent_tools/mcp/transports/http.js +28 -4
- package/dist/hoi4_agent_tools/mcp/transports/stdio-lifetime.js +31 -8
- package/dist/hoi4_agent_tools/probability/job-operations.js +4 -9
- package/dist/hoi4_agent_tools/probability/operations.js +175 -13
- package/dist/hoi4_agent_tools/probability/source-analysis.js +24 -1
- package/dist/hoi4_agent_tools/reference/error-log.js +359 -0
- package/dist/hoi4_agent_tools/reference/known-mistakes.js +307 -0
- package/dist/hoi4_agent_tools/reference/known-mistakes.json +154 -0
- package/dist/hoi4_agent_tools/reference/mod-index.js +101 -0
- package/dist/hoi4_agent_tools/reference/operations.js +88 -19
- package/dist/hoi4_agent_tools/reference/player-text.js +4 -3
- package/dist/hoi4_agent_tools/reference/script-validation.js +19 -1
- package/dist/hoi4_agent_tools/reference/service.js +6 -0
- package/dist/hoi4_agent_tools/schemas/event.js +78 -19
- package/dist/hoi4_agent_tools/schemas/focus-requests.js +10 -0
- package/dist/hoi4_agent_tools/schemas/lenient-arguments.js +266 -0
- package/dist/hoi4_agent_tools/schemas/map-requests.js +63 -20
- package/dist/hoi4_agent_tools/schemas/map.js +122 -3
- package/dist/hoi4_agent_tools/schemas/mod-tools.js +181 -0
- package/dist/hoi4_agent_tools/schemas/probability-requests.js +119 -9
- package/dist/hoi4_agent_tools/schemas/reference.js +13 -0
- package/dist/hoi4_agent_tools/schemas/scenarios.js +1 -3
- package/dist/hoi4_agent_tools/schemas/script-validation.js +13 -6
- package/dist/hoi4_agent_tools/schemas/technology.js +79 -21
- package/dist/hoi4_agent_tools/technology/job-operations.js +3 -4
- package/dist/hoi4_agent_tools/technology/operations.js +14 -1
- package/dist/hoi4_agent_tools/version.js +1 -1
- package/docs/README.md +7 -4
- package/docs/analysis.md +13 -0
- package/docs/configuration.md +281 -0
- package/docs/development.md +24 -2
- package/docs/events.md +27 -3
- package/docs/examples.md +6 -6
- package/docs/focus.md +125 -4
- package/docs/gui.md +98 -17
- package/docs/helper-expansion.md +8 -8
- package/docs/http.md +3 -1
- package/docs/jobs.md +19 -13
- package/docs/map.md +197 -6
- package/docs/mechanics.md +4 -0
- package/docs/probability.md +42 -15
- package/docs/reference.md +71 -4
- package/docs/setup.md +44 -11
- package/docs/technology.md +26 -1
- package/docs/third-party-notices.md +11 -0
- package/docs/tools.md +151 -42
- package/docs/troubleshooting.md +61 -0
- package/examples/clients/codex-config.toml +5 -2
- package/examples/clients/generic.json +2 -2
- package/examples/config/config.json +7 -0
- package/npm-shrinkwrap.json +151 -156
- package/package.json +13 -8
- package/schemas/configuration.schema.json +144 -128
- package/schemas/continuous-focus-palette.schema.json +1 -1
- package/schemas/custom-weighted-pool.schema.json +1 -1
- package/schemas/decision-inspect-input.schema.json +1 -1
- package/schemas/error-log-input.schema.json +55 -0
- package/schemas/event-feature-manifest.schema.json +1 -1
- package/schemas/focus-plan.schema.json +1 -1
- package/schemas/focus-planning-sidecar.schema.json +1 -1
- package/schemas/gui-animation-source.schema.json +1 -1
- package/schemas/gui-helper.schema.json +1 -1
- package/schemas/gui-scenario.schema.json +1 -1
- package/schemas/helper-expansion-request.schema.json +1 -1
- package/schemas/helper-expansion-summary.schema.json +1 -1
- package/schemas/impact-inspect-input.schema.json +1 -1
- package/schemas/map-operation.schema.json +597 -126
- package/schemas/mechanic-test-input.schema.json +1 -1
- package/schemas/mod-index-input.schema.json +51 -0
- package/schemas/operation-result.schema.json +2 -2
- package/schemas/package-check-input.schema.json +1 -1
- package/schemas/package-manifest.schema.json +1 -1
- package/schemas/probability-analysis-result.schema.json +1 -1
- package/schemas/probability-compare-input.schema.json +1 -1
- package/schemas/probability-evaluate-input.schema.json +1 -1
- package/schemas/probability-inspect-input.schema.json +1 -1
- package/schemas/probability-render-input.schema.json +1 -1
- package/schemas/probability-scenario-set.schema.json +1 -1
- package/schemas/probability-sequence-input.schema.json +1 -1
- package/schemas/probability-simulate-input.schema.json +1 -1
- package/schemas/probability-sweep-input.schema.json +1 -1
- package/schemas/reference-context-input.schema.json +1 -1
- package/schemas/reference-read-input.schema.json +1 -1
- package/schemas/reference-search-input.schema.json +1 -1
- package/schemas/scenario-suite.schema.json +3 -5
- package/schemas/scenario-test-input.schema.json +3 -5
- package/schemas/script-validate-input.schema.json +1 -1
- package/schemas/shared-scenario.schema.json +1 -1
- package/schemas/source-lookup-input.schema.json +1 -1
- package/server.json +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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
272
|
-
const
|
|
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
|
|
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 {
|
|
3
|
-
import {
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
147
|
-
|
|
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.
|
|
151
|
-
return this.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
176
|
-
if (
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
if (
|
|
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
|
-
|
|
186
|
-
|
|
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
|
-
|
|
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(
|
|
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:
|
|
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;
|
|
@@ -11,6 +11,7 @@ const HEARTBEAT_MS = 60_000;
|
|
|
11
11
|
/** A record this old without an update belongs to a process that is blocked or gone. */
|
|
12
12
|
const STALE_MS = 5 * HEARTBEAT_MS;
|
|
13
13
|
const MAX_RECORDS = 512;
|
|
14
|
+
const ABANDONED_MS = 24 * STALE_MS;
|
|
14
15
|
const recordSchema = z
|
|
15
16
|
.object({
|
|
16
17
|
pid: z.number().int().positive(),
|
|
@@ -78,8 +79,13 @@ export class ServerRegistry {
|
|
|
78
79
|
const beat = () => void mkdir(root, { recursive: true })
|
|
79
80
|
.then(async () => {
|
|
80
81
|
const temporary = `${file}.${Date.now()}.tmp`;
|
|
81
|
-
|
|
82
|
-
|
|
82
|
+
try {
|
|
83
|
+
await writeFile(temporary, `${JSON.stringify(this.current())}\n`);
|
|
84
|
+
await rename(temporary, file);
|
|
85
|
+
}
|
|
86
|
+
catch {
|
|
87
|
+
await unlink(temporary).catch(() => undefined);
|
|
88
|
+
}
|
|
83
89
|
})
|
|
84
90
|
.catch(() => undefined);
|
|
85
91
|
beat();
|
|
@@ -109,9 +115,14 @@ export class ServerRegistry {
|
|
|
109
115
|
const servers = [];
|
|
110
116
|
let removed = 0;
|
|
111
117
|
if (root !== undefined) {
|
|
112
|
-
const
|
|
113
|
-
|
|
114
|
-
|
|
118
|
+
const entries = await readdir(root).catch(() => []);
|
|
119
|
+
// Temporary heartbeat files left by a failed rename.
|
|
120
|
+
for (const name of entries) {
|
|
121
|
+
const temporary = /^\d+\.json\.(\d+)\.tmp$/u.exec(name);
|
|
122
|
+
if (temporary !== null && Date.now() - Number(temporary[1]) > STALE_MS)
|
|
123
|
+
await unlink(path.join(root, name)).catch(() => undefined);
|
|
124
|
+
}
|
|
125
|
+
const names = entries.filter((name) => /^\d+\.json$/u.test(name)).slice(0, MAX_RECORDS);
|
|
115
126
|
for (const name of names) {
|
|
116
127
|
const file = path.join(root, name);
|
|
117
128
|
const parsed = recordSchema.safeParse(await readFile(file, 'utf8')
|
|
@@ -128,6 +139,12 @@ export class ServerRegistry {
|
|
|
128
139
|
continue;
|
|
129
140
|
}
|
|
130
141
|
const age = Date.now() - Date.parse(record.updatedAt);
|
|
142
|
+
// A record silent for this long belongs to an exited process whose pid was reused.
|
|
143
|
+
if (age > ABANDONED_MS) {
|
|
144
|
+
removed += 1;
|
|
145
|
+
await unlink(file).catch(() => undefined);
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
131
148
|
servers.push({ ...record, state: age > STALE_MS ? 'stale' : 'live' });
|
|
132
149
|
}
|
|
133
150
|
}
|
|
@@ -20,11 +20,13 @@ export class SharedRequestCapacity {
|
|
|
20
20
|
stateRoot;
|
|
21
21
|
capacity;
|
|
22
22
|
deletePendingLimitMs;
|
|
23
|
+
leaseMaxAgeMs;
|
|
23
24
|
now;
|
|
24
25
|
constructor(stateRoot, capacity = 4, options = {}) {
|
|
25
26
|
this.stateRoot = stateRoot;
|
|
26
27
|
this.capacity = capacity;
|
|
27
28
|
this.deletePendingLimitMs = options.deletePendingLimitMs ?? 60_000;
|
|
29
|
+
this.leaseMaxAgeMs = options.leaseMaxAgeMs;
|
|
28
30
|
this.now = options.now ?? Date.now;
|
|
29
31
|
}
|
|
30
32
|
async run(signal, action) {
|
|
@@ -34,6 +36,7 @@ export class SharedRequestCapacity {
|
|
|
34
36
|
handoffToProcess: () => {
|
|
35
37
|
return Promise.reject(new ServiceError('REQUEST_LEASE_STORAGE_REQUIRED', 'Worker ownership transfer requires persistent capacity storage'));
|
|
36
38
|
},
|
|
39
|
+
reclaimFromProcess: () => Promise.resolve(),
|
|
37
40
|
});
|
|
38
41
|
const root = await containedGeneratedPath(await canonicalPath(this.stateRoot, signal), 'request-capacity', sha256Bytes(hostname().toLowerCase()).slice(0, 16));
|
|
39
42
|
await mkdir(root, { recursive: true });
|
|
@@ -112,6 +115,14 @@ export class SharedRequestCapacity {
|
|
|
112
115
|
owner = next;
|
|
113
116
|
transferredPid = pid;
|
|
114
117
|
},
|
|
118
|
+
reclaimFromProcess: async () => {
|
|
119
|
+
if (transferredPid === undefined)
|
|
120
|
+
return;
|
|
121
|
+
const next = path.join(slot, `${process.pid}-${randomUUID()}.lease`);
|
|
122
|
+
await retryWindowsSharing(() => rename(owner, next));
|
|
123
|
+
owner = next;
|
|
124
|
+
transferredPid = undefined;
|
|
125
|
+
},
|
|
115
126
|
});
|
|
116
127
|
}
|
|
117
128
|
finally {
|
|
@@ -137,6 +148,18 @@ export class SharedRequestCapacity {
|
|
|
137
148
|
await delay(sharingRetryDelay(waits) * (0.75 + Math.random() / 2), undefined, { signal });
|
|
138
149
|
}
|
|
139
150
|
}
|
|
151
|
+
async leaseExpired(lease) {
|
|
152
|
+
if (this.leaseMaxAgeMs === undefined)
|
|
153
|
+
return false;
|
|
154
|
+
try {
|
|
155
|
+
return this.now() - (await lstat(lease)).mtimeMs > this.leaseMaxAgeMs;
|
|
156
|
+
}
|
|
157
|
+
catch (error) {
|
|
158
|
+
if (error.code === 'ENOENT')
|
|
159
|
+
return false;
|
|
160
|
+
throw error;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
140
163
|
async reap(slot) {
|
|
141
164
|
try {
|
|
142
165
|
const metadata = await lstat(slot);
|
|
@@ -150,7 +173,9 @@ export class SharedRequestCapacity {
|
|
|
150
173
|
else {
|
|
151
174
|
for (const name of owners) {
|
|
152
175
|
const match = /^([1-9][0-9]*)-[0-9a-f-]{36}\.lease$/u.exec(name);
|
|
153
|
-
if (match === null
|
|
176
|
+
if (match === null)
|
|
177
|
+
return;
|
|
178
|
+
if (processAlive(Number(match[1])) && !(await this.leaseExpired(path.join(slot, name))))
|
|
154
179
|
return;
|
|
155
180
|
// Unique owner names prevent a concurrent reaper from deleting a new lease.
|
|
156
181
|
await unlink(path.join(slot, name)).catch((error) => {
|