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
|
@@ -3,7 +3,7 @@ import { sha256Bytes } from '../canonical.js';
|
|
|
3
3
|
export function sourceDocumentCacheKey(bytes, sourcePath) {
|
|
4
4
|
return `${sha256Bytes(bytes)}:${sourcePath}`;
|
|
5
5
|
}
|
|
6
|
-
function decodedDocument(encoded
|
|
6
|
+
function decodedDocument(encoded) {
|
|
7
7
|
const value = deserialize(encoded);
|
|
8
8
|
if (value === null ||
|
|
9
9
|
typeof value !== 'object' ||
|
|
@@ -20,11 +20,6 @@ function decodedDocument(encoded, expected) {
|
|
|
20
20
|
!('bytes' in value) ||
|
|
21
21
|
!(value.bytes instanceof Uint8Array))
|
|
22
22
|
throw new Error('Persistent source document is structurally invalid');
|
|
23
|
-
if (expected !== undefined &&
|
|
24
|
-
(value.path !== expected.sourcePath ||
|
|
25
|
-
value.sourceRevision !== sha256Bytes(expected.bytes) ||
|
|
26
|
-
!Buffer.from(value.bytes).equals(Buffer.from(expected.bytes))))
|
|
27
|
-
throw new Error('Persistent source document does not match its source identity');
|
|
28
23
|
return value;
|
|
29
24
|
}
|
|
30
25
|
/** Puts between halvings of every entry's hit count, so entries no longer read become evictable. */
|
|
@@ -64,13 +59,23 @@ export class SourceDocumentCache {
|
|
|
64
59
|
this.#misses += 1;
|
|
65
60
|
return undefined;
|
|
66
61
|
}
|
|
62
|
+
let document;
|
|
63
|
+
try {
|
|
64
|
+
document = decodedDocument(entry.encoded);
|
|
65
|
+
}
|
|
66
|
+
catch {
|
|
67
|
+
// An imported entry is decoded here, on first use; one that cannot be decoded is a miss.
|
|
68
|
+
this.#remove(key);
|
|
69
|
+
this.#misses += 1;
|
|
70
|
+
return undefined;
|
|
71
|
+
}
|
|
67
72
|
this.#hits += 1;
|
|
68
73
|
if (entry.hits === 0)
|
|
69
74
|
this.#coldBytes -= entrySize(key, entry.encoded);
|
|
70
75
|
entry.hits += 1;
|
|
71
76
|
this.#entries.delete(key);
|
|
72
77
|
this.#entries.set(key, entry);
|
|
73
|
-
return
|
|
78
|
+
return document;
|
|
74
79
|
}
|
|
75
80
|
/** Availability check without deserialization or cache-hit accounting. */
|
|
76
81
|
has(key) {
|
|
@@ -96,9 +101,14 @@ export class SourceDocumentCache {
|
|
|
96
101
|
}
|
|
97
102
|
this.retain(key, serialize(document));
|
|
98
103
|
}
|
|
99
|
-
/**
|
|
100
|
-
|
|
101
|
-
|
|
104
|
+
/**
|
|
105
|
+
* Import one authenticated same-runtime entry without counting it as a process-local hit.
|
|
106
|
+
* The caller has verified the entry against the key's source bytes (the key is their hash and
|
|
107
|
+
* the path), so the document is decoded when it is first read instead of for every file at
|
|
108
|
+
* load, which cost as much as parsing the files again; `get` treats an undecodable entry as
|
|
109
|
+
* a miss.
|
|
110
|
+
*/
|
|
111
|
+
importEncoded(key, encoded) {
|
|
102
112
|
this.retain(key, Buffer.from(encoded));
|
|
103
113
|
}
|
|
104
114
|
exportEncoded(key) {
|
|
@@ -1,4 +1,12 @@
|
|
|
1
1
|
import { SOURCE_LINE_LIMIT, SOURCE_TOKEN_LIMIT, SourceDiagnosticCollector } from './limits.js';
|
|
2
|
+
const unicodeSpace = /\s/u;
|
|
3
|
+
/** The same characters as the `\s` class; the regular expression only runs for non-ASCII text. */
|
|
4
|
+
function isSpace(character) {
|
|
5
|
+
const code = character.charCodeAt(0);
|
|
6
|
+
if (code < 128)
|
|
7
|
+
return code === 32 || (code >= 9 && code <= 13);
|
|
8
|
+
return code > 159 && unicodeSpace.test(character);
|
|
9
|
+
}
|
|
2
10
|
const operatorStarts = new Set(['=', '<', '>', '!']);
|
|
3
11
|
function startsOperator(text, index) {
|
|
4
12
|
const character = text[index] ?? '';
|
|
@@ -141,8 +149,8 @@ export function lexClausewitz(text, sourcePath, options = {}) {
|
|
|
141
149
|
const startLine = line;
|
|
142
150
|
const startColumn = column;
|
|
143
151
|
const current = text[index] ?? '';
|
|
144
|
-
if (
|
|
145
|
-
while (index < text.length &&
|
|
152
|
+
if (isSpace(current)) {
|
|
153
|
+
while (index < text.length && isSpace(text[index] ?? ''))
|
|
146
154
|
advance();
|
|
147
155
|
add('whitespace', start, startLine, startColumn);
|
|
148
156
|
continue;
|
|
@@ -236,7 +244,7 @@ export function lexClausewitz(text, sourcePath, options = {}) {
|
|
|
236
244
|
}
|
|
237
245
|
while (index < text.length) {
|
|
238
246
|
const character = text[index] ?? '';
|
|
239
|
-
if (
|
|
247
|
+
if (isSpace(character) ||
|
|
240
248
|
character === '#' ||
|
|
241
249
|
character === '{' ||
|
|
242
250
|
character === '}' ||
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
1
2
|
import { access, mkdir, open, readFile, readdir, rename, rm, stat, unlink } from 'node:fs/promises';
|
|
2
3
|
import path from 'node:path';
|
|
3
4
|
import { constants } from 'node:fs';
|
|
@@ -11,6 +12,17 @@ import { containedGeneratedPath, isWithin, resolvedWorkspaceIdentity } from './w
|
|
|
11
12
|
import { TRANSACTION_MAX_ARTIFACTS, TRANSACTION_MAX_DIAGNOSTICS, TRANSACTION_MAX_FILES, TRANSACTION_MAX_MANIFEST_BYTES, TRANSACTION_MAX_OPERATIONS, TRANSACTION_MAX_READ_DEPENDENCIES, TRANSACTION_MAX_VALIDATION_CHECKS, } from './transaction-limits.js';
|
|
12
13
|
import { PACKAGE_VERSION, TRANSACTION_VERSION } from '../version.js';
|
|
13
14
|
import { transactionManifestSchema } from '../schemas/transaction.js';
|
|
15
|
+
/**
|
|
16
|
+
* A file name usable as an artifact name: mod files may contain spaces and other characters
|
|
17
|
+
* (`history/countries/AUR - Aurelia.txt`) that artifact names do not allow.
|
|
18
|
+
*/
|
|
19
|
+
function diffArtifactStem(relativePath) {
|
|
20
|
+
const stem = path
|
|
21
|
+
.basename(relativePath)
|
|
22
|
+
.replace(/[^A-Za-z0-9._-]+/gu, '_')
|
|
23
|
+
.replace(/^[^A-Za-z0-9]+/u, '');
|
|
24
|
+
return (stem.length === 0 ? 'file' : stem).slice(0, 100);
|
|
25
|
+
}
|
|
14
26
|
export function readDependenciesFromScannedFiles(files) {
|
|
15
27
|
const unique = new Map();
|
|
16
28
|
for (const file of files) {
|
|
@@ -154,8 +166,32 @@ export class TransactionManager {
|
|
|
154
166
|
this.maxJournals = maxJournals;
|
|
155
167
|
this.serverState = serverState;
|
|
156
168
|
}
|
|
169
|
+
/** Workspaces whose startup recovery failed, with the reason; their rewrites are refused. */
|
|
170
|
+
#recoveryFailures = new Map();
|
|
171
|
+
/**
|
|
172
|
+
* Record that startup recovery failed for a workspace. The server still starts and reads the
|
|
173
|
+
* workspace; rewrites retry recovery and are refused while it keeps failing.
|
|
174
|
+
*/
|
|
175
|
+
recordRecoveryFailure(workspaceId, error) {
|
|
176
|
+
this.#recoveryFailures.set(workspaceId, error instanceof Error ? error.message : 'Rewrite journal recovery failed');
|
|
177
|
+
}
|
|
178
|
+
async assertRecovered(workspaceId, signal) {
|
|
179
|
+
if (!this.#recoveryFailures.has(workspaceId))
|
|
180
|
+
return;
|
|
181
|
+
try {
|
|
182
|
+
await this.recover(workspaceId, undefined, signal);
|
|
183
|
+
this.#recoveryFailures.delete(workspaceId);
|
|
184
|
+
}
|
|
185
|
+
catch (error) {
|
|
186
|
+
if (error.name === 'AbortError')
|
|
187
|
+
throw error;
|
|
188
|
+
this.recordRecoveryFailure(workspaceId, error);
|
|
189
|
+
throw new ServiceError('TRANSACTION_RECOVERY_REQUIRED', `Rewrites of this workspace wait until its interrupted rewrite journal can be recovered: ${this.#recoveryFailures.get(workspaceId)}`);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
157
192
|
async plan(input) {
|
|
158
193
|
input.signal?.throwIfAborted();
|
|
194
|
+
await this.assertRecovered(input.workspaceId, input.signal);
|
|
159
195
|
if (input.changes.length > TRANSACTION_MAX_FILES ||
|
|
160
196
|
input.operations.length > TRANSACTION_MAX_OPERATIONS ||
|
|
161
197
|
(input.readDependencies?.length ?? 0) > TRANSACTION_MAX_READ_DEPENDENCIES ||
|
|
@@ -259,7 +295,7 @@ export class TransactionManager {
|
|
|
259
295
|
};
|
|
260
296
|
if (isText(file.mediaType)) {
|
|
261
297
|
return {
|
|
262
|
-
name: `${
|
|
298
|
+
name: `${diffArtifactStem(pending.relativePath)}.diff`,
|
|
263
299
|
mimeType: 'text/x-diff',
|
|
264
300
|
content: unifiedTextDiff(sourceDiffText(pending.before), sourceDiffText(pending.after), `a/${pending.relativePath}`, `b/${pending.relativePath}`, { ...(input.signal === undefined ? {} : { signal: input.signal }) }),
|
|
265
301
|
provenance: {
|
|
@@ -272,7 +308,7 @@ export class TransactionManager {
|
|
|
272
308
|
};
|
|
273
309
|
}
|
|
274
310
|
return {
|
|
275
|
-
name: `${
|
|
311
|
+
name: `${diffArtifactStem(pending.relativePath)}.binary-diff.json`,
|
|
276
312
|
mimeType: 'application/json',
|
|
277
313
|
content: `${canonicalJson(binaryDiff(pending.before ?? Buffer.alloc(0), pending.after ?? Buffer.alloc(0)))}\n`,
|
|
278
314
|
provenance: {
|
|
@@ -336,20 +372,28 @@ export class TransactionManager {
|
|
|
336
372
|
const workspace = this.resolver.get(workspaceId, options.principal);
|
|
337
373
|
if (!workspace.writeEnabled)
|
|
338
374
|
throw new ServiceError('WRITE_POLICY_DISABLED', 'Workspace writes are not enabled');
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
375
|
+
await this.assertRecovered(workspaceId, options.signal);
|
|
376
|
+
const loadApplicable = async () => {
|
|
377
|
+
const loaded = await this.load(workspace, transactionId, {
|
|
378
|
+
headMode: 'reconcile',
|
|
379
|
+
...(options.signal === undefined ? {} : { signal: options.signal }),
|
|
380
|
+
});
|
|
381
|
+
this.assertBinding(loaded, workspace, expectedPlanHash, options.principal);
|
|
382
|
+
if (!loaded.planPayload.validation.passed)
|
|
383
|
+
throw new ServiceError('TRANSACTION_VALIDATION_BLOCKED', 'Dry-run validation did not pass');
|
|
384
|
+
if (loaded.files.length === 0)
|
|
385
|
+
throw new ServiceError('TRANSACTION_NO_CHANGES', 'Transaction contains no file changes');
|
|
386
|
+
if (loaded.state !== 'planned')
|
|
387
|
+
throw new ServiceError('TRANSACTION_STATE_INVALID', `Transaction is ${loaded.state}`);
|
|
388
|
+
if (Date.parse(loaded.expiresAt) <= Date.now())
|
|
389
|
+
throw new ServiceError('TRANSACTION_EXPIRED', 'Transaction has expired');
|
|
390
|
+
return loaded;
|
|
391
|
+
};
|
|
392
|
+
// Refuse early without the lock, then decide on the journal as it is once the lock is
|
|
393
|
+
// held: a concurrent apply of the same plan may have finished in between.
|
|
394
|
+
await loadApplicable();
|
|
352
395
|
return this.withWorkspaceLock(workspace, transactionId, async () => {
|
|
396
|
+
const manifest = await loadApplicable();
|
|
353
397
|
await this.verifyReadDependencies(workspace, manifest, options.principal, options.signal);
|
|
354
398
|
await this.verifyCurrentFiles(workspace, manifest, 'before', options.principal, options.signal);
|
|
355
399
|
const targets = [];
|
|
@@ -379,8 +423,15 @@ export class TransactionManager {
|
|
|
379
423
|
if (actualBeforeReplace !== file.beforeSha256) {
|
|
380
424
|
throw new ServiceError('TRANSACTION_STALE', 'Source changed while transaction output was being staged');
|
|
381
425
|
}
|
|
382
|
-
if (existsBeforeReplace)
|
|
426
|
+
if (existsBeforeReplace) {
|
|
383
427
|
await rename(target.target, target.backup);
|
|
428
|
+
// An edit between the hash check and the rename would otherwise be discarded with
|
|
429
|
+
// the backup after a successful apply.
|
|
430
|
+
if (sha256Bytes(await readFile(target.backup)) !== file.beforeSha256) {
|
|
431
|
+
await rename(target.backup, target.target);
|
|
432
|
+
throw new ServiceError('TRANSACTION_STALE', 'Source changed while transaction output was being staged');
|
|
433
|
+
}
|
|
434
|
+
}
|
|
384
435
|
await options.hooks?.afterBackup?.(file.relativePath, index);
|
|
385
436
|
if (target.staged !== null) {
|
|
386
437
|
if (await fileExists(target.target)) {
|
|
@@ -597,6 +648,10 @@ export class TransactionManager {
|
|
|
597
648
|
signal?.throwIfAborted();
|
|
598
649
|
if (!transactionIdPattern.test(entry.name))
|
|
599
650
|
continue;
|
|
651
|
+
// A journal folder without a manifest is a plan that never committed: nothing was
|
|
652
|
+
// applied from it, and pruning removes it once it expires.
|
|
653
|
+
if (!(await fileExists(path.join(directory, entry.name, 'manifest.json'))))
|
|
654
|
+
continue;
|
|
600
655
|
const manifest = await this.load(workspace, entry.name, {
|
|
601
656
|
headMode: 'none',
|
|
602
657
|
rootBinding: 'ignored',
|
|
@@ -656,7 +711,10 @@ export class TransactionManager {
|
|
|
656
711
|
}
|
|
657
712
|
backupValid = true;
|
|
658
713
|
}
|
|
659
|
-
|
|
714
|
+
// A file this transaction never replaced keeps whatever it holds now, including an
|
|
715
|
+
// edit made while the transaction ran; it does not block restoring the others.
|
|
716
|
+
const untouched = !manifest.appliedFiles.includes(file.relativePath) && !backupExists;
|
|
717
|
+
if (currentHash === file.beforeSha256 || untouched) {
|
|
660
718
|
restorations.push({ file, target, backup, staged, mode: 'keep' });
|
|
661
719
|
continue;
|
|
662
720
|
}
|
|
@@ -720,7 +778,7 @@ export class TransactionManager {
|
|
|
720
778
|
if (await fileExists(restoration.staged))
|
|
721
779
|
await unlink(restoration.staged);
|
|
722
780
|
}
|
|
723
|
-
await this.verifyCurrentFiles(workspace, manifest, 'before', principal);
|
|
781
|
+
await this.verifyCurrentFiles(workspace, manifest, 'before', principal, undefined, new Set(restorations.filter(({ mode }) => mode !== 'keep').map(({ file }) => file.relativePath)));
|
|
724
782
|
manifest.rollbackStatus = 'applied';
|
|
725
783
|
}
|
|
726
784
|
safeFailure(error, fallbackCode, message) {
|
|
@@ -739,9 +797,11 @@ export class TransactionManager {
|
|
|
739
797
|
if ((manifest.principal ?? null) !== (principal ?? null))
|
|
740
798
|
throw new ServiceError('TRANSACTION_PRINCIPAL_MISMATCH', 'Transaction belongs to another principal');
|
|
741
799
|
}
|
|
742
|
-
async verifyCurrentFiles(workspace, manifest, phase, principal, signal) {
|
|
800
|
+
async verifyCurrentFiles(workspace, manifest, phase, principal, signal, only) {
|
|
743
801
|
for (const file of manifest.files) {
|
|
744
802
|
signal?.throwIfAborted();
|
|
803
|
+
if (only !== undefined && !only.has(file.relativePath))
|
|
804
|
+
continue;
|
|
745
805
|
const { path: target } = await this.resolver.resolvePath(workspace.id, file.relativePath, 'write', ['mod'], principal);
|
|
746
806
|
const exists = await fileExists(target);
|
|
747
807
|
const actual = exists ? sha256Bytes(await readFile(target)) : null;
|
|
@@ -1219,7 +1279,13 @@ export class TransactionManager {
|
|
|
1219
1279
|
return await action();
|
|
1220
1280
|
}
|
|
1221
1281
|
finally {
|
|
1222
|
-
|
|
1282
|
+
// Release only this holder's lock; never one another process holds after a takeover.
|
|
1283
|
+
const owner = await readFile(path.join(lock, 'owner.json'), 'utf8')
|
|
1284
|
+
.then((text) => JSON.parse(text))
|
|
1285
|
+
.catch(() => undefined);
|
|
1286
|
+
if (owner === undefined ||
|
|
1287
|
+
(owner.instanceId === lockInstanceId && owner.transactionId === transactionId))
|
|
1288
|
+
await rm(lock, { recursive: true, force: true });
|
|
1223
1289
|
}
|
|
1224
1290
|
}
|
|
1225
1291
|
async clearCrashedProcessLock(lock) {
|
|
@@ -1243,8 +1309,7 @@ export class TransactionManager {
|
|
|
1243
1309
|
catch {
|
|
1244
1310
|
return false;
|
|
1245
1311
|
}
|
|
1246
|
-
|
|
1247
|
-
return true;
|
|
1312
|
+
return this.retireStaleLock(lock, undefined);
|
|
1248
1313
|
}
|
|
1249
1314
|
if (typeof owner !== 'object' ||
|
|
1250
1315
|
owner === null ||
|
|
@@ -1272,7 +1337,33 @@ export class TransactionManager {
|
|
|
1272
1337
|
if (error.code !== 'ESRCH')
|
|
1273
1338
|
return false;
|
|
1274
1339
|
}
|
|
1275
|
-
|
|
1340
|
+
return this.retireStaleLock(lock, canonicalJson(owner));
|
|
1341
|
+
}
|
|
1342
|
+
/**
|
|
1343
|
+
* Move a lock judged stale aside under a unique name; only one process can win the rename.
|
|
1344
|
+
* The moved lock must still carry the owner that was judged stale (`undefined`: no owner
|
|
1345
|
+
* file). Otherwise a new holder took the lock in between, and it is put back untouched.
|
|
1346
|
+
*/
|
|
1347
|
+
async retireStaleLock(lock, judgedOwner) {
|
|
1348
|
+
const tombstone = `${lock}.stale-${randomUUID()}`;
|
|
1349
|
+
try {
|
|
1350
|
+
await rename(lock, tombstone);
|
|
1351
|
+
}
|
|
1352
|
+
catch {
|
|
1353
|
+
return false;
|
|
1354
|
+
}
|
|
1355
|
+
let movedOwner;
|
|
1356
|
+
try {
|
|
1357
|
+
movedOwner = canonicalJson(JSON.parse(await readFile(path.join(tombstone, 'owner.json'), 'utf8')));
|
|
1358
|
+
}
|
|
1359
|
+
catch {
|
|
1360
|
+
movedOwner = undefined;
|
|
1361
|
+
}
|
|
1362
|
+
if (movedOwner !== judgedOwner) {
|
|
1363
|
+
await rename(tombstone, lock).catch(() => undefined);
|
|
1364
|
+
return false;
|
|
1365
|
+
}
|
|
1366
|
+
await rm(tombstone, { recursive: true, force: true });
|
|
1276
1367
|
return true;
|
|
1277
1368
|
}
|
|
1278
1369
|
}
|
|
@@ -53,6 +53,49 @@ async function discoverDlcLayers(gameRoot) {
|
|
|
53
53
|
throw new ServiceError('WORKSPACE_DLC_LIMIT', `The game root lists more than ${MAX_DLC_LAYERS} DLC folders`);
|
|
54
54
|
return layers.sort((left, right) => left.id - right.id || compareCodeUnits(left.folder, right.folder));
|
|
55
55
|
}
|
|
56
|
+
/** Read a mod's `descriptor.mod` metadata, or `undefined` when it is absent or unreadable. */
|
|
57
|
+
export async function readModDescriptor(root) {
|
|
58
|
+
const descriptor = path.join(root, 'descriptor.mod');
|
|
59
|
+
let bytes;
|
|
60
|
+
try {
|
|
61
|
+
const metadata = await lstat(descriptor);
|
|
62
|
+
if (!metadata.isFile() || metadata.size > MAX_DESCRIPTOR_BYTES)
|
|
63
|
+
return undefined;
|
|
64
|
+
bytes = await readFile(descriptor);
|
|
65
|
+
}
|
|
66
|
+
catch {
|
|
67
|
+
return undefined;
|
|
68
|
+
}
|
|
69
|
+
const document = parseClausewitz(bytes, descriptor);
|
|
70
|
+
const result = { tags: [], dependencies: [], replacePaths: [] };
|
|
71
|
+
const text = (value) => value.slice(0, 256);
|
|
72
|
+
for (const entry of document.root.entries) {
|
|
73
|
+
if (entry.type !== 'assignment')
|
|
74
|
+
continue;
|
|
75
|
+
const key = entry.key.value.toLowerCase();
|
|
76
|
+
if (entry.value.type === 'scalar') {
|
|
77
|
+
const value = text(entry.value.value);
|
|
78
|
+
if (key === 'name')
|
|
79
|
+
result.name = value;
|
|
80
|
+
else if (key === 'version')
|
|
81
|
+
result.version = value;
|
|
82
|
+
else if (key === 'supported_version')
|
|
83
|
+
result.supportedVersion = value;
|
|
84
|
+
else if (key === 'remote_file_id')
|
|
85
|
+
result.remoteFileId = value;
|
|
86
|
+
else if (key === 'replace_path' && result.replacePaths.length < 64)
|
|
87
|
+
result.replacePaths.push(value);
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
const list = key === 'tags' ? result.tags : key === 'dependencies' ? result.dependencies : undefined;
|
|
91
|
+
if (list === undefined)
|
|
92
|
+
continue;
|
|
93
|
+
for (const item of entry.value.entries)
|
|
94
|
+
if (item.type === 'scalar' && list.length < 64)
|
|
95
|
+
list.push(text(item.value));
|
|
96
|
+
}
|
|
97
|
+
return result;
|
|
98
|
+
}
|
|
56
99
|
/**
|
|
57
100
|
* `replace_path` entries from a mod descriptor. The engine unloads earlier-loaded files
|
|
58
101
|
* under each path. Entries that are not safe relative folders are ignored.
|
|
@@ -175,12 +218,12 @@ function requestedWorkspaceAlias(value) {
|
|
|
175
218
|
export function resolvedWorkspaceIdentity(workspace) {
|
|
176
219
|
return hashCanonical({
|
|
177
220
|
id: workspace.id,
|
|
178
|
-
roots: workspace.roots.map(({ kind, path: rootPath, loadOrder, writable, replacePaths }) => ({
|
|
221
|
+
roots: workspace.roots.map(({ kind, path: rootPath, loadOrder, writable, replacePaths, identityReplacePaths }) => ({
|
|
179
222
|
kind,
|
|
180
223
|
rootHash: sha256Bytes(process.platform === 'win32' ? rootPath.toLowerCase() : rootPath),
|
|
181
224
|
loadOrder,
|
|
182
225
|
writable,
|
|
183
|
-
replacePaths,
|
|
226
|
+
replacePaths: identityReplacePaths ?? replacePaths,
|
|
184
227
|
})),
|
|
185
228
|
behavior: {
|
|
186
229
|
kind: workspace.registration.kind,
|
|
@@ -246,6 +289,8 @@ function assertRelative(relativePath) {
|
|
|
246
289
|
export class WorkspaceResolver {
|
|
247
290
|
configuration;
|
|
248
291
|
#byId = new Map();
|
|
292
|
+
/** Size and modification time of each descriptor when its `replace_path` entries were read. */
|
|
293
|
+
#descriptorStamps = new Map();
|
|
249
294
|
#discoveredWorkspaceIds = new Set();
|
|
250
295
|
#storageRoots = [];
|
|
251
296
|
#modRoots = [];
|
|
@@ -454,6 +499,34 @@ export class WorkspaceResolver {
|
|
|
454
499
|
.filter((workspace) => allowed === undefined || allowed.has(workspace.id))
|
|
455
500
|
.sort((a, b) => compareCodeUnits(a.id, b.id));
|
|
456
501
|
}
|
|
502
|
+
/**
|
|
503
|
+
* Re-read the `replace_path` entries of the workspace's mod and dependency descriptors when a
|
|
504
|
+
* descriptor changed since they were last read. A server keeps running while a rewrite such as
|
|
505
|
+
* a new world adds folders to the descriptor, and later scans must hide the replaced folders.
|
|
506
|
+
*/
|
|
507
|
+
async refreshDescriptors(workspaceId, principal) {
|
|
508
|
+
const workspace = this.get(workspaceId, principal);
|
|
509
|
+
if (workspace.registration.kind === 'game')
|
|
510
|
+
return;
|
|
511
|
+
const dependencies = workspace.registration.dependencies;
|
|
512
|
+
for (const root of workspace.roots) {
|
|
513
|
+
if (root.kind !== 'mod' && root.kind !== 'dependency')
|
|
514
|
+
continue;
|
|
515
|
+
const descriptor = path.join(root.path, 'descriptor.mod');
|
|
516
|
+
const stamp = await lstat(descriptor).then((metadata) => `${metadata.size}:${metadata.mtimeMs}`, () => 'missing');
|
|
517
|
+
const key = `${workspace.id}\0${root.path}`;
|
|
518
|
+
if (this.#descriptorStamps.get(key) === stamp)
|
|
519
|
+
continue;
|
|
520
|
+
this.#descriptorStamps.set(key, stamp);
|
|
521
|
+
const index = workspace.dependencyRoots.indexOf(root.path);
|
|
522
|
+
const configured = root.kind === 'mod'
|
|
523
|
+
? workspace.registration.replacePaths
|
|
524
|
+
: (dependencies[index]?.replacePaths ?? []);
|
|
525
|
+
root.replacePaths = [
|
|
526
|
+
...new Set([...configured, ...(await descriptorReplacePaths(root.path))]),
|
|
527
|
+
];
|
|
528
|
+
}
|
|
529
|
+
}
|
|
457
530
|
get(workspaceId, principal) {
|
|
458
531
|
const resolvedWorkspaceId = this.resolveWorkspaceId(workspaceId, principal);
|
|
459
532
|
if (principal !== undefined && !this.allowedWorkspaceIds(principal).has(resolvedWorkspaceId)) {
|
|
@@ -476,7 +549,9 @@ export class WorkspaceResolver {
|
|
|
476
549
|
.split('/')
|
|
477
550
|
.find((segment) => segment !== '.' && segment.length > 0)
|
|
478
551
|
?.toLowerCase();
|
|
479
|
-
|
|
552
|
+
// Server storage holds other principals' proposals and journals; it is never mod source,
|
|
553
|
+
// for reads as well as writes.
|
|
554
|
+
if (firstSegment === '.hoi4-agent') {
|
|
480
555
|
throw new ServiceError('PATH_GENERATED_STORAGE_RESERVED', 'Generated .hoi4-agent storage cannot be targeted as mod source');
|
|
481
556
|
}
|
|
482
557
|
const roots = workspace.roots.filter((root) => (rootKinds === undefined || rootKinds.includes(root.kind)) &&
|
|
@@ -487,12 +562,12 @@ export class WorkspaceResolver {
|
|
|
487
562
|
continue;
|
|
488
563
|
if (access === 'read' && !(await exists(candidate)))
|
|
489
564
|
continue;
|
|
565
|
+
if (root.kind === 'mod' &&
|
|
566
|
+
(pathsOverlap(workspace.artifactRoot, candidate) ||
|
|
567
|
+
pathsOverlap(workspace.cacheRoot, candidate))) {
|
|
568
|
+
throw new ServiceError('PATH_GENERATED_STORAGE_RESERVED', 'Artifact and cache storage cannot be targeted as mod source');
|
|
569
|
+
}
|
|
490
570
|
if (access === 'write') {
|
|
491
|
-
if (root.kind === 'mod' &&
|
|
492
|
-
(pathsOverlap(workspace.artifactRoot, candidate) ||
|
|
493
|
-
pathsOverlap(workspace.cacheRoot, candidate))) {
|
|
494
|
-
throw new ServiceError('PATH_GENERATED_STORAGE_RESERVED', 'Artifact and cache storage cannot be targeted as mod source');
|
|
495
|
-
}
|
|
496
571
|
const existingParent = await canonicalPath(path.dirname(candidate));
|
|
497
572
|
if (!isWithin(root.path, existingParent))
|
|
498
573
|
continue;
|
|
@@ -698,6 +773,7 @@ export class WorkspaceResolver {
|
|
|
698
773
|
];
|
|
699
774
|
for (const root of roots) {
|
|
700
775
|
root.path = await canonicalPath(root.path);
|
|
776
|
+
root.identityReplacePaths = root.replacePaths;
|
|
701
777
|
}
|
|
702
778
|
const workspaceBase = {
|
|
703
779
|
id: registration.id,
|
|
@@ -85,7 +85,7 @@ export async function inspectDecisionAi(engine, before, id, scenarios, overlay,
|
|
|
85
85
|
...(overlay === undefined
|
|
86
86
|
? {}
|
|
87
87
|
: {
|
|
88
|
-
proposedUnresolved: 'The proposal changes multiple sources or adds/removes the weighted decision; use
|
|
88
|
+
proposedUnresolved: 'The proposal changes multiple sources or adds/removes the weighted decision; use hoi4.probability_analyze with analysis compare and explicit source selectors',
|
|
89
89
|
}),
|
|
90
90
|
};
|
|
91
91
|
}
|
|
@@ -150,6 +150,34 @@ export class DecisionAnalyzer {
|
|
|
150
150
|
overrides: inventory.overrides.length,
|
|
151
151
|
scenarios: input.mode === 'inventory' ? 0 : input.scenarios.length,
|
|
152
152
|
unresolvedDefinitions: inventory.unresolvedDefinitions.length,
|
|
153
|
+
...(inventory.unresolvedDefinitions.length === 0
|
|
154
|
+
? {}
|
|
155
|
+
: {
|
|
156
|
+
unresolvedSample: inventory.unresolvedDefinitions
|
|
157
|
+
.slice(0, 8)
|
|
158
|
+
.map(({ id, path, reason }) => ({ id, path, reason })),
|
|
159
|
+
}),
|
|
160
|
+
// The gate outcome of each scenario answers most inspections without the report.
|
|
161
|
+
...(input.mode === 'inspect' && Array.isArray(analysis)
|
|
162
|
+
? {
|
|
163
|
+
outcomes: analysis.slice(0, 16).map(({ scenarioId, inspection }) => inspection === null
|
|
164
|
+
? { scenarioId, found: false }
|
|
165
|
+
: {
|
|
166
|
+
scenarioId,
|
|
167
|
+
found: true,
|
|
168
|
+
eligible: inspection.gates.eligible,
|
|
169
|
+
visible: inspection.gates.visible,
|
|
170
|
+
available: inspection.gates.available,
|
|
171
|
+
...(inspection.gates.unresolved.length === 0
|
|
172
|
+
? {}
|
|
173
|
+
: {
|
|
174
|
+
unresolved: inspection.gates.unresolved
|
|
175
|
+
.slice(0, 5)
|
|
176
|
+
.map(({ code, message }) => ({ code, message: message.slice(0, 300) })),
|
|
177
|
+
}),
|
|
178
|
+
}),
|
|
179
|
+
}
|
|
180
|
+
: {}),
|
|
153
181
|
});
|
|
154
182
|
result.code = complete ? 'DECISION_ANALYZED' : 'DECISION_ANALYZED_PARTIAL';
|
|
155
183
|
result.artifacts = [publicArtifactLink(artifact)];
|
|
@@ -137,12 +137,14 @@ export function decisionSourceInventory(snapshot) {
|
|
|
137
137
|
const category = typeof symbol.metadata.category === 'string' ? symbol.metadata.category : undefined;
|
|
138
138
|
const file = files.get(symbol.path);
|
|
139
139
|
const document = sourceDocument(file, documents);
|
|
140
|
-
|
|
140
|
+
// A file may open the same category block more than once; the decision is in one of them.
|
|
141
|
+
const decisionNode = document === undefined || category === undefined
|
|
141
142
|
? undefined
|
|
142
|
-
:
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
143
|
+
: assignments(document.root, category)
|
|
144
|
+
.flatMap((categoryAssignment) => categoryAssignment.value.type === 'block'
|
|
145
|
+
? assignments(categoryAssignment.value, symbol.id)
|
|
146
|
+
: [])
|
|
147
|
+
.find((assignment) => assignment.value.type === 'block')?.value;
|
|
146
148
|
if (file === undefined ||
|
|
147
149
|
document === undefined ||
|
|
148
150
|
category === undefined ||
|
|
@@ -2,8 +2,8 @@ import { z } from 'zod/v4';
|
|
|
2
2
|
import { IdleCacheLifetime } from '../core/idle-cache-lifetime.js';
|
|
3
3
|
import { errorResult, toolResult } from '../core/operation-result.js';
|
|
4
4
|
import { ServiceError } from '../core/result.js';
|
|
5
|
-
import { eventInspectRequestSchema, eventRenderRequestSchema
|
|
6
|
-
import {
|
|
5
|
+
import { eventInspectRequestSchema, eventRenderRequestSchema } from '../schemas/event.js';
|
|
6
|
+
import { inspectOrCompareEvents, renderEvents, normalizeEventInspectionRequest, normalizeEventRenderRequest, } from './operations.js';
|
|
7
7
|
import { EventChainViewer } from './service.js';
|
|
8
8
|
const wireResult = z.record(z.string(), z.json());
|
|
9
9
|
/** Registers typed event operations without invoking transport handlers. */
|
|
@@ -30,7 +30,12 @@ export function registerEventJobs(operations, engine) {
|
|
|
30
30
|
'revision' in result.data &&
|
|
31
31
|
typeof result.data.revision === 'string'
|
|
32
32
|
? result.data.revision
|
|
33
|
-
:
|
|
33
|
+
: result.data !== null &&
|
|
34
|
+
typeof result.data === 'object' &&
|
|
35
|
+
'afterRevision' in result.data &&
|
|
36
|
+
typeof result.data.afterRevision === 'string'
|
|
37
|
+
? result.data.afterRevision
|
|
38
|
+
: undefined;
|
|
34
39
|
if (revision !== undefined)
|
|
35
40
|
await context.stageResult(wire, { sourceRevision: revision });
|
|
36
41
|
return wire;
|
|
@@ -45,7 +50,6 @@ export function registerEventJobs(operations, engine) {
|
|
|
45
50
|
}
|
|
46
51
|
});
|
|
47
52
|
}
|
|
48
|
-
register('hoi4.event_inspect', eventInspectRequestSchema, normalizeEventInspectionRequest,
|
|
53
|
+
register('hoi4.event_inspect', eventInspectRequestSchema, normalizeEventInspectionRequest, inspectOrCompareEvents, 'Analyzing event chain');
|
|
49
54
|
register('hoi4.event_render', eventRenderRequestSchema, normalizeEventRenderRequest, renderEvents, 'Rendering event-chain view');
|
|
50
|
-
register('hoi4.event_compare', eventCompareRequestSchema, normalizeEventCompareRequest, compareEvents, 'Comparing event-chain graphs');
|
|
51
55
|
}
|
|
@@ -3,13 +3,26 @@ import { helperExpansionValidation } from '../core/helper-expansion.js';
|
|
|
3
3
|
import { publicArtifactLink } from '../core/artifacts.js';
|
|
4
4
|
import { setInlineFilesScanned } from '../core/operation-result.js';
|
|
5
5
|
import { emptyServiceResult } from '../core/result.js';
|
|
6
|
-
import { eventInspectRequestSchema, eventRenderRequestSchema, eventCompareRequestSchema, } from '../schemas/event.js';
|
|
6
|
+
import { eventInspectRequestSchema, splitEventInspectRequest, eventRenderRequestSchema, eventCompareRequestSchema, } from '../schemas/event.js';
|
|
7
7
|
import { eventDiagnostics, } from './service.js';
|
|
8
8
|
export function normalizeEventInspectionRequest(input) {
|
|
9
9
|
// Validated request fields are JSON-only. Match wire semantics by removing optional
|
|
10
10
|
// undefined fields, including nested source-selector columns, before service dispatch.
|
|
11
11
|
return JSON.parse(JSON.stringify(eventInspectRequestSchema.parse(input)));
|
|
12
12
|
}
|
|
13
|
+
/** Route mode compare to the graph comparison and every other mode to inspection. */
|
|
14
|
+
export function inspectOrCompareEvents(viewer, input) {
|
|
15
|
+
const split = splitEventInspectRequest(input);
|
|
16
|
+
if (!split.compare)
|
|
17
|
+
return inspectEvents(viewer, input);
|
|
18
|
+
const { signal, principal } = input;
|
|
19
|
+
return compareEvents(viewer, {
|
|
20
|
+
...JSON.parse(JSON.stringify(split.request)),
|
|
21
|
+
workspaceId: input.workspaceId,
|
|
22
|
+
...(principal === undefined ? {} : { principal }),
|
|
23
|
+
...(signal === undefined ? {} : { signal }),
|
|
24
|
+
});
|
|
25
|
+
}
|
|
13
26
|
export function normalizeEventRenderRequest(input) {
|
|
14
27
|
return JSON.parse(JSON.stringify(eventRenderRequestSchema.parse(input)));
|
|
15
28
|
}
|
|
@@ -37,8 +37,7 @@ export function registerFocusJobs(operations, engine) {
|
|
|
37
37
|
});
|
|
38
38
|
}
|
|
39
39
|
register('hoi4.focus_inspect', focusInspectRequestSchema, normalizeFocusInspectRequest, (input, context) => inspectFocus(engine, workbench, input, context));
|
|
40
|
-
register('hoi4.focus_render', focusRenderRequestSchema, normalizeFocusRenderRequest, (input, context) => renderFocus(engine, workbench, input, context, false));
|
|
41
|
-
register('hoi4.focus_raster', focusRenderRequestSchema, normalizeFocusRenderRequest, (input, context) => renderFocus(engine, workbench, input, context, true));
|
|
40
|
+
register('hoi4.focus_render', focusRenderRequestSchema, normalizeFocusRenderRequest, (input, context) => renderFocus(engine, workbench, input, context, input.png !== false || (input.cropFocusIds?.length ?? 0) > 0));
|
|
42
41
|
operations.registerWrite('hoi4.focus_rewrite', focusRewriteRequestSchema, async (input, context) => {
|
|
43
42
|
if (input.workspaceId !== 'current' && input.workspaceId !== context.workspaceId)
|
|
44
43
|
throw new ServiceError('JOB_ARGUMENT_SCOPE_MISMATCH', 'The request workspace does not match the authorized job workspace');
|
|
@@ -583,7 +583,7 @@ export async function inspectFocus(engine, workbench, input, context) {
|
|
|
583
583
|
export async function renderFocus(engine, workbench, input, context, rasterize) {
|
|
584
584
|
const outputName = rasterize ? 'raster' : 'render';
|
|
585
585
|
if (!rasterize && (input.cropFocusIds?.length ?? 0) > 0)
|
|
586
|
-
throw new ServiceError('FOCUS_CROP_REQUIRES_RASTER', 'Focus problem crops
|
|
586
|
+
throw new ServiceError('FOCUS_CROP_REQUIRES_RASTER', 'Focus problem crops are PNG images; omit png: false');
|
|
587
587
|
await context.progress(0, 4, 'Importing and indexing focus source');
|
|
588
588
|
const workspace = engine.resolver.get(context.workspaceId, context.principal);
|
|
589
589
|
const snapshot = await engine.scan(context.workspaceId, { patterns: focusDomainScanPatterns(workspace) }, context.principal, context.signal);
|