hoi4-agent-tools 4.0.3 → 4.0.5
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 +18 -0
- package/dist/hoi4_agent_tools/core/clausewitz-evaluation.js +5 -3
- package/dist/hoi4_agent_tools/core/condition-evaluator.js +5 -1
- package/dist/hoi4_agent_tools/core/index.js +11 -0
- package/dist/hoi4_agent_tools/core/job-executor.js +3 -1
- package/dist/hoi4_agent_tools/core/job-worker-host.js +1 -1
- package/dist/hoi4_agent_tools/core/render-progress.js +7 -0
- package/dist/hoi4_agent_tools/gui/layout.js +76 -33
- package/dist/hoi4_agent_tools/gui/native-state.js +40 -1
- package/dist/hoi4_agent_tools/gui/scenario-generator.js +3 -3
- package/dist/hoi4_agent_tools/gui/source-graph.js +67 -32
- package/dist/hoi4_agent_tools/gui/studio.js +101 -32
- package/dist/hoi4_agent_tools/gui/tool-operations.js +8 -2
- package/dist/hoi4_agent_tools/mcp/server/create-modern-operations.js +2 -1
- package/dist/hoi4_agent_tools/mcp/server/job-task-store.js +3 -1
- package/dist/hoi4_agent_tools/mcp/server/modern-tasks.js +2 -0
- package/dist/hoi4_agent_tools/mcp/server/progress.js +12 -0
- package/dist/hoi4_agent_tools/mcp/server/request-lifecycle.js +2 -2
- package/dist/hoi4_agent_tools/mcp/server/task-request-context.js +5 -2
- package/dist/hoi4_agent_tools/technology/job-operations.js +1 -0
- package/dist/hoi4_agent_tools/technology/service.js +18 -37
- package/dist/hoi4_agent_tools/version.js +1 -1
- package/docs/gui.md +14 -2
- package/docs/technology.md +2 -0
- package/npm-shrinkwrap.json +2 -2
- package/package.json +1 -1
- package/schemas/configuration.schema.json +1 -1
- 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 +1 -1
- 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 +1 -1
- package/schemas/mechanic-test-input.schema.json +1 -1
- package/schemas/mod-index-input.schema.json +1 -1
- package/schemas/operation-result.schema.json +1 -1
- 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 +1 -1
- package/schemas/scenario-test-input.schema.json +1 -1
- 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 +2 -2
|
@@ -2,6 +2,7 @@ import path from 'node:path';
|
|
|
2
2
|
import { compareCodeUnits, deterministicId } from '../core/canonical.js';
|
|
3
3
|
import { DiagnosticCollector } from '../core/diagnostic-collector.js';
|
|
4
4
|
import { sortDiagnostics } from '../core/diagnostics.js';
|
|
5
|
+
import { SymbolIndex } from '../core/index.js';
|
|
5
6
|
import { ServiceError } from '../core/result.js';
|
|
6
7
|
import { GUI_GRAPH_MAX_EDGES, GUI_GRAPH_MAX_ELEMENTS, GUI_GRAPH_MAX_NODES } from './limits.js';
|
|
7
8
|
import { assignments, childBlocks, firstScalar, locationFor, nodeLocation, parseClausewitz, parseLocalisation, } from '../core/source/index.js';
|
|
@@ -118,7 +119,7 @@ export function guiElementAttributeFidelity(key, elementType) {
|
|
|
118
119
|
if (elementType?.toLowerCase() === 'extendedscrollbartype' &&
|
|
119
120
|
['horizontal', 'slider', 'track', 'decreaseButton', 'increaseButton'].some((candidate) => candidate.toLowerCase() === key.toLowerCase()))
|
|
120
121
|
return 'modelled';
|
|
121
|
-
if (/^(?:containerwindowtype|windowtype)$/iu.test(elementType ?? '') &&
|
|
122
|
+
if (/^(?:containerwindowtype|windowtype|expandedwindow)$/iu.test(elementType ?? '') &&
|
|
122
123
|
['verticalScrollbar', 'horizontalScrollbar', 'autohide_scrollbars', 'margin'].some((candidate) => candidate.toLowerCase() === key.toLowerCase()))
|
|
123
124
|
return 'structural';
|
|
124
125
|
if (elementType?.toLowerCase() === 'scrollbartype' &&
|
|
@@ -297,9 +298,12 @@ function nestedSizeFrom(block, key) {
|
|
|
297
298
|
function raw(document, node) {
|
|
298
299
|
return document.text.slice(node.start, node.end);
|
|
299
300
|
}
|
|
300
|
-
function isGuiElement(assignment) {
|
|
301
|
+
function isGuiElement(assignment, parentType) {
|
|
301
302
|
if (assignment.value.type !== 'block')
|
|
302
303
|
return false;
|
|
304
|
+
if (parentType?.toLowerCase() === 'dropdownboxtype' &&
|
|
305
|
+
/^(?:expandedwindow|expandbutton)$/iu.test(assignment.key.value))
|
|
306
|
+
return true;
|
|
303
307
|
if (explicitGuiElementTypes.has(assignment.key.value.toLowerCase()))
|
|
304
308
|
return true;
|
|
305
309
|
return (/type$/iu.test(assignment.key.value) && firstScalar(assignment.value, 'name') !== undefined);
|
|
@@ -362,11 +366,11 @@ function indexGuiElements(document, file, fileNodeId, nodes, edges, elements) {
|
|
|
362
366
|
const constants = constantsFor(document);
|
|
363
367
|
const firstFileElement = elements.length;
|
|
364
368
|
let order = 0;
|
|
365
|
-
const walk = (block, parentId) => {
|
|
369
|
+
const walk = (block, parentId, parentType) => {
|
|
366
370
|
for (const assignment of assignments(block)) {
|
|
367
371
|
if (assignment.value.type !== 'block')
|
|
368
372
|
continue;
|
|
369
|
-
if (isGuiElement(assignment)) {
|
|
373
|
+
if (isGuiElement(assignment, parentType)) {
|
|
370
374
|
const name = firstScalar(assignment.value, 'name')?.value;
|
|
371
375
|
if (name === undefined)
|
|
372
376
|
continue;
|
|
@@ -385,7 +389,7 @@ function indexGuiElements(document, file, fileNodeId, nodes, edges, elements) {
|
|
|
385
389
|
childIds: [],
|
|
386
390
|
attributes: attributes,
|
|
387
391
|
unsupportedAttributes: assignments(assignment.value)
|
|
388
|
-
.filter((child) => !isGuiElement(child))
|
|
392
|
+
.filter((child) => !isGuiElement(child, assignment.key.value))
|
|
389
393
|
.map(({ key }) => key.value)
|
|
390
394
|
.filter((key) => guiElementAttributeFidelity(key, assignment.key.value) === 'unsupported')
|
|
391
395
|
.sort((a, b) => compareCodeUnits(a, b)),
|
|
@@ -408,7 +412,7 @@ function indexGuiElements(document, file, fileNodeId, nodes, edges, elements) {
|
|
|
408
412
|
addEdge(edges, 'contains', parentId ?? fileNodeId, id, true, {}, element.location);
|
|
409
413
|
if (parentId !== undefined)
|
|
410
414
|
addEdge(edges, 'parent', id, parentId, true, {}, element.location);
|
|
411
|
-
walk(assignment.value, id);
|
|
415
|
+
walk(assignment.value, id, assignment.key.value);
|
|
412
416
|
}
|
|
413
417
|
else {
|
|
414
418
|
walk(assignment.value, parentId);
|
|
@@ -1306,7 +1310,8 @@ function referenceLabel(edge) {
|
|
|
1306
1310
|
return String(value);
|
|
1307
1311
|
return value === undefined ? edge.to : JSON.stringify(value);
|
|
1308
1312
|
}
|
|
1309
|
-
export function buildGuiSourceGraph(files,
|
|
1313
|
+
export function buildGuiSourceGraph(files, providedIndex, additionalLocalisationKeys = [], limits = {}) {
|
|
1314
|
+
const sharedIndex = providedIndex ?? new SymbolIndex();
|
|
1310
1315
|
const activeFiles = files.filter(({ shadowedBy }) => shadowedBy === undefined);
|
|
1311
1316
|
const nodes = [];
|
|
1312
1317
|
const edges = [];
|
|
@@ -1323,25 +1328,6 @@ export function buildGuiSourceGraph(files, sharedIndex, additionalLocalisationKe
|
|
|
1323
1328
|
category: 'layout',
|
|
1324
1329
|
message: 'GUI source graph diagnostics exceeded the fixed global result ceiling',
|
|
1325
1330
|
});
|
|
1326
|
-
diagnostics.pushMany(sharedIndex.diagnostics);
|
|
1327
|
-
if (!sharedIndex.complete) {
|
|
1328
|
-
diagnostics.push({
|
|
1329
|
-
code: 'GUI_INVENTORY_PARTIAL',
|
|
1330
|
-
severity: 'warning',
|
|
1331
|
-
category: 'reference',
|
|
1332
|
-
message: sharedIndex.skippedSourceCount === 0
|
|
1333
|
-
? 'The GUI inventory is incomplete because the shared index reached a fixed record limit'
|
|
1334
|
-
: `The GUI inventory skipped ${sharedIndex.skippedSourceCount} over-limit source${sharedIndex.skippedSourceCount === 1 ? '' : 's'}`,
|
|
1335
|
-
details: {
|
|
1336
|
-
skippedSourceCount: sharedIndex.skippedSourceCount,
|
|
1337
|
-
retainedSkippedSources: sharedIndex.skippedSources.length,
|
|
1338
|
-
skippedSources: sharedIndex.skippedSources.slice(0, 10).map(({ path, reasonCodes }) => ({
|
|
1339
|
-
path,
|
|
1340
|
-
reasonCodes,
|
|
1341
|
-
})),
|
|
1342
|
-
},
|
|
1343
|
-
});
|
|
1344
|
-
}
|
|
1345
1331
|
const sourceHashes = {};
|
|
1346
1332
|
const textureNodes = new Map();
|
|
1347
1333
|
const fontAssetNodes = new Map();
|
|
@@ -1423,13 +1409,20 @@ export function buildGuiSourceGraph(files, sharedIndex, additionalLocalisationKe
|
|
|
1423
1409
|
if (kind === undefined || fileNodeId === undefined) {
|
|
1424
1410
|
if (/\.(?:txt|gui|gfx)$/u.test(normalized)) {
|
|
1425
1411
|
const document = parseClausewitz(file.bytes, file.displayPath);
|
|
1412
|
+
if (providedIndex === undefined &&
|
|
1413
|
+
!sharedIndex.recordSourceCoverage(file, document.diagnostics))
|
|
1414
|
+
continue;
|
|
1426
1415
|
if (file.rootKind === 'mod' || file.rootKind === 'fixture')
|
|
1427
1416
|
diagnostics.pushMany(document.diagnostics);
|
|
1428
|
-
|
|
1417
|
+
if (/^common\/decision(?:s|_categories)\//u.test(normalized))
|
|
1418
|
+
indexDecisionEntries(document, file, nodes, pendingDecisionEdges);
|
|
1429
1419
|
}
|
|
1430
1420
|
continue;
|
|
1431
1421
|
}
|
|
1432
1422
|
const document = parseClausewitz(file.bytes, file.displayPath);
|
|
1423
|
+
if (providedIndex === undefined &&
|
|
1424
|
+
!sharedIndex.recordSourceCoverage(file, document.diagnostics))
|
|
1425
|
+
continue;
|
|
1433
1426
|
if (file.rootKind === 'mod' || file.rootKind === 'fixture')
|
|
1434
1427
|
diagnostics.pushMany(document.diagnostics);
|
|
1435
1428
|
if (kind === 'gui_file')
|
|
@@ -1451,14 +1444,22 @@ export function buildGuiSourceGraph(files, sharedIndex, additionalLocalisationKe
|
|
|
1451
1444
|
const referencedLocalisationKeys = referencedGuiLocalisationKeys(elements, scriptedLocalisation);
|
|
1452
1445
|
for (const key of additionalLocalisationKeys)
|
|
1453
1446
|
referencedLocalisationKeys.add(key);
|
|
1447
|
+
const maximumCandidates = Math.min(GUI_GRAPH_MAX_NODES, limits.maximumLocalisationCandidates ?? GUI_GRAPH_MAX_NODES);
|
|
1448
|
+
if (!Number.isSafeInteger(maximumCandidates) || maximumCandidates < 1)
|
|
1449
|
+
throw new ServiceError('GUI_LOCALISATION_BUDGET_INVALID', 'Localisation candidate limit must be a positive safe integer');
|
|
1450
|
+
let candidateCount = 0;
|
|
1451
|
+
const localisationCandidates = new Map();
|
|
1454
1452
|
for (const { file, fileNodeId } of localisationFiles.sort((left, right) => right.file.loadOrder - left.file.loadOrder ||
|
|
1455
1453
|
compareCodeUnits(left.file.displayPath, right.file.displayPath))) {
|
|
1456
1454
|
const document = parseLocalisation(file.bytes, file.displayPath);
|
|
1455
|
+
if (providedIndex === undefined &&
|
|
1456
|
+
!sharedIndex.recordSourceCoverage(file, document.diagnostics))
|
|
1457
|
+
continue;
|
|
1457
1458
|
if (file.rootKind === 'mod' || file.rootKind === 'fixture')
|
|
1458
1459
|
diagnostics.pushMany(document.diagnostics);
|
|
1459
1460
|
for (const entry of document.entries) {
|
|
1460
|
-
if (
|
|
1461
|
-
|
|
1461
|
+
if (++candidateCount > maximumCandidates)
|
|
1462
|
+
throw new ServiceError('GUI_LOCALISATION_BUDGET_BLOCKED', 'Localisation dependency inventory exceeds the fixed candidate ceiling', { maximumCandidates });
|
|
1462
1463
|
const value = {
|
|
1463
1464
|
key: entry.key,
|
|
1464
1465
|
language: entry.language,
|
|
@@ -1466,21 +1467,55 @@ export function buildGuiSourceGraph(files, sharedIndex, additionalLocalisationKe
|
|
|
1466
1467
|
sourcePath: file.displayPath,
|
|
1467
1468
|
location: locationFor(file.displayPath, document.lineIndex, entry.start, entry.end),
|
|
1468
1469
|
};
|
|
1470
|
+
const candidates = localisationCandidates.get(entry.key) ?? [];
|
|
1471
|
+
candidates.push({ value, fileNodeId });
|
|
1472
|
+
localisationCandidates.set(entry.key, candidates);
|
|
1473
|
+
}
|
|
1474
|
+
}
|
|
1475
|
+
const pendingKeys = [...referencedLocalisationKeys];
|
|
1476
|
+
for (const key of pendingKeys) {
|
|
1477
|
+
for (const { value, fileNodeId } of localisationCandidates.get(key) ?? []) {
|
|
1469
1478
|
addDomainEntry(localisation, value, 'localisation');
|
|
1470
1479
|
const id = deterministicId('gui_loc', value);
|
|
1471
1480
|
addNode(nodes, {
|
|
1472
1481
|
id,
|
|
1473
1482
|
kind: 'localisation',
|
|
1474
|
-
name:
|
|
1475
|
-
path:
|
|
1483
|
+
name: key,
|
|
1484
|
+
path: value.sourcePath,
|
|
1476
1485
|
...(value.location === undefined ? {} : { location: value.location }),
|
|
1477
|
-
metadata: { language:
|
|
1486
|
+
metadata: { language: value.language, value: value.value },
|
|
1478
1487
|
});
|
|
1479
1488
|
addEdge(edges, 'contains', fileNodeId, id, true, {}, value.location);
|
|
1489
|
+
for (const match of value.value.matchAll(/\$([A-Za-z0-9_.-]+)(?:\|[^$]*)?\$/gu)) {
|
|
1490
|
+
const dependency = match[1];
|
|
1491
|
+
if (!referencedLocalisationKeys.has(dependency)) {
|
|
1492
|
+
referencedLocalisationKeys.add(dependency);
|
|
1493
|
+
pendingKeys.push(dependency);
|
|
1494
|
+
}
|
|
1495
|
+
}
|
|
1480
1496
|
}
|
|
1481
1497
|
}
|
|
1482
1498
|
linkGraph(nodes, edges, elements, sprites, fonts, scriptedGuis, scriptedLocalisation, localisation, textureNodes, fontAssetNodes, pendingDecisionEdges);
|
|
1483
1499
|
linkAnimationSources(nodes, edges, animationSources, sprites, textureNodes, animationFrameNodes);
|
|
1500
|
+
diagnostics.pushMany(sharedIndex.diagnostics);
|
|
1501
|
+
if (!sharedIndex.complete) {
|
|
1502
|
+
diagnostics.push({
|
|
1503
|
+
code: 'GUI_INVENTORY_PARTIAL',
|
|
1504
|
+
severity: 'warning',
|
|
1505
|
+
category: 'reference',
|
|
1506
|
+
message: sharedIndex.skippedSourceCount === 0
|
|
1507
|
+
? 'The GUI inventory is incomplete because the shared index reached a fixed record limit'
|
|
1508
|
+
: `The GUI inventory skipped ${sharedIndex.skippedSourceCount} over-limit source${sharedIndex.skippedSourceCount === 1 ? '' : 's'}`,
|
|
1509
|
+
details: {
|
|
1510
|
+
skippedSourceCount: sharedIndex.skippedSourceCount,
|
|
1511
|
+
retainedSkippedSources: sharedIndex.skippedSources.length,
|
|
1512
|
+
skippedSources: sharedIndex.skippedSources.slice(0, 10).map(({ path, reasonCodes }) => ({
|
|
1513
|
+
path,
|
|
1514
|
+
reasonCodes,
|
|
1515
|
+
})),
|
|
1516
|
+
},
|
|
1517
|
+
});
|
|
1518
|
+
}
|
|
1484
1519
|
markPartialReferenceEdges(edges, sharedIndex);
|
|
1485
1520
|
diagnostics.pushMany(referenceDiagnostics(edges));
|
|
1486
1521
|
if (nodes.length > GUI_GRAPH_MAX_NODES ||
|
|
@@ -9,6 +9,7 @@ import { CoreEngine } from '../core/engine.js';
|
|
|
9
9
|
import { ClausewitzEvaluationDefinitions } from '../core/clausewitz-evaluation.js';
|
|
10
10
|
import { assertRenderDimensions, RenderBudget, RENDER_MAX_PIXELS } from '../core/render-budget.js';
|
|
11
11
|
import { escapeFileGlob } from '../core/file-glob.js';
|
|
12
|
+
import { renderStage } from '../core/render-progress.js';
|
|
12
13
|
import { ServiceError } from '../core/result.js';
|
|
13
14
|
import { WorkspaceScanner } from '../core/scanner.js';
|
|
14
15
|
import { applyReplacements, decodeSource, encodeSource, parseClausewitz, parseLocalisation, SOURCE_MAX_BYTES, sourcePartialLimitDiagnostics, } from '../core/source/index.js';
|
|
@@ -22,7 +23,7 @@ import { projectGuiGraphForArtifact } from './inspection-artifact.js';
|
|
|
22
23
|
import { buildGuiScene } from './layout.js';
|
|
23
24
|
import { GUI_VALIDATION_MAX_DIAGNOSTICS } from './limits.js';
|
|
24
25
|
import { compareGuiImages, galleryDimensions, renderGallerySvg, renderGuiScene, } from './renderer.js';
|
|
25
|
-
import { generateGuiPreviewScenarios, resolveScenarioScriptedLocalisation, } from './scenario-generator.js';
|
|
26
|
+
import { generatedCountryTags, generateGuiPreviewScenarios, resolveScenarioScriptedLocalisation, } from './scenario-generator.js';
|
|
26
27
|
import { parseGeneratedScenarioOptions, parsePreviewScenario } from './scenario.js';
|
|
27
28
|
import { buildGuiSourceGraph } from './source-graph.js';
|
|
28
29
|
import { assertGuiSourcePatchesSafe } from './source-patch.js';
|
|
@@ -242,13 +243,17 @@ function referenceVariants(value) {
|
|
|
242
243
|
return candidates;
|
|
243
244
|
}
|
|
244
245
|
const FALLBACK_NAMES_PER_PATTERN = 100;
|
|
245
|
-
function basenameFallbackPatterns(exactPaths, exactFiles) {
|
|
246
|
+
function basenameFallbackPatterns(exactPaths, exactFiles, assetRoots = ['gfx', 'interface', 'fonts']) {
|
|
246
247
|
const resolved = new Set(exactFiles
|
|
247
248
|
.filter(({ shadowedBy }) => shadowedBy === undefined)
|
|
248
249
|
.map(({ relativePath }) => relativePath.replaceAll('\\', '/').toLowerCase()));
|
|
250
|
+
const stem = (value) => value.toLowerCase().replace(/\.(?:png|bmp|tga|dds|svg|fnt|ttf|otf|woff2?)$/u, '');
|
|
251
|
+
const resolvedStems = new Set([...resolved].map(stem));
|
|
249
252
|
const names = [
|
|
250
253
|
...new Set(exactPaths
|
|
251
|
-
.filter((candidate) => !/[*?[\]{}!]/u.test(candidate) &&
|
|
254
|
+
.filter((candidate) => !/[*?[\]{}!]/u.test(candidate) &&
|
|
255
|
+
!resolved.has(candidate.toLowerCase()) &&
|
|
256
|
+
!resolvedStems.has(stem(candidate)))
|
|
252
257
|
.map((candidate) => path.posix.basename(candidate))),
|
|
253
258
|
].sort((left, right) => compareCodeUnits(left, right));
|
|
254
259
|
// One brace group searches many names in a single bounded wildcard pattern, so a window
|
|
@@ -261,7 +266,9 @@ function basenameFallbackPatterns(exactPaths, exactFiles) {
|
|
|
261
266
|
const chunk = grouped.slice(start, start + FALLBACK_NAMES_PER_PATTERN).map(escapeFileGlob);
|
|
262
267
|
patterns.push(chunk.length === 1 ? `**/${chunk[0]}` : `**/{${chunk.join(',')}}`);
|
|
263
268
|
}
|
|
264
|
-
return
|
|
269
|
+
return [...new Set(assetRoots)]
|
|
270
|
+
.flatMap((root) => patterns.map((pattern) => `${escapeFileGlob(normalizeConfiguredRoot(root))}/${pattern}`))
|
|
271
|
+
.sort(compareCodeUnits);
|
|
265
272
|
}
|
|
266
273
|
function selectedElements(graph, windowName, additionalTemplateNames = []) {
|
|
267
274
|
const byId = new Map(graph.elements.map((element) => [element.id, element]));
|
|
@@ -549,10 +556,14 @@ export function scenarioCountryFlagPatterns(scenarios) {
|
|
|
549
556
|
for (const values of [
|
|
550
557
|
scenario.values,
|
|
551
558
|
scenario.scriptedGui,
|
|
559
|
+
scenario.country ?? {},
|
|
552
560
|
...Object.values(scenario.lists).flat(),
|
|
553
561
|
]) {
|
|
554
562
|
for (const [key, value] of Object.entries(values)) {
|
|
555
|
-
if (key.endsWith('.countryTag')
|
|
563
|
+
if ((key.endsWith('.countryTag') ||
|
|
564
|
+
key.endsWith('.tag') ||
|
|
565
|
+
/(?:^|\.)GetFlag$/u.test(key) ||
|
|
566
|
+
['countryTag', 'GetTag', 'tag'].includes(key)) &&
|
|
556
567
|
typeof value === 'string' &&
|
|
557
568
|
/^[A-Za-z0-9_]{2,64}$/u.test(value))
|
|
558
569
|
tags.add(value.toUpperCase());
|
|
@@ -573,8 +584,14 @@ export function scenarioCountryFlagPatterns(scenarios) {
|
|
|
573
584
|
function scenarioCountryHistoryPatterns(scenarios) {
|
|
574
585
|
return scenarios.some(scenarioNeedsCountryHistory) ? ['history/countries/*.txt'] : [];
|
|
575
586
|
}
|
|
576
|
-
function scenarioCountryAssetPatterns(scenarios) {
|
|
577
|
-
|
|
587
|
+
function scenarioCountryAssetPatterns(scenarios, generated = false) {
|
|
588
|
+
const flags = generated
|
|
589
|
+
? [
|
|
590
|
+
...scenarios,
|
|
591
|
+
...generatedCountryTags.map((tag) => parsePreviewScenario({ id: `generated-${tag}`, country: { tag } })),
|
|
592
|
+
]
|
|
593
|
+
: scenarios;
|
|
594
|
+
return [...scenarioCountryFlagPatterns(flags), ...scenarioCountryHistoryPatterns(scenarios)];
|
|
578
595
|
}
|
|
579
596
|
function bmFontPagePatterns(files) {
|
|
580
597
|
const references = files.flatMap((file) => {
|
|
@@ -972,13 +989,9 @@ export class ScriptedGuiStudio {
|
|
|
972
989
|
return cached;
|
|
973
990
|
}
|
|
974
991
|
const retainedFiles = [...files];
|
|
975
|
-
// The previous graph of this scope still indexes every unchanged file.
|
|
976
|
-
const previous = [...this.#graphCache].find(([existing]) => existing.startsWith(`${scope}:`))?.[1].index;
|
|
977
|
-
const index = this.engine.indexFiles(retainedFiles, previous);
|
|
978
992
|
const scanned = {
|
|
979
993
|
files: retainedFiles,
|
|
980
|
-
graph: buildGuiSourceGraph(retainedFiles,
|
|
981
|
-
index,
|
|
994
|
+
graph: buildGuiSourceGraph(retainedFiles, undefined, additionalLocalisationKeys),
|
|
982
995
|
};
|
|
983
996
|
// One graph per scope: a preview builds layout, game-layout and definition graphs, and a
|
|
984
997
|
// single shared slot made each repeat call rebuild all of them.
|
|
@@ -1003,50 +1016,75 @@ export class ScriptedGuiStudio {
|
|
|
1003
1016
|
signal?.throwIfAborted();
|
|
1004
1017
|
const workspace = this.resolver.get(workspaceId, principal);
|
|
1005
1018
|
const [snapshot, localisationFiles] = await Promise.all([
|
|
1006
|
-
this.
|
|
1019
|
+
this.scanner.scan(workspace, {
|
|
1020
|
+
patterns: guiDefinitionPatterns(workspace),
|
|
1021
|
+
...(signal === undefined ? {} : { signal }),
|
|
1022
|
+
}),
|
|
1007
1023
|
this.scanner.scan(workspace, {
|
|
1008
1024
|
patterns: guiLocalisationPatterns(workspace, languages),
|
|
1009
1025
|
...(signal === undefined ? {} : { signal }),
|
|
1010
1026
|
}),
|
|
1011
1027
|
]);
|
|
1012
1028
|
signal?.throwIfAborted();
|
|
1013
|
-
const files = mergeScannedFiles(snapshot
|
|
1029
|
+
const files = mergeScannedFiles(snapshot, localisationFiles);
|
|
1014
1030
|
return this.graphForFiles(files, `workspace:${workspaceId}`);
|
|
1015
1031
|
}
|
|
1016
|
-
async scanWindow(workspaceId, windowName, principal, signal, languages = ['l_english'], additionalSpriteNames = [], additionalTemplateNames = [], additionalAssetPatterns = [], additionalLocalisationKeys = [], includeImmediateTooltip = false) {
|
|
1032
|
+
async scanWindow(workspaceId, windowName, principal, signal, languages = ['l_english'], additionalSpriteNames = [], additionalTemplateNames = [], additionalAssetPatterns = [], additionalLocalisationKeys = [], includeImmediateTooltip = false, progress) {
|
|
1033
|
+
const stage = (phase) => renderStage({
|
|
1034
|
+
...(signal === undefined ? {} : { signal }),
|
|
1035
|
+
...(progress === undefined ? {} : { progress }),
|
|
1036
|
+
}, phase);
|
|
1037
|
+
await stage('Scanning GUI layouts');
|
|
1017
1038
|
const workspace = this.resolver.get(workspaceId, principal);
|
|
1018
1039
|
const layoutPatterns = guiLayoutPatterns(workspace);
|
|
1019
|
-
const overlayLayouts = await this.
|
|
1040
|
+
const overlayLayouts = await this.scanner.scan(workspace, {
|
|
1020
1041
|
patterns: layoutPatterns,
|
|
1021
1042
|
rootKinds: ['mod', 'dependency', 'fixture'],
|
|
1022
|
-
|
|
1023
|
-
|
|
1043
|
+
...(signal === undefined ? {} : { signal }),
|
|
1044
|
+
});
|
|
1045
|
+
let layoutFiles = overlayLayouts;
|
|
1024
1046
|
let layoutGraph = this.graphForFiles(layoutFiles, `layout:${workspaceId}`).graph;
|
|
1025
1047
|
const selectedLayoutElements = selectedElements(layoutGraph, windowName, additionalTemplateNames);
|
|
1026
1048
|
const requiredNames = new Set([windowName, ...additionalTemplateNames]);
|
|
1027
1049
|
for (const element of selectedLayoutElements)
|
|
1028
1050
|
collectNamedAttributes(element.attributes, new Set(['verticalScrollbar', 'horizontalScrollbar', 'scrollbarType', 'scrollbartype']), requiredNames);
|
|
1029
|
-
|
|
1051
|
+
// Scrollbar and row references name public templates, not private controls with the same name.
|
|
1052
|
+
const availableNames = new Set(layoutGraph.elements.filter(({ parentId }) => parentId === undefined).map(({ name }) => name));
|
|
1053
|
+
if (layoutGraph.elements.some(({ name }) => name === windowName))
|
|
1054
|
+
availableNames.add(windowName);
|
|
1030
1055
|
if ([...requiredNames].some((name) => !availableNames.has(name))) {
|
|
1031
|
-
const gameLayouts = await this.
|
|
1032
|
-
|
|
1056
|
+
const gameLayouts = await this.scanner.scan(workspace, {
|
|
1057
|
+
patterns: layoutPatterns,
|
|
1058
|
+
rootKinds: ['game', 'dlc'],
|
|
1059
|
+
...(signal === undefined ? {} : { signal }),
|
|
1060
|
+
});
|
|
1061
|
+
const gameGraph = this.graphForFiles(gameLayouts, `game-layout:${workspaceId}`).graph;
|
|
1033
1062
|
const selectedPaths = new Set(selectedElements(gameGraph, windowName, [...requiredNames]).map(({ sourcePath }) => sourcePath));
|
|
1034
|
-
layoutFiles = mergeScannedFiles(layoutFiles, gameLayouts.
|
|
1063
|
+
layoutFiles = mergeScannedFiles(layoutFiles, gameLayouts.filter(({ displayPath }) => selectedPaths.has(displayPath)));
|
|
1035
1064
|
layoutGraph = this.graphForFiles(layoutFiles, `merged-layout:${workspaceId}`).graph;
|
|
1036
1065
|
}
|
|
1037
1066
|
if (!layoutGraph.elements.some(({ name }) => name === windowName))
|
|
1038
1067
|
assertTargetWindowAvailable(layoutGraph, windowName);
|
|
1068
|
+
await stage('Scanning GUI definitions and localisation');
|
|
1039
1069
|
const [linkedDefinitions, localisation] = await Promise.all([
|
|
1040
|
-
this.
|
|
1041
|
-
|
|
1070
|
+
this.scanner.scan(workspace, {
|
|
1071
|
+
patterns: guiLinkedDefinitionPatterns(workspace),
|
|
1072
|
+
...(signal === undefined ? {} : { signal }),
|
|
1073
|
+
}),
|
|
1074
|
+
this.scanner.scan(workspace, {
|
|
1075
|
+
patterns: guiLocalisationPatterns(workspace, languages),
|
|
1076
|
+
...(signal === undefined ? {} : { signal }),
|
|
1077
|
+
}),
|
|
1042
1078
|
]);
|
|
1043
|
-
const definitionFiles = mergeScannedFiles(layoutFiles, linkedDefinitions
|
|
1079
|
+
const definitionFiles = mergeScannedFiles(layoutFiles, linkedDefinitions, localisation);
|
|
1080
|
+
await stage('Building GUI source graph');
|
|
1044
1081
|
const definitions = this.graphForFiles(definitionFiles, `definitions:${workspaceId}`, additionalLocalisationKeys);
|
|
1045
1082
|
assertTargetWindowAvailable(definitions.graph, windowName);
|
|
1046
1083
|
const referencedPatterns = [
|
|
1047
|
-
...referencedAssetPatternsForWindow(definitions.graph, windowName, additionalSpriteNames, languages, additionalTemplateNames, includeImmediateTooltip),
|
|
1084
|
+
...referencedAssetPatternsForWindow(definitions.graph, windowName, additionalSpriteNames, languages, additionalTemplateNames, includeImmediateTooltip).filter((pattern) => !pattern.startsWith('gfx/flags/')),
|
|
1048
1085
|
...additionalAssetPatterns,
|
|
1049
1086
|
];
|
|
1087
|
+
await stage('Loading selected GUI assets');
|
|
1050
1088
|
const exactReferenced = referencedPatterns.length === 0
|
|
1051
1089
|
? []
|
|
1052
1090
|
: await this.scanner.scan(workspace, {
|
|
@@ -1054,7 +1092,11 @@ export class ScriptedGuiStudio {
|
|
|
1054
1092
|
...(signal === undefined ? {} : { signal }),
|
|
1055
1093
|
});
|
|
1056
1094
|
signal?.throwIfAborted();
|
|
1057
|
-
const fallbackPatterns = basenameFallbackPatterns(referencedPatterns, exactReferenced
|
|
1095
|
+
const fallbackPatterns = basenameFallbackPatterns(referencedPatterns, exactReferenced, [
|
|
1096
|
+
...workspace.registration.roots.gfx,
|
|
1097
|
+
...workspace.registration.roots.interface,
|
|
1098
|
+
'fonts',
|
|
1099
|
+
]);
|
|
1058
1100
|
const fallbackReferenced = fallbackPatterns.length === 0
|
|
1059
1101
|
? []
|
|
1060
1102
|
: await this.scanner.scan(workspace, {
|
|
@@ -1070,7 +1112,11 @@ export class ScriptedGuiStudio {
|
|
|
1070
1112
|
...(signal === undefined ? {} : { signal }),
|
|
1071
1113
|
});
|
|
1072
1114
|
signal?.throwIfAborted();
|
|
1073
|
-
const fontPageFallbackPatterns = basenameFallbackPatterns(fontPagePatterns, exactFontPages
|
|
1115
|
+
const fontPageFallbackPatterns = basenameFallbackPatterns(fontPagePatterns, exactFontPages, [
|
|
1116
|
+
...workspace.registration.roots.gfx,
|
|
1117
|
+
...workspace.registration.roots.interface,
|
|
1118
|
+
'fonts',
|
|
1119
|
+
]);
|
|
1074
1120
|
const fallbackFontPages = fontPageFallbackPatterns.length === 0
|
|
1075
1121
|
? []
|
|
1076
1122
|
: await this.scanner.scan(workspace, {
|
|
@@ -1079,7 +1125,26 @@ export class ScriptedGuiStudio {
|
|
|
1079
1125
|
});
|
|
1080
1126
|
const fontPages = mergeScannedFiles(exactFontPages, fallbackFontPages);
|
|
1081
1127
|
signal?.throwIfAborted();
|
|
1082
|
-
const
|
|
1128
|
+
const windowElements = selectedElements(definitions.graph, windowName, additionalTemplateNames);
|
|
1129
|
+
const selectedPaths = new Set(windowElements.map(({ sourcePath }) => sourcePath));
|
|
1130
|
+
const namedSprites = new Set(additionalSpriteNames);
|
|
1131
|
+
const namedFonts = new Set();
|
|
1132
|
+
for (const element of windowElements) {
|
|
1133
|
+
collectNamedAttributes(element.attributes, new Set(['spriteType', 'quadTextureSprite']), namedSprites);
|
|
1134
|
+
collectNamedAttributes(element.attributes, new Set(['font', 'buttonFont']), namedFonts);
|
|
1135
|
+
}
|
|
1136
|
+
const assetPaths = new Set(referencedPatterns.map((value) => value.toLowerCase()));
|
|
1137
|
+
for (const sprite of definitions.graph.sprites)
|
|
1138
|
+
if (namedSprites.has(sprite.name) ||
|
|
1139
|
+
[sprite.texturePath, sprite.texturePath2, sprite.effectFile].some((value) => value !== undefined &&
|
|
1140
|
+
referenceVariants(value).some((variant) => assetPaths.has(variant.toLowerCase()))))
|
|
1141
|
+
selectedPaths.add(sprite.sourcePath);
|
|
1142
|
+
for (const font of definitions.graph.fonts)
|
|
1143
|
+
if (namedFonts.has(font.name) ||
|
|
1144
|
+
font.assetPaths.some((value) => referenceVariants(value).some((variant) => assetPaths.has(variant.toLowerCase()))))
|
|
1145
|
+
selectedPaths.add(font.sourcePath);
|
|
1146
|
+
const files = mergeScannedFiles(definitions.files.filter((file) => !/\.(?:gui|gfx)$/iu.test(file.relativePath) || selectedPaths.has(file.displayPath)), referenced, fontPages);
|
|
1147
|
+
await stage('Resolving selected GUI assets');
|
|
1083
1148
|
return this.graphForFiles(files, `window:${workspaceId}:${windowName}`, additionalLocalisationKeys);
|
|
1084
1149
|
}
|
|
1085
1150
|
async lint(input) {
|
|
@@ -1089,7 +1154,7 @@ export class ScriptedGuiStudio {
|
|
|
1089
1154
|
const generatedOptions = input.generatedScenarios === undefined
|
|
1090
1155
|
? undefined
|
|
1091
1156
|
: parseGeneratedScenarioOptions(input.generatedScenarios);
|
|
1092
|
-
const scanned = await this.scanWindow(input.workspaceId, input.windowName, input.principal, input.signal, [placeholderScenario.language, ...explicitRelatedScenarios.map(({ language }) => language)], scenarioSpriteNames([placeholderScenario, ...explicitRelatedScenarios], generatedOptions?.textSamples), scenarioTemplateNames([placeholderScenario, ...explicitRelatedScenarios]), scenarioCountryAssetPatterns([placeholderScenario, ...explicitRelatedScenarios]), scenarioLocalisationKeys([placeholderScenario, ...explicitRelatedScenarios]), [placeholderScenario, ...explicitRelatedScenarios].some(({ tooltip }) => tooltip !== undefined));
|
|
1157
|
+
const scanned = await this.scanWindow(input.workspaceId, input.windowName, input.principal, input.signal, [placeholderScenario.language, ...explicitRelatedScenarios.map(({ language }) => language)], scenarioSpriteNames([placeholderScenario, ...explicitRelatedScenarios], generatedOptions?.textSamples), scenarioTemplateNames([placeholderScenario, ...explicitRelatedScenarios]), scenarioCountryAssetPatterns([placeholderScenario, ...explicitRelatedScenarios], generatedOptions?.enabled === true), scenarioLocalisationKeys([placeholderScenario, ...explicitRelatedScenarios]), [placeholderScenario, ...explicitRelatedScenarios].some(({ tooltip }) => tooltip !== undefined), input.progress);
|
|
1093
1158
|
let builtDefinitions;
|
|
1094
1159
|
const evaluationDefinitions = () => (builtDefinitions ??= ClausewitzEvaluationDefinitions.build(scanned));
|
|
1095
1160
|
const resolveLocalisation = (target) => resolveScenarioScriptedLocalisation(scanned.graph, input.windowName, target, evaluationDefinitions);
|
|
@@ -1108,6 +1173,7 @@ export class ScriptedGuiStudio {
|
|
|
1108
1173
|
assertUniqueScenarioIds([scenario, ...relatedScenarios]);
|
|
1109
1174
|
const budget = new RenderBudget();
|
|
1110
1175
|
const catalog = new GuiAssetCatalog(scanned.graph, scanned.files, budget, scenario.language);
|
|
1176
|
+
await renderStage(input, 'Laying out GUI and decoding visible assets');
|
|
1111
1177
|
const scene = await buildGuiScene(scanned.graph, scanned.files, input.windowName, scenario, catalog);
|
|
1112
1178
|
const relatedScenes = [];
|
|
1113
1179
|
for (const related of relatedScenarios) {
|
|
@@ -1179,7 +1245,7 @@ export class ScriptedGuiStudio {
|
|
|
1179
1245
|
placeholderScenario,
|
|
1180
1246
|
...(requestedBaselineScenario === undefined ? [] : [requestedBaselineScenario]),
|
|
1181
1247
|
...explicitRelatedScenarios,
|
|
1182
|
-
]), scenarioLocalisationKeys([
|
|
1248
|
+
], generatedOptions?.enabled === true), scenarioLocalisationKeys([
|
|
1183
1249
|
placeholderScenario,
|
|
1184
1250
|
...(requestedBaselineScenario === undefined ? [] : [requestedBaselineScenario]),
|
|
1185
1251
|
...explicitRelatedScenarios,
|
|
@@ -1187,7 +1253,7 @@ export class ScriptedGuiStudio {
|
|
|
1187
1253
|
placeholderScenario,
|
|
1188
1254
|
...(requestedBaselineScenario === undefined ? [] : [requestedBaselineScenario]),
|
|
1189
1255
|
...explicitRelatedScenarios,
|
|
1190
|
-
].some(({ tooltip }) => tooltip !== undefined));
|
|
1256
|
+
].some(({ tooltip }) => tooltip !== undefined), input.progress);
|
|
1191
1257
|
let builtDefinitions;
|
|
1192
1258
|
const evaluationDefinitions = () => (builtDefinitions ??= ClausewitzEvaluationDefinitions.build(scanned));
|
|
1193
1259
|
const resolveLocalisation = (target) => resolveScenarioScriptedLocalisation(scanned.graph, input.windowName, target, evaluationDefinitions);
|
|
@@ -1207,7 +1273,9 @@ export class ScriptedGuiStudio {
|
|
|
1207
1273
|
const baselineScenario = requestedBaselineScenario ??
|
|
1208
1274
|
parsePreviewScenario({ ...scenario, id: `${scenario.id}-comparison`, state: 'normal' });
|
|
1209
1275
|
const catalog = new GuiAssetCatalog(scanned.graph, scanned.files, budget, scenario.language);
|
|
1276
|
+
await renderStage(input, 'Laying out GUI and decoding visible assets');
|
|
1210
1277
|
const scene = await buildGuiScene(scanned.graph, scanned.files, input.windowName, scenario, catalog);
|
|
1278
|
+
await renderStage(input, 'Rasterizing GUI views');
|
|
1211
1279
|
const render = await renderGuiScene(scene, undefined, input.signal, budget);
|
|
1212
1280
|
const fullRenderCache = new Map([
|
|
1213
1281
|
[visualSceneKey(scene), fullImage(render)],
|
|
@@ -1319,6 +1387,7 @@ export class ScriptedGuiStudio {
|
|
|
1319
1387
|
validation.diagnostics.push(...stateValidation.diagnostics, ...resolutionValidation.diagnostics);
|
|
1320
1388
|
validation.checks.push(...stateValidation.checks, ...resolutionValidation.checks);
|
|
1321
1389
|
const completeValidation = withGraphDiagnostics(validationGraph, validation);
|
|
1390
|
+
await renderStage(input, 'Writing GUI galleries and evidence');
|
|
1322
1391
|
const scenarioGallerySvg = renderGallerySvg(`${input.windowName} scripted scenario matrix`, scenarioItems);
|
|
1323
1392
|
const stateGallerySvg = renderGallerySvg(`${input.windowName} state matrix`, stateItems);
|
|
1324
1393
|
const resolutionGallerySvg = renderGallerySvg(`${input.windowName} resolution and UI-scale matrix`, resolutionItems);
|
|
@@ -120,11 +120,14 @@ export async function inspectGui(engine, studio, input, context) {
|
|
|
120
120
|
const linted = await studio.lint({
|
|
121
121
|
workspaceId: context.workspaceId,
|
|
122
122
|
windowName: input.windowName,
|
|
123
|
+
progress: (phase) => context.progress(1, 3, phase),
|
|
123
124
|
scenario: input.scenario,
|
|
124
125
|
...(input.relatedScenarios === undefined
|
|
125
126
|
? {}
|
|
126
127
|
: { relatedScenarios: input.relatedScenarios }),
|
|
127
|
-
|
|
128
|
+
...(input.generatedScenarios === undefined
|
|
129
|
+
? {}
|
|
130
|
+
: { generatedScenarios: input.generatedScenarios }),
|
|
128
131
|
...(context.principal === undefined ? {} : { principal: context.principal }),
|
|
129
132
|
...(context.signal === undefined ? {} : { signal: context.signal }),
|
|
130
133
|
});
|
|
@@ -217,6 +220,7 @@ export async function inspectGui(engine, studio, input, context) {
|
|
|
217
220
|
export async function renderGui(studio, input, context) {
|
|
218
221
|
await context.progress(0, 4, 'Building GUI source graph');
|
|
219
222
|
const rendered = await studio.renderAndStore({
|
|
223
|
+
progress: (phase) => context.progress(1, 4, phase),
|
|
220
224
|
workspaceId: context.workspaceId,
|
|
221
225
|
windowName: input.windowName,
|
|
222
226
|
scenario: input.scenario,
|
|
@@ -231,7 +235,9 @@ export async function renderGui(studio, input, context) {
|
|
|
231
235
|
})),
|
|
232
236
|
}),
|
|
233
237
|
...(input.relatedScenarios === undefined ? {} : { relatedScenarios: input.relatedScenarios }),
|
|
234
|
-
|
|
238
|
+
...(input.generatedScenarios === undefined
|
|
239
|
+
? {}
|
|
240
|
+
: { generatedScenarios: input.generatedScenarios }),
|
|
235
241
|
...(input.comparisonScenario === undefined
|
|
236
242
|
? {}
|
|
237
243
|
: { comparisonScenario: input.comparisonScenario }),
|
|
@@ -17,7 +17,7 @@ import { referenceTools } from '../tools/reference.js';
|
|
|
17
17
|
import { isReferenceTool, ReferenceToolService } from '../../reference/operations.js';
|
|
18
18
|
import { ModernTaskAdapter, TASKS_EXTENSION } from './modern-tasks.js';
|
|
19
19
|
import { SERVER_INSTRUCTIONS } from './instructions.js';
|
|
20
|
-
import { modernProgressReporter, withProgressHeartbeat } from './progress.js';
|
|
20
|
+
import { jobProgressReporter, modernProgressReporter, withProgressHeartbeat } from './progress.js';
|
|
21
21
|
import { taskToolCatalog } from './task-tool-catalog.js';
|
|
22
22
|
const rootsResponseSchema = z
|
|
23
23
|
.object({
|
|
@@ -187,6 +187,7 @@ export function createModernOperationServer(engine, context = {}, tasks = new Op
|
|
|
187
187
|
const progress = modernProgressReporter(extra);
|
|
188
188
|
const result = await withProgressHeartbeat(async () => {
|
|
189
189
|
const completed = await adapter.call({ name: definition.name, arguments: arguments_ }, capabilities, operationContext, {
|
|
190
|
+
progress: jobProgressReporter(progress),
|
|
190
191
|
requestId: extra.mcpReq.id,
|
|
191
192
|
signal: extra.mcpReq.signal,
|
|
192
193
|
...(extra.sessionId === undefined ? {} : { sessionId: extra.sessionId }),
|
|
@@ -2,7 +2,7 @@ import { CallToolResultSchema, } from '@modelcontextprotocol/sdk/types.js';
|
|
|
2
2
|
import { jobTaskTiming } from '../../core/job-store.js';
|
|
3
3
|
import { OperationTaskService, operationTaskCallSchema, operationTaskId, operationTaskMessage, operationTaskStatus, } from '../../core/operation-tasks.js';
|
|
4
4
|
import { ServiceError } from '../../core/result.js';
|
|
5
|
-
import { currentTaskRequestSignal } from './task-request-context.js';
|
|
5
|
+
import { currentTaskRequestSignal, currentTaskRequestProgress } from './task-request-context.js';
|
|
6
6
|
function legacyTask(record) {
|
|
7
7
|
if (record.request.protocolTask === undefined)
|
|
8
8
|
throw new ServiceError('TASK_NOT_FOUND', 'The requested protocol task does not exist');
|
|
@@ -44,6 +44,8 @@ export class PersistentJobTaskStore {
|
|
|
44
44
|
}
|
|
45
45
|
async getTask(value, _sessionId) {
|
|
46
46
|
const record = await this.tasks.get(value, this.context());
|
|
47
|
+
if (record?.progress !== undefined)
|
|
48
|
+
await currentTaskRequestProgress()?.(record.progress.completed, record.progress.total ?? 1, record.progress.message);
|
|
47
49
|
return record === null ? null : legacyTask(record);
|
|
48
50
|
}
|
|
49
51
|
async storeTaskResult(value, status, result, _sessionId) {
|
|
@@ -112,6 +112,8 @@ export class ModernTaskAdapter {
|
|
|
112
112
|
options.signal?.throwIfAborted();
|
|
113
113
|
await delay(100, undefined, { signal: options.signal });
|
|
114
114
|
latest = await this.known(taskId, context);
|
|
115
|
+
if (latest.progress !== undefined)
|
|
116
|
+
await options.progress?.(latest.progress.completed, latest.progress.total ?? 1, latest.progress.message);
|
|
115
117
|
}
|
|
116
118
|
return modernToolResult(latest, await this.tasks.result(operationTaskId(record), context));
|
|
117
119
|
}
|
|
@@ -101,3 +101,15 @@ export async function withProgressHeartbeat(operation, reporter, message, interv
|
|
|
101
101
|
heartbeatOwners.delete(reporter);
|
|
102
102
|
}
|
|
103
103
|
}
|
|
104
|
+
/** Reserve the outer request's final two steps for retrieval/completion while forwarding worker phases. */
|
|
105
|
+
export function jobProgressReporter(reporter) {
|
|
106
|
+
let previous;
|
|
107
|
+
return async (completed, total, message) => {
|
|
108
|
+
const identity = JSON.stringify([completed, total, message]);
|
|
109
|
+
if (identity === previous)
|
|
110
|
+
return;
|
|
111
|
+
previous = identity;
|
|
112
|
+
await reporter.report(Math.min(1, completed / Math.max(1, total)), 3, message);
|
|
113
|
+
await reporter.pulse(message);
|
|
114
|
+
};
|
|
115
|
+
}
|
|
@@ -5,7 +5,7 @@ import { referenceTools } from '../tools/reference.js';
|
|
|
5
5
|
import { jobControlTools } from '../tools/job.js';
|
|
6
6
|
import { invalidArgumentsResult } from './argument-errors.js';
|
|
7
7
|
import { taskToolCatalog } from './task-tool-catalog.js';
|
|
8
|
-
import { progressReporter, withProgressHeartbeat } from './progress.js';
|
|
8
|
+
import { jobProgressReporter, progressReporter, withProgressHeartbeat } from './progress.js';
|
|
9
9
|
import { withTaskRequestSignal } from './task-request-context.js';
|
|
10
10
|
import { slimToolList } from './tool-listing.js';
|
|
11
11
|
// Durable task compatibility and cancellation are control traffic. They must
|
|
@@ -42,7 +42,7 @@ export function installRequestLifecycle(server, engine) {
|
|
|
42
42
|
return handler(request, extra);
|
|
43
43
|
if (taskCall.success && backgroundTools.has(taskCall.data.params.name)) {
|
|
44
44
|
return withProgressHeartbeat(async () => {
|
|
45
|
-
const result = await withTaskRequestSignal(extra.signal, () => handler(request, extra));
|
|
45
|
+
const result = await withTaskRequestSignal(extra.signal, () => handler(request, extra), jobProgressReporter(progress));
|
|
46
46
|
if (taskCall.data.params.task === undefined) {
|
|
47
47
|
await progress.report(2, 3, 'Retrieving persistent operation result');
|
|
48
48
|
await progress.report(3, 3, 'Persistent operation complete');
|
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
2
2
|
const taskRequestContext = new AsyncLocalStorage();
|
|
3
3
|
/** Bind transport cancellation to foreground-compatible task creation only. */
|
|
4
|
-
export function withTaskRequestSignal(signal, action) {
|
|
5
|
-
return taskRequestContext.run({ signal }, action);
|
|
4
|
+
export function withTaskRequestSignal(signal, action, progress) {
|
|
5
|
+
return taskRequestContext.run({ signal, ...(progress === undefined ? {} : { progress }) }, action);
|
|
6
6
|
}
|
|
7
7
|
export function currentTaskRequestSignal() {
|
|
8
8
|
return taskRequestContext.getStore()?.signal;
|
|
9
9
|
}
|
|
10
|
+
export function currentTaskRequestProgress() {
|
|
11
|
+
return taskRequestContext.getStore()?.progress;
|
|
12
|
+
}
|
|
@@ -22,6 +22,7 @@ export function registerTechnologyJobs(operations, engine) {
|
|
|
22
22
|
workspaceId: context.workspaceId,
|
|
23
23
|
...(context.principal === undefined ? {} : { principal: context.principal }),
|
|
24
24
|
signal: context.signal,
|
|
25
|
+
progress: (phase) => context.progress({ completed: 1, total: 3, message: phase }),
|
|
25
26
|
});
|
|
26
27
|
await context.progress({
|
|
27
28
|
completed: 3,
|