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.
- package/CHANGELOG.md +118 -0
- package/README.md +115 -147
- package/dist/bin/http.js +12 -0
- package/dist/bin/setup.js +3 -2
- package/dist/bin/stdio.js +12 -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 +56 -10
- package/dist/hoi4_agent_tools/core/index.js +45 -20
- 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 +155 -0
- package/dist/hoi4_agent_tools/core/server-status-resource.js +22 -0
- package/dist/hoi4_agent_tools/core/shared-request-capacity.js +26 -1
- package/dist/hoi4_agent_tools/core/source/cache.js +108 -29
- 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/event/service.js +7 -0
- 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/scenario-generator.js +11 -3
- package/dist/hoi4_agent_tools/gui/studio.js +20 -7
- 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/resources/register-modern.js +13 -1
- package/dist/hoi4_agent_tools/mcp/resources/register.js +6 -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 -26
- package/dist/hoi4_agent_tools/mcp/transports/http.js +28 -4
- package/dist/hoi4_agent_tools/mcp/transports/stdio-lifetime.js +37 -18
- 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 +83 -23
- 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/technology/service.js +7 -0
- 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 +45 -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 +161 -162
- 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
|
@@ -16,10 +16,24 @@ export function canonicalize(value) {
|
|
|
16
16
|
return value.map(canonicalize);
|
|
17
17
|
if (typeof value === 'object') {
|
|
18
18
|
const object = value;
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
19
|
+
// Filling the copy directly avoids an entry array per member; a plain object lists integer
|
|
20
|
+
// keys first and the rest in insertion (here code unit) order, the same as before.
|
|
21
|
+
const copy = {};
|
|
22
|
+
for (const key of Object.keys(object).sort(compareCodeUnits)) {
|
|
23
|
+
const member = object[key];
|
|
24
|
+
if (member === undefined)
|
|
25
|
+
continue;
|
|
26
|
+
if (key === '__proto__')
|
|
27
|
+
Object.defineProperty(copy, key, {
|
|
28
|
+
value: canonicalize(member),
|
|
29
|
+
enumerable: true,
|
|
30
|
+
writable: true,
|
|
31
|
+
configurable: true,
|
|
32
|
+
});
|
|
33
|
+
else
|
|
34
|
+
copy[key] = canonicalize(member);
|
|
35
|
+
}
|
|
36
|
+
return copy;
|
|
23
37
|
}
|
|
24
38
|
throw new TypeError(`Canonical JSON cannot contain ${typeof value}`);
|
|
25
39
|
}
|
|
@@ -155,6 +155,11 @@ export const serverConfigurationSchema = z
|
|
|
155
155
|
stdioIdleExitMinutes: z.number().int().min(0).max(10_080).default(0),
|
|
156
156
|
/** V8 heap ceiling for each job worker in MiB; unset keeps Node's default. */
|
|
157
157
|
jobWorkerMaxHeapMiB: z.number().int().min(256).max(65_536).optional(),
|
|
158
|
+
/**
|
|
159
|
+
* A job worker stays warm this many seconds after a job, so the next job reuses its loaded
|
|
160
|
+
* indexes instead of scanning cold; 0 starts a fresh worker for every job.
|
|
161
|
+
*/
|
|
162
|
+
jobWorkerIdleSeconds: z.number().int().min(0).max(3_600).default(180),
|
|
158
163
|
scanMaxBytes: z
|
|
159
164
|
.number()
|
|
160
165
|
.int()
|
|
@@ -182,6 +187,8 @@ export const serverConfigurationSchema = z
|
|
|
182
187
|
.default(134_217_728),
|
|
183
188
|
modRoots: z.array(z.string().min(1)).max(WORKSPACE_MAX_SOURCE_ROOTS).default([]),
|
|
184
189
|
gameRoot: z.string().min(1).optional(),
|
|
190
|
+
/** The game's user-data folder holding logs/ and mod/; derived from the mod folder when unset. */
|
|
191
|
+
gameUserRoot: z.string().min(1).optional(),
|
|
185
192
|
workspaceStorageRoot: z.string().min(1).optional(),
|
|
186
193
|
storageRoots: z.array(z.string().min(1)).max(WORKSPACE_MAX_SOURCE_ROOTS).default([]),
|
|
187
194
|
workspaces: z.array(workspaceRegistrationSchema).max(WORKSPACE_MAX_REGISTRATIONS).default([]),
|
|
@@ -78,6 +78,18 @@ export function countryAssetDiscoveryPatterns(workspace) {
|
|
|
78
78
|
...under(roots.gfx, '**/*.gfx'),
|
|
79
79
|
]);
|
|
80
80
|
}
|
|
81
|
+
/**
|
|
82
|
+
* The changed files of a map rewrite plus the province definitions they refer to. A state file
|
|
83
|
+
* lists provinces that `map/definition.csv` defines, usually in the base game, so a scan of the
|
|
84
|
+
* changed files alone would report every listed province as unresolved.
|
|
85
|
+
*/
|
|
86
|
+
export function mapChangeScanPatterns(relativePaths) {
|
|
87
|
+
return uniquePatterns([
|
|
88
|
+
...exactChangedFilePatterns(relativePaths),
|
|
89
|
+
'map/default.map',
|
|
90
|
+
'map/*.csv',
|
|
91
|
+
]);
|
|
92
|
+
}
|
|
81
93
|
export function exactChangedFilePatterns(relativePaths) {
|
|
82
94
|
return uniquePatterns(relativePaths.map((relativePath) => relativePath.replaceAll('\\', '/').replace(/^\.\//u, '')));
|
|
83
95
|
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { decisionInspectRequestSchema, impactInspectRequestSchema } from '../schemas/analysis.js';
|
|
2
|
+
import { eventInspectRequestSchema, eventRenderRequestSchema } from '../schemas/event.js';
|
|
3
|
+
import { focusInspectRequestSchema, focusRenderRequestSchema, focusRewriteRequestSchema, } from '../schemas/focus-requests.js';
|
|
4
|
+
import { guiInspectRequestSchema, guiRenderRequestSchema, guiRewriteRequestSchema, } from '../schemas/gui-requests.js';
|
|
5
|
+
import { mapInspectRequestSchema, mapRewriteRequestSchema } from '../schemas/map-requests.js';
|
|
6
|
+
import { probabilityAnalyzeRequestSchema, probabilityInspectRequestSchema, } from '../schemas/probability-requests.js';
|
|
7
|
+
import { mechanicTestRequestSchema, packageCheckRequestSchema, scenarioTestRequestSchema, } from '../schemas/scenarios.js';
|
|
8
|
+
import { technologyInspectRequestSchema, technologyRenderRequestSchema, } from '../schemas/technology.js';
|
|
9
|
+
/**
|
|
10
|
+
* The single list of domain tools that run as durable jobs. Task routing, worker admission,
|
|
11
|
+
* request lifecycles and job validation all read this list, so adding, merging or removing a
|
|
12
|
+
* domain tool happens in one place.
|
|
13
|
+
*/
|
|
14
|
+
export const DOMAIN_TOOL_NAMES = [
|
|
15
|
+
'hoi4.impact_inspect',
|
|
16
|
+
'hoi4.decision_inspect',
|
|
17
|
+
'hoi4.mechanic_test',
|
|
18
|
+
'hoi4.package_check',
|
|
19
|
+
'hoi4.scenario_test',
|
|
20
|
+
'hoi4.event_inspect',
|
|
21
|
+
'hoi4.event_render',
|
|
22
|
+
'hoi4.tech_inspect',
|
|
23
|
+
'hoi4.tech_render',
|
|
24
|
+
'hoi4.probability_inspect',
|
|
25
|
+
'hoi4.probability_analyze',
|
|
26
|
+
'hoi4.map_inspect',
|
|
27
|
+
'hoi4.map_rewrite',
|
|
28
|
+
'hoi4.gui_inspect',
|
|
29
|
+
'hoi4.gui_render',
|
|
30
|
+
'hoi4.gui_rewrite',
|
|
31
|
+
'hoi4.focus_inspect',
|
|
32
|
+
'hoi4.focus_render',
|
|
33
|
+
'hoi4.focus_rewrite',
|
|
34
|
+
];
|
|
35
|
+
export const DOMAIN_TOOL_ARGUMENT_SCHEMAS = {
|
|
36
|
+
'hoi4.impact_inspect': impactInspectRequestSchema,
|
|
37
|
+
'hoi4.decision_inspect': decisionInspectRequestSchema,
|
|
38
|
+
'hoi4.mechanic_test': mechanicTestRequestSchema,
|
|
39
|
+
'hoi4.package_check': packageCheckRequestSchema,
|
|
40
|
+
'hoi4.scenario_test': scenarioTestRequestSchema,
|
|
41
|
+
'hoi4.event_inspect': eventInspectRequestSchema,
|
|
42
|
+
'hoi4.event_render': eventRenderRequestSchema,
|
|
43
|
+
'hoi4.tech_inspect': technologyInspectRequestSchema,
|
|
44
|
+
'hoi4.tech_render': technologyRenderRequestSchema,
|
|
45
|
+
'hoi4.probability_inspect': probabilityInspectRequestSchema,
|
|
46
|
+
'hoi4.probability_analyze': probabilityAnalyzeRequestSchema,
|
|
47
|
+
'hoi4.map_inspect': mapInspectRequestSchema,
|
|
48
|
+
'hoi4.map_rewrite': mapRewriteRequestSchema,
|
|
49
|
+
'hoi4.gui_inspect': guiInspectRequestSchema,
|
|
50
|
+
'hoi4.gui_render': guiRenderRequestSchema,
|
|
51
|
+
'hoi4.gui_rewrite': guiRewriteRequestSchema,
|
|
52
|
+
'hoi4.focus_inspect': focusInspectRequestSchema,
|
|
53
|
+
'hoi4.focus_render': focusRenderRequestSchema,
|
|
54
|
+
'hoi4.focus_rewrite': focusRewriteRequestSchema,
|
|
55
|
+
};
|
|
56
|
+
const domainToolNames = new Set(DOMAIN_TOOL_NAMES);
|
|
57
|
+
export function isDomainToolName(name) {
|
|
58
|
+
return domainToolNames.has(name);
|
|
59
|
+
}
|
|
60
|
+
/** Domain tools that change mod source. */
|
|
61
|
+
export const MUTATION_TOOL_NAMES = new Set([
|
|
62
|
+
'hoi4.gui_rewrite',
|
|
63
|
+
'hoi4.map_rewrite',
|
|
64
|
+
'hoi4.focus_rewrite',
|
|
65
|
+
]);
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { boundedSourceHashEvidence, publicArtifactLink } from './artifacts.js';
|
|
2
2
|
import { canonicalJson } from './canonical.js';
|
|
3
|
-
import { exactChangedFilePatterns, focusDomainScanPatterns } from './domain-scan-patterns.js';
|
|
3
|
+
import { exactChangedFilePatterns, focusDomainScanPatterns, mapChangeScanPatterns, } from './domain-scan-patterns.js';
|
|
4
4
|
import { parseClausewitz } from './source/index.js';
|
|
5
5
|
import { TRANSACTION_MAX_DIAGNOSTICS } from './transaction-limits.js';
|
|
6
6
|
import { importContinuousFocusPalettes, importFocusTrees, layoutFocusTreeAsync, lintContinuousFocusPalette, lintFocusTree, } from '../focus/index.js';
|
|
@@ -31,10 +31,13 @@ export async function postValidateTransaction(engine, transaction, principal, si
|
|
|
31
31
|
const workspace = engine.resolver.get(transaction.workspaceId, principal);
|
|
32
32
|
const focusTransaction = transaction.operationKind === 'focus-plan-changes' ||
|
|
33
33
|
transaction.operationKind === 'continuous-focus-plan-changes';
|
|
34
|
+
const changedPaths = transaction.files.map(({ relativePath }) => relativePath);
|
|
34
35
|
const snapshot = await engine.scan(transaction.workspaceId, {
|
|
35
36
|
patterns: focusTransaction
|
|
36
37
|
? focusDomainScanPatterns(workspace)
|
|
37
|
-
:
|
|
38
|
+
: transaction.operationKind === 'agent-nudger-map-changes'
|
|
39
|
+
? mapChangeScanPatterns(changedPaths)
|
|
40
|
+
: exactChangedFilePatterns(changedPaths),
|
|
38
41
|
}, principal, signal);
|
|
39
42
|
const changed = new Set(transaction.files.map(({ relativePath }) => sourcePathComparisonKey(relativePath)));
|
|
40
43
|
const diagnostics = snapshot.diagnostics.filter(({ location }) => {
|
|
@@ -9,6 +9,13 @@ import { TransactionManager, transactionRootFingerprint } from './transactions.j
|
|
|
9
9
|
import { RequestScheduler } from './request-scheduler.js';
|
|
10
10
|
import { SharedRequestCapacity } from './shared-request-capacity.js';
|
|
11
11
|
import { JobStore } from './job-store.js';
|
|
12
|
+
import { ServiceError } from './result.js';
|
|
13
|
+
/**
|
|
14
|
+
* Completed scans kept per workspace. One tool call often scans several pattern sets (a GUI
|
|
15
|
+
* preview scans layouts, linked definitions and localisation), so a single slot made every
|
|
16
|
+
* repeat call rescan and reindex each set.
|
|
17
|
+
*/
|
|
18
|
+
const SCAN_CACHE_ENTRIES_PER_WORKSPACE = 4;
|
|
12
19
|
const RECOVERY_TTL_SECONDS = 3_600;
|
|
13
20
|
const RECOVERY_MAX_BYTES = 536_870_912;
|
|
14
21
|
const RECOVERY_MAX_RECORDS = 128;
|
|
@@ -124,8 +131,21 @@ export class CoreEngine {
|
|
|
124
131
|
}
|
|
125
132
|
async initialize() {
|
|
126
133
|
for (const workspace of this.resolver.list()) {
|
|
127
|
-
// Recovery repairs an interrupted internal rewrite before the workspace is exposed.
|
|
128
|
-
|
|
134
|
+
// Recovery repairs an interrupted internal rewrite before the workspace is exposed. A
|
|
135
|
+
// journal that cannot be recovered blocks rewrites of its own workspace, not the server.
|
|
136
|
+
try {
|
|
137
|
+
await this.transactions.recover(workspace.id);
|
|
138
|
+
}
|
|
139
|
+
catch (error) {
|
|
140
|
+
this.transactions.recordRecoveryFailure(workspace.id, error);
|
|
141
|
+
process.stderr.write(`${JSON.stringify({
|
|
142
|
+
level: 'error',
|
|
143
|
+
event: 'transaction_recovery_blocked',
|
|
144
|
+
workspaceId: workspace.id,
|
|
145
|
+
code: error instanceof ServiceError ? error.code : 'TRANSACTION_RECOVERY_FAILED',
|
|
146
|
+
message: error instanceof Error ? error.message : String(error),
|
|
147
|
+
})}\n`);
|
|
148
|
+
}
|
|
129
149
|
}
|
|
130
150
|
}
|
|
131
151
|
status(workspaceId, principal) {
|
|
@@ -154,6 +174,7 @@ export class CoreEngine {
|
|
|
154
174
|
}
|
|
155
175
|
async scan(workspaceId, options = {}, principal, signal) {
|
|
156
176
|
signal?.throwIfAborted();
|
|
177
|
+
await this.resolver.refreshDescriptors(workspaceId, principal);
|
|
157
178
|
const workspace = this.resolver.get(workspaceId, principal);
|
|
158
179
|
const patterns = options.patterns ?? defaultPatterns(workspace);
|
|
159
180
|
const ignore = options.ignore;
|
|
@@ -167,9 +188,6 @@ export class CoreEngine {
|
|
|
167
188
|
})}`;
|
|
168
189
|
let flight = this.#scanFlights.get(requestKey);
|
|
169
190
|
if (flight === undefined) {
|
|
170
|
-
for (const key of this.#scanCache.keys())
|
|
171
|
-
if (key.startsWith(`${workspaceId}:`) && !key.startsWith(`${requestKey}:`))
|
|
172
|
-
this.#scanCache.delete(key);
|
|
173
191
|
const controller = new AbortController();
|
|
174
192
|
const promise = (async () => {
|
|
175
193
|
return this.withScanAdmission(controller.signal, async () => {
|
|
@@ -193,8 +211,11 @@ export class CoreEngine {
|
|
|
193
211
|
})));
|
|
194
212
|
const cacheKey = `${requestKey}:${revision}`;
|
|
195
213
|
const cached = this.#scanCache.get(cacheKey);
|
|
196
|
-
if (cached !== undefined)
|
|
214
|
+
if (cached !== undefined) {
|
|
215
|
+
this.#scanCache.delete(cacheKey);
|
|
216
|
+
this.#scanCache.set(cacheKey, cached);
|
|
197
217
|
return cached;
|
|
218
|
+
}
|
|
198
219
|
const persistentAnalysisCache = await this.persistentAnalysisCache;
|
|
199
220
|
const analysisScope = {
|
|
200
221
|
workspaceIdentity: workspace.workspaceIdentity,
|
|
@@ -202,7 +223,12 @@ export class CoreEngine {
|
|
|
202
223
|
principal: principal ?? null,
|
|
203
224
|
};
|
|
204
225
|
await persistentAnalysisCache?.hydrate(analysisScope, files, sourceDocuments, this.indexSegments, controller.signal);
|
|
205
|
-
|
|
226
|
+
// The previous revision of the same request still holds every unchanged file's
|
|
227
|
+
// records, so only edited files are parsed and indexed again.
|
|
228
|
+
const previous = [...this.#scanCache]
|
|
229
|
+
.filter(([key]) => key.startsWith(`${requestKey}:`))
|
|
230
|
+
.at(-1)?.[1].index;
|
|
231
|
+
const index = await SymbolIndex.buildAsync(files, controller.signal, this.indexSegments, previous);
|
|
206
232
|
await persistentAnalysisCache?.persist(analysisScope, files, sourceDocuments, this.indexSegments, controller.signal);
|
|
207
233
|
const snapshot = {
|
|
208
234
|
workspaceId,
|
|
@@ -215,11 +241,30 @@ export class CoreEngine {
|
|
|
215
241
|
diagnostics: index.diagnostics,
|
|
216
242
|
};
|
|
217
243
|
if ((this.#scanGenerations.get(workspaceId) ?? 0) === generation) {
|
|
244
|
+
// Replace this request's earlier revision and drop earlier generations; keep the
|
|
245
|
+
// most recent other requests of this workspace.
|
|
218
246
|
for (const key of this.#scanCache.keys()) {
|
|
219
|
-
if (key.startsWith(`${
|
|
247
|
+
if (key.startsWith(`${requestKey}:`) ||
|
|
248
|
+
(key.startsWith(`${workspaceId}:`) &&
|
|
249
|
+
!key.startsWith(`${workspaceId}:${generation}:`)))
|
|
220
250
|
this.#scanCache.delete(key);
|
|
221
251
|
}
|
|
222
252
|
this.#scanCache.set(cacheKey, snapshot);
|
|
253
|
+
const retained = [...this.#scanCache.keys()].filter((key) => key.startsWith(`${workspaceId}:`));
|
|
254
|
+
for (const key of retained.slice(0, -SCAN_CACHE_ENTRIES_PER_WORKSPACE))
|
|
255
|
+
this.#scanCache.delete(key);
|
|
256
|
+
// Snapshots hold every file's bytes; keep all of them within twice one scan's limit.
|
|
257
|
+
const limit = this.resolver.config().scanMaxBytes * 2;
|
|
258
|
+
const bytes = (cached) => cached.files.reduce((sum, { size }) => sum + size, 0);
|
|
259
|
+
let retainedBytes = [...this.#scanCache.values()].reduce((sum, cached) => sum + bytes(cached), 0);
|
|
260
|
+
for (const [key, cached] of this.#scanCache) {
|
|
261
|
+
if (retainedBytes <= limit)
|
|
262
|
+
break;
|
|
263
|
+
if (key === cacheKey)
|
|
264
|
+
continue;
|
|
265
|
+
this.#scanCache.delete(key);
|
|
266
|
+
retainedBytes -= bytes(cached);
|
|
267
|
+
}
|
|
223
268
|
}
|
|
224
269
|
return snapshot;
|
|
225
270
|
});
|
|
@@ -384,7 +429,8 @@ export class CoreEngine {
|
|
|
384
429
|
request.run();
|
|
385
430
|
}
|
|
386
431
|
}
|
|
387
|
-
|
|
388
|
-
|
|
432
|
+
/** `previous` supplies unchanged files' records from an earlier index of the same sources. */
|
|
433
|
+
indexFiles(files, previous) {
|
|
434
|
+
return SymbolIndex.build(files, this.indexSegments, previous);
|
|
389
435
|
}
|
|
390
436
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { compareCodeUnits } from './canonical.js';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { sortDiagnostics } from './diagnostics.js';
|
|
4
|
-
import { indexSegmentAddress } from './index-segments.js';
|
|
4
|
+
import { indexSegmentAddress, } from './index-segments.js';
|
|
5
5
|
import { parseClausewitz, parseLocalisation, assignments, childBlocks, firstScalar, locationFor, sourcePartialLimitDiagnostics, SOURCE_MAX_BYTES, SOURCE_MAX_NESTING, nodeLocation, } from './source/index.js';
|
|
6
6
|
const spriteDefinitionKeys = new Set([
|
|
7
7
|
'spritetype',
|
|
@@ -261,6 +261,11 @@ export class SymbolIndex {
|
|
|
261
261
|
files = new Map();
|
|
262
262
|
#active = new Map();
|
|
263
263
|
#definitionFiles = new Set();
|
|
264
|
+
/**
|
|
265
|
+
* Where each completely indexed file's records sit in this index, by segment address, so a
|
|
266
|
+
* rebuild after an edit reuses unchanged files instead of parsing and indexing them again.
|
|
267
|
+
*/
|
|
268
|
+
#fileRanges = new Map();
|
|
264
269
|
#indexNestingBlocked = new Set();
|
|
265
270
|
#skippedSourcePaths = new Set();
|
|
266
271
|
#skippedPossibleSymbolKinds = new Set();
|
|
@@ -271,7 +276,7 @@ export class SymbolIndex {
|
|
|
271
276
|
#referenceLimitReported = false;
|
|
272
277
|
#diagnosticsTruncated = false;
|
|
273
278
|
#indexTableBlocked = new Set();
|
|
274
|
-
static build(files, segments) {
|
|
279
|
+
static build(files, segments, previous) {
|
|
275
280
|
const index = new SymbolIndex();
|
|
276
281
|
const definitionSelection = selectedDefinitionFiles(files);
|
|
277
282
|
for (const displayPath of definitionSelection.selected)
|
|
@@ -281,13 +286,13 @@ export class SymbolIndex {
|
|
|
281
286
|
}
|
|
282
287
|
for (const file of [...files].sort((a, b) => a.loadOrder - b.loadOrder || compareCodeUnits(a.displayPath, b.displayPath))) {
|
|
283
288
|
index.files.set(file.displayPath, file);
|
|
284
|
-
index.indexFileWithSegments(file, segments);
|
|
289
|
+
index.indexFileWithSegments(file, segments, previous);
|
|
285
290
|
}
|
|
286
291
|
index.finalize();
|
|
287
292
|
return index;
|
|
288
293
|
}
|
|
289
294
|
/** Yield between source files so protocol cancellation and progress remain responsive. */
|
|
290
|
-
static async buildAsync(files, signal, segments) {
|
|
295
|
+
static async buildAsync(files, signal, segments, previous) {
|
|
291
296
|
signal?.throwIfAborted();
|
|
292
297
|
const index = new SymbolIndex();
|
|
293
298
|
const definitionSelection = selectedDefinitionFiles(files);
|
|
@@ -300,7 +305,7 @@ export class SymbolIndex {
|
|
|
300
305
|
for (const file of [...files].sort((a, b) => a.loadOrder - b.loadOrder || compareCodeUnits(a.displayPath, b.displayPath))) {
|
|
301
306
|
signal?.throwIfAborted();
|
|
302
307
|
index.files.set(file.displayPath, file);
|
|
303
|
-
index.indexFileWithSegments(file, segments);
|
|
308
|
+
index.indexFileWithSegments(file, segments, previous);
|
|
304
309
|
if (performance.now() - yieldedAt >= 20) {
|
|
305
310
|
await new Promise((resolve) => setImmediate(resolve));
|
|
306
311
|
signal?.throwIfAborted();
|
|
@@ -437,31 +442,50 @@ export class SymbolIndex {
|
|
|
437
442
|
for (const diagnostic of diagnostics)
|
|
438
443
|
this.addDiagnostic(diagnostic);
|
|
439
444
|
}
|
|
440
|
-
|
|
441
|
-
|
|
445
|
+
/** The records one completely indexed file contributed to this index, if it was recorded. */
|
|
446
|
+
#segmentAt(address) {
|
|
447
|
+
const range = this.#fileRanges.get(address);
|
|
448
|
+
if (range === undefined)
|
|
449
|
+
return undefined;
|
|
450
|
+
return {
|
|
451
|
+
symbols: this.symbols.slice(...range.symbols),
|
|
452
|
+
references: this.references.slice(...range.references),
|
|
453
|
+
diagnostics: range.diagnostics,
|
|
454
|
+
complete: true,
|
|
455
|
+
};
|
|
456
|
+
}
|
|
457
|
+
indexFileWithSegments(file, cache, previous) {
|
|
458
|
+
if (cache === undefined && previous === undefined) {
|
|
442
459
|
this.indexFile(file);
|
|
443
460
|
return;
|
|
444
461
|
}
|
|
445
462
|
const address = indexSegmentAddress(file, this.#definitionFiles.has(file.displayPath));
|
|
446
|
-
const
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
463
|
+
const symbols = this.symbols.length;
|
|
464
|
+
const references = this.references.length;
|
|
465
|
+
const diagnostics = this.diagnostics.length;
|
|
466
|
+
const record = () => this.#fileRanges.set(address, {
|
|
467
|
+
symbols: [symbols, this.symbols.length],
|
|
468
|
+
references: [references, this.references.length],
|
|
469
|
+
diagnostics: this.diagnostics.slice(diagnostics),
|
|
470
|
+
});
|
|
471
|
+
const reused = (previous === undefined ? undefined : previous.#segmentAt(address)) ?? cache?.get(address);
|
|
472
|
+
if (reused?.complete === true &&
|
|
473
|
+
this.symbols.length + reused.symbols.length <= INDEX_RECORD_LIMIT &&
|
|
474
|
+
this.references.length + reused.references.length <= INDEX_RECORD_LIMIT &&
|
|
475
|
+
this.diagnostics.length + reused.diagnostics.length <= INDEX_DIAGNOSTIC_LIMIT) {
|
|
451
476
|
this.#currentFileShadowed = file.shadowedBy !== undefined;
|
|
452
|
-
for (const symbol of
|
|
477
|
+
for (const symbol of reused.symbols)
|
|
453
478
|
this.addSymbol(symbol);
|
|
454
|
-
for (const reference of
|
|
479
|
+
for (const reference of reused.references)
|
|
455
480
|
this.addReference(reference);
|
|
456
|
-
this.addDiagnostics(
|
|
481
|
+
this.addDiagnostics(reused.diagnostics);
|
|
482
|
+
if (!this.#diagnosticsTruncated)
|
|
483
|
+
record();
|
|
457
484
|
return;
|
|
458
485
|
}
|
|
459
|
-
const symbols = this.symbols.length;
|
|
460
|
-
const references = this.references.length;
|
|
461
|
-
const diagnostics = this.diagnostics.length;
|
|
462
486
|
this.indexFile(file);
|
|
463
487
|
// Incomplete files and aggregate ceilings retain the full builder's exact diagnostic
|
|
464
|
-
// ordering and coverage bookkeeping. They are never
|
|
488
|
+
// ordering and coverage bookkeeping. They are never reused as complete segments.
|
|
465
489
|
if (this.#indexTableBlocked.has(file.displayPath) ||
|
|
466
490
|
this.#skippedSourcePaths.has(file.displayPath) ||
|
|
467
491
|
this.#indexNestingBlocked.has(file.displayPath) ||
|
|
@@ -469,7 +493,8 @@ export class SymbolIndex {
|
|
|
469
493
|
this.#referenceLimitReported ||
|
|
470
494
|
this.#diagnosticsTruncated)
|
|
471
495
|
return;
|
|
472
|
-
|
|
496
|
+
record();
|
|
497
|
+
cache?.put(address, {
|
|
473
498
|
symbols: this.symbols.slice(symbols),
|
|
474
499
|
references: this.references.slice(references),
|
|
475
500
|
diagnostics: this.diagnostics.slice(diagnostics),
|
|
@@ -67,7 +67,12 @@ export class JobExecutor {
|
|
|
67
67
|
this.jobs = jobs;
|
|
68
68
|
this.operations = operations;
|
|
69
69
|
}
|
|
70
|
-
|
|
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;
|