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
|
@@ -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
|
-
:
|
|
38
|
+
: transaction.operationKind === 'agent-nudger-map-changes'
|
|
39
|
+
? mapChangeScanPatterns(changedPaths)
|
|
40
|
+
: exactChangedFilePatterns(changedPaths),
|
|
38
41
|
}, principal, signal);
|
|
39
42
|
const changed = new Set(transaction.files.map(({ relativePath }) => sourcePathComparisonKey(relativePath)));
|
|
40
43
|
const diagnostics = snapshot.diagnostics.filter(({ location }) => {
|
|
@@ -9,6 +9,7 @@ import { TransactionManager, transactionRootFingerprint } from './transactions.j
|
|
|
9
9
|
import { RequestScheduler } from './request-scheduler.js';
|
|
10
10
|
import { SharedRequestCapacity } from './shared-request-capacity.js';
|
|
11
11
|
import { JobStore } from './job-store.js';
|
|
12
|
+
import { ServiceError } from './result.js';
|
|
12
13
|
/**
|
|
13
14
|
* Completed scans kept per workspace. One tool call often scans several pattern sets (a GUI
|
|
14
15
|
* preview scans layouts, linked definitions and localisation), so a single slot made every
|
|
@@ -130,8 +131,21 @@ export class CoreEngine {
|
|
|
130
131
|
}
|
|
131
132
|
async initialize() {
|
|
132
133
|
for (const workspace of this.resolver.list()) {
|
|
133
|
-
// Recovery repairs an interrupted internal rewrite before the workspace is exposed.
|
|
134
|
-
|
|
134
|
+
// Recovery repairs an interrupted internal rewrite before the workspace is exposed. A
|
|
135
|
+
// journal that cannot be recovered blocks rewrites of its own workspace, not the server.
|
|
136
|
+
try {
|
|
137
|
+
await this.transactions.recover(workspace.id);
|
|
138
|
+
}
|
|
139
|
+
catch (error) {
|
|
140
|
+
this.transactions.recordRecoveryFailure(workspace.id, error);
|
|
141
|
+
process.stderr.write(`${JSON.stringify({
|
|
142
|
+
level: 'error',
|
|
143
|
+
event: 'transaction_recovery_blocked',
|
|
144
|
+
workspaceId: workspace.id,
|
|
145
|
+
code: error instanceof ServiceError ? error.code : 'TRANSACTION_RECOVERY_FAILED',
|
|
146
|
+
message: error instanceof Error ? error.message : String(error),
|
|
147
|
+
})}\n`);
|
|
148
|
+
}
|
|
135
149
|
}
|
|
136
150
|
}
|
|
137
151
|
status(workspaceId, principal) {
|
|
@@ -160,6 +174,7 @@ export class CoreEngine {
|
|
|
160
174
|
}
|
|
161
175
|
async scan(workspaceId, options = {}, principal, signal) {
|
|
162
176
|
signal?.throwIfAborted();
|
|
177
|
+
await this.resolver.refreshDescriptors(workspaceId, principal);
|
|
163
178
|
const workspace = this.resolver.get(workspaceId, principal);
|
|
164
179
|
const patterns = options.patterns ?? defaultPatterns(workspace);
|
|
165
180
|
const ignore = options.ignore;
|
|
@@ -238,6 +253,18 @@ export class CoreEngine {
|
|
|
238
253
|
const retained = [...this.#scanCache.keys()].filter((key) => key.startsWith(`${workspaceId}:`));
|
|
239
254
|
for (const key of retained.slice(0, -SCAN_CACHE_ENTRIES_PER_WORKSPACE))
|
|
240
255
|
this.#scanCache.delete(key);
|
|
256
|
+
// Snapshots hold every file's bytes; keep all of them within twice one scan's limit.
|
|
257
|
+
const limit = this.resolver.config().scanMaxBytes * 2;
|
|
258
|
+
const bytes = (cached) => cached.files.reduce((sum, { size }) => sum + size, 0);
|
|
259
|
+
let retainedBytes = [...this.#scanCache.values()].reduce((sum, cached) => sum + bytes(cached), 0);
|
|
260
|
+
for (const [key, cached] of this.#scanCache) {
|
|
261
|
+
if (retainedBytes <= limit)
|
|
262
|
+
break;
|
|
263
|
+
if (key === cacheKey)
|
|
264
|
+
continue;
|
|
265
|
+
this.#scanCache.delete(key);
|
|
266
|
+
retainedBytes -= bytes(cached);
|
|
267
|
+
}
|
|
241
268
|
}
|
|
242
269
|
return snapshot;
|
|
243
270
|
});
|
|
@@ -450,7 +450,7 @@ export class SymbolIndex {
|
|
|
450
450
|
return {
|
|
451
451
|
symbols: this.symbols.slice(...range.symbols),
|
|
452
452
|
references: this.references.slice(...range.references),
|
|
453
|
-
diagnostics:
|
|
453
|
+
diagnostics: range.diagnostics,
|
|
454
454
|
complete: true,
|
|
455
455
|
};
|
|
456
456
|
}
|
|
@@ -466,7 +466,7 @@ export class SymbolIndex {
|
|
|
466
466
|
const record = () => this.#fileRanges.set(address, {
|
|
467
467
|
symbols: [symbols, this.symbols.length],
|
|
468
468
|
references: [references, this.references.length],
|
|
469
|
-
diagnostics:
|
|
469
|
+
diagnostics: this.diagnostics.slice(diagnostics),
|
|
470
470
|
});
|
|
471
471
|
const reused = (previous === undefined ? undefined : previous.#segmentAt(address)) ?? cache?.get(address);
|
|
472
472
|
if (reused?.complete === true &&
|
|
@@ -67,7 +67,12 @@ export class JobExecutor {
|
|
|
67
67
|
this.jobs = jobs;
|
|
68
68
|
this.operations = operations;
|
|
69
69
|
}
|
|
70
|
-
|
|
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,
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
482
|
+
return this.locked(signal, async () => {
|
|
462
483
|
const current = await this.get(scope, id, signal);
|
|
463
484
|
if (current.revision !== expectedRevision)
|
|
464
485
|
throw new ServiceError('JOB_REVISION_CONFLICT', 'Job state changed; retrieve its current revision before updating');
|
|
@@ -490,6 +511,58 @@ export class JobStore {
|
|
|
490
511
|
});
|
|
491
512
|
}
|
|
492
513
|
/** Operator-internal eviction guard; never returns another principal's job data. */
|
|
514
|
+
/**
|
|
515
|
+
* Remove expired, finished read-only records across every scope, inspecting at most
|
|
516
|
+
* `limit` directory entries. Records are otherwise removed only when they are read again,
|
|
517
|
+
* so results nobody polls would accumulate without bound.
|
|
518
|
+
*/
|
|
519
|
+
async sweepExpired(limit = 5_000, signal = new AbortController().signal) {
|
|
520
|
+
let inspected = 0;
|
|
521
|
+
let removed = 0;
|
|
522
|
+
const terminal = (record) => record !== undefined &&
|
|
523
|
+
!record.request.mutation &&
|
|
524
|
+
['completed', 'cancelled', 'failed'].includes(record.status) &&
|
|
525
|
+
jobRetentionExpired(record);
|
|
526
|
+
await assertUnlinked(this.root);
|
|
527
|
+
for await (const directory of await opendir(this.root)) {
|
|
528
|
+
if (++inspected > limit)
|
|
529
|
+
break;
|
|
530
|
+
signal.throwIfAborted();
|
|
531
|
+
if (directory.name === 'request-capacity' ||
|
|
532
|
+
directory.isSymbolicLink() ||
|
|
533
|
+
!directory.isDirectory() ||
|
|
534
|
+
!digest.safeParse(directory.name).success)
|
|
535
|
+
continue;
|
|
536
|
+
const scopeRoot = await containedGeneratedPath(this.root, directory.name);
|
|
537
|
+
for await (const entry of await opendir(scopeRoot)) {
|
|
538
|
+
if (++inspected > limit)
|
|
539
|
+
break;
|
|
540
|
+
const id = entry.name.replace(/\.json$/u, '');
|
|
541
|
+
if (!entry.isFile() || !entry.name.endsWith('.json') || !jobId.safeParse(id).success)
|
|
542
|
+
continue;
|
|
543
|
+
const file = await containedGeneratedPath(scopeRoot, entry.name);
|
|
544
|
+
const read = () => this.readEnvelope(file, id, directory.name).catch(() => undefined);
|
|
545
|
+
if (!terminal(await read()))
|
|
546
|
+
continue;
|
|
547
|
+
const deleted = await this.locked(signal, async () => {
|
|
548
|
+
if (!terminal(await read()))
|
|
549
|
+
return false;
|
|
550
|
+
try {
|
|
551
|
+
await retryWindowsSharing(() => unlink(file), { signal });
|
|
552
|
+
return true;
|
|
553
|
+
}
|
|
554
|
+
catch (error) {
|
|
555
|
+
if (error.code === 'ENOENT')
|
|
556
|
+
return false;
|
|
557
|
+
throw error;
|
|
558
|
+
}
|
|
559
|
+
});
|
|
560
|
+
if (deleted)
|
|
561
|
+
removed += 1;
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
return removed;
|
|
565
|
+
}
|
|
493
566
|
async checkpointResources(workspace, signal) {
|
|
494
567
|
const resources = new Set();
|
|
495
568
|
let inspected = 0;
|
|
@@ -55,7 +55,7 @@ export class JobTransactions {
|
|
|
55
55
|
return this.fail(job, ownerToken, transaction.rollbackStatus === 'applied'
|
|
56
56
|
? 'JOB_REWRITE_RESTORED'
|
|
57
57
|
: 'JOB_REWRITE_RECOVERY_REQUIRED', transaction.rollbackStatus === 'applied'
|
|
58
|
-
?
|
|
58
|
+
? `The interrupted rewrite was restored; it was not replayed${failureCause(undefined, transaction)}`
|
|
59
59
|
: 'The transaction could not establish a completed or restored outcome', principal);
|
|
60
60
|
throw new ServiceError('JOB_TRANSACTION_UNRESOLVED', 'The transaction has no stable outcome after recovery');
|
|
61
61
|
}
|
|
@@ -101,7 +101,7 @@ export class JobTransactions {
|
|
|
101
101
|
if (outcome.state === 'applied')
|
|
102
102
|
return this.complete(current, ownerToken, { transaction: outcome, outcome: 'applied', artifacts: [] }, true, principal, current.writeRecipe ?? recipe, completion);
|
|
103
103
|
if (outcome.state === 'rolled_back')
|
|
104
|
-
return this.fail(current, ownerToken, 'JOB_REWRITE_RESTORED',
|
|
104
|
+
return this.fail(current, ownerToken, 'JOB_REWRITE_RESTORED', `The rewrite failed and its original source was restored${failureCause(error, outcome)}`, principal);
|
|
105
105
|
if (outcome.state === 'failed')
|
|
106
106
|
return this.fail(current, ownerToken, 'JOB_REWRITE_RECOVERY_REQUIRED', 'The transaction could not establish a completed or restored outcome', principal);
|
|
107
107
|
}
|
|
@@ -124,3 +124,32 @@ export class JobTransactions {
|
|
|
124
124
|
return this.jobs.store.updateOwned(this.jobs.scope(job.scope.workspaceId, principal), job.id, ownerToken, { status: 'failed', failure: { code, message } });
|
|
125
125
|
}
|
|
126
126
|
}
|
|
127
|
+
/**
|
|
128
|
+
* Why the write stopped: the error code and message of a service error, else only the error
|
|
129
|
+
* kind, followed by the first check or diagnostic of the transaction that failed validation.
|
|
130
|
+
*/
|
|
131
|
+
function failureCause(error, transaction) {
|
|
132
|
+
const recorded = transaction.failure === undefined
|
|
133
|
+
? ''
|
|
134
|
+
: `${transaction.failure.code}: ${transaction.failure.message}`;
|
|
135
|
+
const cause = error instanceof ServiceError
|
|
136
|
+
? `${error.code}: ${error.message}`
|
|
137
|
+
: error instanceof Error
|
|
138
|
+
? error.name
|
|
139
|
+
: recorded;
|
|
140
|
+
const failedCheck = transaction.validation.checks.find(({ passed }) => !passed);
|
|
141
|
+
const blocking = transaction.diagnostics.find(({ severity }) => severity === 'error' || severity === 'blocker');
|
|
142
|
+
// The blocking diagnostic comes first: it is the part the agent can act on.
|
|
143
|
+
const text = [
|
|
144
|
+
blocking === undefined
|
|
145
|
+
? ''
|
|
146
|
+
: `${blocking.code}: ${blocking.message}${blocking.location === undefined
|
|
147
|
+
? ''
|
|
148
|
+
: ` at ${blocking.location.path}:${blocking.location.start.line}`}`,
|
|
149
|
+
failedCheck?.message ?? '',
|
|
150
|
+
cause,
|
|
151
|
+
]
|
|
152
|
+
.filter((part) => part !== '')
|
|
153
|
+
.join('; ');
|
|
154
|
+
return text === '' ? '' : ` (${text})`.slice(0, 700);
|
|
155
|
+
}
|