hoi4-agent-tools 3.0.7 → 3.0.9
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 +26 -0
- package/dist/hoi4_agent_tools/core/artifacts.js +21 -1
- package/dist/hoi4_agent_tools/core/condition-evaluator.js +399 -0
- package/dist/hoi4_agent_tools/core/condition-model.js +1 -0
- package/dist/hoi4_agent_tools/core/condition-schema.js +42 -0
- package/dist/hoi4_agent_tools/core/configuration.js +14 -28
- package/dist/hoi4_agent_tools/core/engine.js +7 -1
- package/dist/hoi4_agent_tools/core/index.js +25 -0
- package/dist/hoi4_agent_tools/core/request-scheduler.js +79 -0
- package/dist/hoi4_agent_tools/core/scenario-state.js +97 -0
- package/dist/hoi4_agent_tools/core/shared-request-capacity.js +125 -0
- package/dist/hoi4_agent_tools/gui/scenario-conditions.js +119 -0
- package/dist/hoi4_agent_tools/gui/scenario-generator.js +59 -73
- package/dist/hoi4_agent_tools/gui/scenario.js +3 -0
- package/dist/hoi4_agent_tools/gui/studio.js +9 -3
- package/dist/hoi4_agent_tools/mcp/server/create.js +3 -1
- package/dist/hoi4_agent_tools/mcp/server/progress.js +29 -8
- package/dist/hoi4_agent_tools/mcp/server/request-lifecycle.js +14 -0
- package/dist/hoi4_agent_tools/mcp/transports/bounded-stdio.js +44 -14
- package/dist/hoi4_agent_tools/mcp/transports/http.js +75 -7
- package/dist/hoi4_agent_tools/probability/scenario-state.js +2 -95
- package/dist/hoi4_agent_tools/probability/trigger-evaluator.js +1 -287
- package/dist/hoi4_agent_tools/version.js +1 -1
- package/docs/development.md +2 -0
- package/docs/gui.md +3 -1
- package/docs/http.md +5 -1
- package/package.json +3 -2
- package/schemas/configuration.schema.json +151 -133
- package/schemas/continuous-focus-palette.schema.json +1 -1
- package/schemas/custom-weighted-pool.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 +189 -33
- package/schemas/map-operation.schema.json +1 -1
- package/schemas/operation-result.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/server.json +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,32 @@
|
|
|
4
4
|
|
|
5
5
|
No unreleased changes.
|
|
6
6
|
|
|
7
|
+
## 3.0.9 - 2026-09-08
|
|
8
|
+
|
|
9
|
+
- Evaluate GUI scripted-localisation branches through the shared structured condition evaluator instead of regex threshold guesses.
|
|
10
|
+
- Correct inclusive boundaries, compound conditions, negation, variable operands, constants, and named scopes; retain the existing probability API through core compatibility exports.
|
|
11
|
+
- Resolve source-backed scripted-trigger calls, including declared scalar parameters, and retain helper provenance and unresolved conditions.
|
|
12
|
+
- Keep an unknown earlier localisation branch unresolved instead of selecting a ranked candidate or an unproven fallback.
|
|
13
|
+
- Preserve explicit values and seeded exploratory previews, expose optional named GUI scope bindings, and include per-choice condition evidence in scenario artifacts.
|
|
14
|
+
- Add independent condition truth-table tests and generated-scenario-to-render regressions for the reported failures, colours, scoped helpers, placeholders, and repeatable SVG/PNG output.
|
|
15
|
+
- Align the sparse-mod stdio test's overall budget with its RPC waits and honor matching progress notifications without removing the finite test deadline.
|
|
16
|
+
- Avoid Windows path-resolution races while concurrent stdio processes release capacity slots, retaining private-root validation and explicit linked-slot rejection.
|
|
17
|
+
- Retry Windows delete-pending capacity-slot creation without admitting duplicate work; persistent permission failures remain bounded errors.
|
|
18
|
+
|
|
19
|
+
## 3.0.8 - 2026-09-05
|
|
20
|
+
|
|
21
|
+
- Queue simultaneous tool calls fairly across HTTP sessions and coordinate expensive operations across local task processes sharing private server state.
|
|
22
|
+
- Rotate across every waiting session and canonicalize configured storage aliases, including Windows short-name paths, before acquiring shared execution capacity.
|
|
23
|
+
- Keep every tool call responsive with strictly increasing MCP progress notifications during execution and queue waits; allow cancellation and discovery while tool capacity is occupied.
|
|
24
|
+
- Separate HTTP request admission and body-byte reservations from expensive execution capacity, replacing the two-request ceiling with defaults suitable for parallel agents.
|
|
25
|
+
- Keep active HTTP sessions alive, yield during shared source indexing, and serialize backpressured stdio output without leaking listeners or leaving sends pending after a disconnect.
|
|
26
|
+
- Share cached GUI source graphs across sessions using the same engine instead of retaining a separate graph per client.
|
|
27
|
+
- Reverify artifacts after metadata changes caused by concurrent publication-link cleanup, avoiding false hash collisions while still rejecting altered bytes.
|
|
28
|
+
- Serialize artifact admission, publication, and failed-batch cleanup across processes so one writer cannot remove evidence committed by another.
|
|
29
|
+
- Add concurrent production workflows spanning focus, GUI, map, event, technology, and probability tools over both transports, including shared artifacts, session expiry, cancelled queue entries, and abandoned process recovery.
|
|
30
|
+
- Qualify each map layer separately from semantic and pixel diffs so failures and deadlines identify the affected operation rather than an aggregate rendering loop.
|
|
31
|
+
- Update `fast-uri` and `qs` to patched releases identified by the dependency audit.
|
|
32
|
+
|
|
7
33
|
## 3.0.7 - 2026-09-01
|
|
8
34
|
|
|
9
35
|
- Reclaim detached parser, graph, and render memory after the idle cache window so parallel coding-agent tasks do not retain large V8 heaps and trigger `Transport closed` through host memory exhaustion. Idle cleanup is coordinated across every MCP domain and never compacts during an active tool call.
|
|
@@ -4,6 +4,7 @@ import path from 'node:path';
|
|
|
4
4
|
import { z } from 'zod/v4';
|
|
5
5
|
import { canonicalJson, compareCodeUnits, sha256Bytes } from './canonical.js';
|
|
6
6
|
import { ServiceError } from './result.js';
|
|
7
|
+
import { SharedRequestCapacity } from './shared-request-capacity.js';
|
|
7
8
|
import { containedGeneratedPath, isPortablePathSegment } from './workspace.js';
|
|
8
9
|
const sha256Schema = z.string().regex(/^[a-f0-9]{64}$/u);
|
|
9
10
|
const artifactProvenanceSchema = z
|
|
@@ -329,7 +330,9 @@ async function withArtifactQueue(root, action, signal) {
|
|
|
329
330
|
artifactQueues.set(root, tail);
|
|
330
331
|
try {
|
|
331
332
|
await waitForArtifactTurn(previous, signal);
|
|
332
|
-
|
|
333
|
+
// Admission, publication, and rollback must be one critical section across
|
|
334
|
+
// processes too: another writer may reuse the same immutable address.
|
|
335
|
+
return await new SharedRequestCapacity(root, 1).run(signal ?? new AbortController().signal, action);
|
|
333
336
|
}
|
|
334
337
|
finally {
|
|
335
338
|
release();
|
|
@@ -607,6 +610,23 @@ async function publishExclusiveFile(target, bytes, signal) {
|
|
|
607
610
|
}
|
|
608
611
|
}
|
|
609
612
|
async function readVerifiedArtifactRange(filePath, expectedSha256, range, verifiedIdentity, signal) {
|
|
613
|
+
for (let attempt = 0;; attempt += 1) {
|
|
614
|
+
try {
|
|
615
|
+
return await readArtifactRangeOnce(filePath, expectedSha256, range, attempt === 0 ? verifiedIdentity : undefined, signal);
|
|
616
|
+
}
|
|
617
|
+
catch (error) {
|
|
618
|
+
if (attempt >= 2 ||
|
|
619
|
+
!(error instanceof ServiceError) ||
|
|
620
|
+
error.code !== 'ARTIFACT_READ_INCOMPLETE')
|
|
621
|
+
throw error;
|
|
622
|
+
// Removing the temporary hard link after atomic publication changes ctime
|
|
623
|
+
// without changing content. Retry from a fresh handle and hash all bytes;
|
|
624
|
+
// never reuse cached verification after an identity change.
|
|
625
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
async function readArtifactRangeOnce(filePath, expectedSha256, range, verifiedIdentity, signal) {
|
|
610
630
|
signal?.throwIfAborted();
|
|
611
631
|
if (range !== undefined &&
|
|
612
632
|
(!Number.isSafeInteger(range.offset) ||
|
|
@@ -0,0 +1,399 @@
|
|
|
1
|
+
import { assignments, astPathFor, firstScalar, nodeLocation, } from './source/index.js';
|
|
2
|
+
import { resolveScopeContext, rootScopeContext, rootStateValue, scopeIdentity, scopeStateValue, } from './scenario-state.js';
|
|
3
|
+
function helperWithArguments(block, invocation) {
|
|
4
|
+
const parameters = new Map();
|
|
5
|
+
for (const entry of invocation.entries) {
|
|
6
|
+
if (entry.type !== 'assignment' || entry.value.type !== 'scalar')
|
|
7
|
+
return undefined;
|
|
8
|
+
parameters.set(entry.key.value, entry.value.value);
|
|
9
|
+
}
|
|
10
|
+
const missing = new Set();
|
|
11
|
+
const substitute = (value) => value.replace(/\$([A-Za-z0-9_]+)\$/gu, (match, key) => {
|
|
12
|
+
const replacement = parameters.get(key);
|
|
13
|
+
if (replacement === undefined) {
|
|
14
|
+
missing.add(key);
|
|
15
|
+
return match;
|
|
16
|
+
}
|
|
17
|
+
return replacement;
|
|
18
|
+
});
|
|
19
|
+
const visit = (entry) => {
|
|
20
|
+
if (entry.type === 'scalar')
|
|
21
|
+
return { ...entry, value: substitute(entry.value) };
|
|
22
|
+
if (entry.type === 'block')
|
|
23
|
+
return { ...entry, entries: entry.entries.map(visit) };
|
|
24
|
+
return {
|
|
25
|
+
...entry,
|
|
26
|
+
key: { ...entry.key, value: substitute(entry.key.value) },
|
|
27
|
+
value: entry.value.type === 'scalar'
|
|
28
|
+
? { ...entry.value, value: substitute(entry.value.value) }
|
|
29
|
+
: { ...entry.value, entries: entry.value.entries.map(visit) },
|
|
30
|
+
};
|
|
31
|
+
};
|
|
32
|
+
const expanded = { ...block, entries: block.entries.map(visit) };
|
|
33
|
+
return missing.size === 0 ? expanded : undefined;
|
|
34
|
+
}
|
|
35
|
+
function combineAnd(values) {
|
|
36
|
+
if (values.includes('false'))
|
|
37
|
+
return 'false';
|
|
38
|
+
return values.includes('unresolved') ? 'unresolved' : 'true';
|
|
39
|
+
}
|
|
40
|
+
function combineOr(values) {
|
|
41
|
+
if (values.includes('true'))
|
|
42
|
+
return 'true';
|
|
43
|
+
return values.includes('unresolved') ? 'unresolved' : 'false';
|
|
44
|
+
}
|
|
45
|
+
function invert(value) {
|
|
46
|
+
return value === 'true' ? 'false' : value === 'false' ? 'true' : 'unresolved';
|
|
47
|
+
}
|
|
48
|
+
function bool(value) {
|
|
49
|
+
if (typeof value === 'boolean')
|
|
50
|
+
return value;
|
|
51
|
+
if (value === 'yes' || value === 'true')
|
|
52
|
+
return true;
|
|
53
|
+
if (value === 'no' || value === 'false')
|
|
54
|
+
return false;
|
|
55
|
+
return undefined;
|
|
56
|
+
}
|
|
57
|
+
function numeric(value) {
|
|
58
|
+
if (typeof value === 'number' && Number.isFinite(value))
|
|
59
|
+
return value;
|
|
60
|
+
if (typeof value === 'string' && value.trim() !== '' && Number.isFinite(Number(value)))
|
|
61
|
+
return Number(value);
|
|
62
|
+
return undefined;
|
|
63
|
+
}
|
|
64
|
+
function compare(left, right, operator) {
|
|
65
|
+
if (typeof left === 'number' && typeof right === 'number') {
|
|
66
|
+
if (operator === '>')
|
|
67
|
+
return left > right;
|
|
68
|
+
if (operator === '<')
|
|
69
|
+
return left < right;
|
|
70
|
+
}
|
|
71
|
+
return operator === '!=' ? left !== right : left === right;
|
|
72
|
+
}
|
|
73
|
+
function numberValue(expression, scenario, context, definitions, path, seen = new Set()) {
|
|
74
|
+
const literal = numeric(expression);
|
|
75
|
+
if (literal !== undefined)
|
|
76
|
+
return literal;
|
|
77
|
+
const identity = `${path ?? ''}:${expression}`;
|
|
78
|
+
if (seen.has(identity))
|
|
79
|
+
return undefined;
|
|
80
|
+
seen.add(identity);
|
|
81
|
+
const constant = expression.startsWith('constant:')
|
|
82
|
+
? definitions.scriptConstants.get(expression.slice(9))
|
|
83
|
+
: expression.startsWith('@') && path !== undefined
|
|
84
|
+
? definitions.localConstants.get(path)?.get(expression)
|
|
85
|
+
: undefined;
|
|
86
|
+
if (constant !== undefined)
|
|
87
|
+
return numberValue(constant.value, scenario, context, definitions, constant.file.displayPath, seen);
|
|
88
|
+
const direct = scopeStateValue(scenario, expression, context) ??
|
|
89
|
+
scopeStateValue(scenario, `variable.${expression}`, context);
|
|
90
|
+
if (direct !== undefined)
|
|
91
|
+
return numeric(direct);
|
|
92
|
+
for (let split = expression.lastIndexOf('.'); split > 0; split = expression.lastIndexOf('.', split - 1)) {
|
|
93
|
+
const scope = resolveScopeContext(scenario, expression.slice(0, split), context);
|
|
94
|
+
if (scope !== undefined)
|
|
95
|
+
return numberValue(expression.slice(split + 1), scenario, scope, definitions, path, seen);
|
|
96
|
+
}
|
|
97
|
+
return undefined;
|
|
98
|
+
}
|
|
99
|
+
function unresolved(assignment, candidate, reason) {
|
|
100
|
+
return {
|
|
101
|
+
state: 'unresolved',
|
|
102
|
+
unresolved: [
|
|
103
|
+
{
|
|
104
|
+
code: 'TRIGGER_UNRESOLVED',
|
|
105
|
+
message: reason,
|
|
106
|
+
path: assignment.key.value,
|
|
107
|
+
candidateId: candidate.id,
|
|
108
|
+
...(candidate.provenance[0] === undefined ? {} : { provenance: candidate.provenance[0] }),
|
|
109
|
+
},
|
|
110
|
+
],
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
function malformedBlock(block, candidate) {
|
|
114
|
+
if (block.entries.every(({ type }) => type === 'assignment'))
|
|
115
|
+
return undefined;
|
|
116
|
+
return {
|
|
117
|
+
state: 'unresolved',
|
|
118
|
+
unresolved: [
|
|
119
|
+
{
|
|
120
|
+
code: 'TRIGGER_UNRESOLVED',
|
|
121
|
+
message: 'Condition contains a bare value or block instead of a trigger assignment',
|
|
122
|
+
candidateId: candidate.id,
|
|
123
|
+
...(candidate.provenance[0] === undefined ? {} : { provenance: candidate.provenance[0] }),
|
|
124
|
+
},
|
|
125
|
+
],
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
function evaluateCheckVariable(block, scenario, candidate, assignment, scopeContext, definitions) {
|
|
129
|
+
const short = assignments(block).find(({ key }) => !['var', 'value', 'compare', 'tooltip'].includes(key.value));
|
|
130
|
+
const variable = firstScalar(block, 'var')?.value ?? short?.key.value;
|
|
131
|
+
const target = firstScalar(block, 'value')?.value ??
|
|
132
|
+
(short?.value.type === 'scalar' ? short.value.value : undefined);
|
|
133
|
+
if (variable === undefined || target === undefined)
|
|
134
|
+
return unresolved(assignment, candidate, 'check_variable requires declared var and value');
|
|
135
|
+
const path = candidate.provenance[0]?.path;
|
|
136
|
+
const leftNumber = numberValue(variable, scenario, scopeContext, definitions, path);
|
|
137
|
+
const rightNumber = numberValue(target, scenario, scopeContext, definitions, path);
|
|
138
|
+
if (leftNumber === undefined || rightNumber === undefined)
|
|
139
|
+
return unresolved(assignment, candidate, `Scenario does not resolve numeric operands ${variable} and ${target}`);
|
|
140
|
+
const compareName = firstScalar(block, 'compare')?.value ??
|
|
141
|
+
(short?.operator.text === '>'
|
|
142
|
+
? 'greater_than'
|
|
143
|
+
: short?.operator.text === '<'
|
|
144
|
+
? 'less_than'
|
|
145
|
+
: 'equals');
|
|
146
|
+
if (![
|
|
147
|
+
'greater_than',
|
|
148
|
+
'greater_than_or_equals',
|
|
149
|
+
'less_than',
|
|
150
|
+
'less_than_or_equals',
|
|
151
|
+
'not_equals',
|
|
152
|
+
'equals',
|
|
153
|
+
].includes(compareName))
|
|
154
|
+
return unresolved(assignment, candidate, `Unsupported check_variable comparison ${compareName}`);
|
|
155
|
+
const result = compareName === 'greater_than'
|
|
156
|
+
? leftNumber > rightNumber
|
|
157
|
+
: compareName === 'greater_than_or_equals'
|
|
158
|
+
? leftNumber >= rightNumber
|
|
159
|
+
: compareName === 'less_than'
|
|
160
|
+
? leftNumber < rightNumber
|
|
161
|
+
: compareName === 'less_than_or_equals'
|
|
162
|
+
? leftNumber <= rightNumber
|
|
163
|
+
: compareName === 'not_equals'
|
|
164
|
+
? leftNumber !== rightNumber
|
|
165
|
+
: leftNumber === rightNumber;
|
|
166
|
+
return { state: result ? 'true' : 'false', unresolved: [] };
|
|
167
|
+
}
|
|
168
|
+
function evaluateAssignmentCore(assignment, scenario, candidate, definitions, helperStack, scopeContext) {
|
|
169
|
+
const key = assignment.key.value;
|
|
170
|
+
if (assignment.value.type === 'block') {
|
|
171
|
+
const malformed = malformedBlock(assignment.value, candidate);
|
|
172
|
+
if (malformed !== undefined)
|
|
173
|
+
return malformed;
|
|
174
|
+
if (key === 'AND')
|
|
175
|
+
return evaluateTriggerBlock(assignment.value, scenario, candidate, definitions, helperStack, scopeContext);
|
|
176
|
+
if (key === 'OR') {
|
|
177
|
+
const children = assignments(assignment.value).map((child) => evaluateAssignment(child, scenario, candidate, definitions, helperStack, scopeContext));
|
|
178
|
+
return {
|
|
179
|
+
state: combineOr(children.map(({ state }) => state)),
|
|
180
|
+
unresolved: children.flatMap(({ unresolved: items }) => items),
|
|
181
|
+
helperProvenance: children.flatMap(({ helperProvenance }) => helperProvenance ?? []),
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
if (key === 'NOT' || key === 'NOR' || key === 'NAND') {
|
|
185
|
+
const children = assignments(assignment.value).map((child) => evaluateAssignment(child, scenario, candidate, definitions, helperStack, scopeContext));
|
|
186
|
+
const base = key === 'NAND'
|
|
187
|
+
? combineAnd(children.map(({ state }) => state))
|
|
188
|
+
: combineOr(children.map(({ state }) => state));
|
|
189
|
+
return {
|
|
190
|
+
state: invert(base),
|
|
191
|
+
unresolved: children.flatMap(({ unresolved: items }) => items),
|
|
192
|
+
helperProvenance: children.flatMap(({ helperProvenance }) => helperProvenance ?? []),
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
if (key === 'check_variable')
|
|
196
|
+
return evaluateCheckVariable(assignment.value, scenario, candidate, assignment, scopeContext, definitions);
|
|
197
|
+
if (key === 'is_in_array' || key === 'is_variable_in_array') {
|
|
198
|
+
const arrayId = firstScalar(assignment.value, 'array')?.value;
|
|
199
|
+
const declaredValue = firstScalar(assignment.value, 'value')?.value;
|
|
200
|
+
if (arrayId === undefined || declaredValue === undefined)
|
|
201
|
+
return unresolved(assignment, candidate, `${key} requires array and value`);
|
|
202
|
+
const array = scopeStateValue(scenario, `array.${arrayId}`, scopeContext) ??
|
|
203
|
+
rootStateValue(scenario, `array.${arrayId}`);
|
|
204
|
+
if (!Array.isArray(array))
|
|
205
|
+
return unresolved(assignment, candidate, `Scenario does not declare array ${arrayId}`);
|
|
206
|
+
const compared = ['THIS', 'ROOT', 'PREV'].includes(declaredValue.toUpperCase()) ||
|
|
207
|
+
resolveScopeContext(scenario, declaredValue, scopeContext) !== undefined
|
|
208
|
+
? scopeIdentity(resolveScopeContext(scenario, declaredValue, scopeContext) ?? scopeContext)
|
|
209
|
+
: (scopeStateValue(scenario, declaredValue, scopeContext) ?? declaredValue);
|
|
210
|
+
return { state: array.includes(compared) ? 'true' : 'false', unresolved: [] };
|
|
211
|
+
}
|
|
212
|
+
const resolvedScope = resolveScopeContext(scenario, key, scopeContext);
|
|
213
|
+
if (resolvedScope !== undefined)
|
|
214
|
+
return evaluateTriggerBlock(assignment.value, scenario, candidate, definitions, helperStack, resolvedScope);
|
|
215
|
+
const helper = definitions.scriptedTriggers.get(key);
|
|
216
|
+
if (helper !== undefined) {
|
|
217
|
+
if (helperStack.includes(key))
|
|
218
|
+
return unresolved(assignment, candidate, `Scripted trigger recursion detected at ${key}`);
|
|
219
|
+
const expanded = helperWithArguments(helper.value, assignment.value);
|
|
220
|
+
if (expanded === undefined)
|
|
221
|
+
return unresolved(assignment, candidate, `Scripted trigger ${key} requires declared scalar arguments for every parameter`);
|
|
222
|
+
const helperProvenance = {
|
|
223
|
+
path: helper.file.displayPath,
|
|
224
|
+
rootKind: helper.file.rootKind,
|
|
225
|
+
loadOrder: helper.file.loadOrder,
|
|
226
|
+
sourceHash: helper.file.sha256,
|
|
227
|
+
location: nodeLocation(helper.document, helper.node, key),
|
|
228
|
+
...(astPathFor(helper.document, helper.node) === undefined
|
|
229
|
+
? {}
|
|
230
|
+
: { astPath: astPathFor(helper.document, helper.node) }),
|
|
231
|
+
symbol: key,
|
|
232
|
+
helperChain: [...helperStack, key],
|
|
233
|
+
};
|
|
234
|
+
const evaluated = evaluateTriggerBlock(expanded, scenario, {
|
|
235
|
+
...candidate,
|
|
236
|
+
document: helper.document,
|
|
237
|
+
provenance: [helperProvenance, ...candidate.provenance],
|
|
238
|
+
}, definitions, [...helperStack, key], scopeContext);
|
|
239
|
+
return {
|
|
240
|
+
...evaluated,
|
|
241
|
+
helperProvenance: [helperProvenance, ...(evaluated.helperProvenance ?? [])],
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
const declaredScope = bool(rootStateValue(scenario, `scope.${key}`));
|
|
245
|
+
if (declaredScope !== undefined)
|
|
246
|
+
return { state: declaredScope ? 'true' : 'false', unresolved: [] };
|
|
247
|
+
return unresolved(assignment, candidate, `Scoped or compound trigger ${key} is not declared by the scenario`);
|
|
248
|
+
}
|
|
249
|
+
const right = assignment.value.value;
|
|
250
|
+
if (key === 'always') {
|
|
251
|
+
const value = bool(right);
|
|
252
|
+
return value === undefined
|
|
253
|
+
? unresolved(assignment, candidate, 'always trigger is not boolean')
|
|
254
|
+
: { state: value ? 'true' : 'false', unresolved: [] };
|
|
255
|
+
}
|
|
256
|
+
if (key === 'has_country_flag' || key === 'has_global_flag' || key === 'has_state_flag') {
|
|
257
|
+
const explicit = bool(scopeStateValue(scenario, `flag.${right}`, scopeContext));
|
|
258
|
+
if (explicit !== undefined)
|
|
259
|
+
return { state: explicit ? 'true' : 'false', unresolved: [] };
|
|
260
|
+
const flags = scopeContext.expression === 'ROOT' ? scenario.flags : scopeContext.binding.flags;
|
|
261
|
+
if (scenario.closedFlags === false && flags?.includes(right) !== true)
|
|
262
|
+
return unresolved(assignment, candidate, `Scenario does not declare flag ${right}`);
|
|
263
|
+
return { state: flags?.includes(right) === true ? 'true' : 'false', unresolved: [] };
|
|
264
|
+
}
|
|
265
|
+
if (key === 'has_event_target') {
|
|
266
|
+
const eventTargets = scopeContext.binding.eventTargets ?? scenario.eventTargets;
|
|
267
|
+
return {
|
|
268
|
+
state: Object.hasOwn(eventTargets ?? {}, right) ? 'true' : 'false',
|
|
269
|
+
unresolved: [],
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
if (key === 'tag' || key === 'original_tag') {
|
|
273
|
+
const actor = scopeContext.expression === 'ROOT'
|
|
274
|
+
? scenario.actor
|
|
275
|
+
: (scopeContext.binding.actor ?? scopeContext.binding.id);
|
|
276
|
+
if (actor === undefined)
|
|
277
|
+
return unresolved(assignment, candidate, `Scenario does not declare actor for ${key}`);
|
|
278
|
+
return {
|
|
279
|
+
state: compare(actor, right, assignment.operator.text) ? 'true' : 'false',
|
|
280
|
+
unresolved: [],
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
if (key === 'date') {
|
|
284
|
+
if (scenario.date === undefined)
|
|
285
|
+
return unresolved(assignment, candidate, 'Scenario does not declare date');
|
|
286
|
+
const dateNumber = (value) => {
|
|
287
|
+
const parts = /^(\d+)\.(\d{1,2})\.(\d{1,2})$/u.exec(value);
|
|
288
|
+
if (parts === null)
|
|
289
|
+
return undefined;
|
|
290
|
+
const month = Number(parts[2]);
|
|
291
|
+
const day = Number(parts[3]);
|
|
292
|
+
return month < 1 || month > 12 || day < 1 || day > 31
|
|
293
|
+
? undefined
|
|
294
|
+
: Number(parts[1]) * 10_000 + month * 100 + day;
|
|
295
|
+
};
|
|
296
|
+
const leftDate = dateNumber(scenario.date);
|
|
297
|
+
const rightDate = dateNumber(right);
|
|
298
|
+
if (leftDate === undefined || rightDate === undefined)
|
|
299
|
+
return unresolved(assignment, candidate, 'Date comparison is not numeric');
|
|
300
|
+
return {
|
|
301
|
+
state: compare(leftDate, rightDate, assignment.operator.text) ? 'true' : 'false',
|
|
302
|
+
unresolved: [],
|
|
303
|
+
};
|
|
304
|
+
}
|
|
305
|
+
const helper = definitions.scriptedTriggers.get(key);
|
|
306
|
+
if (helper !== undefined && bool(right) !== undefined) {
|
|
307
|
+
if (helperStack.includes(key))
|
|
308
|
+
return unresolved(assignment, candidate, `Scripted trigger recursion detected at ${key}`);
|
|
309
|
+
const helperProvenance = {
|
|
310
|
+
path: helper.file.displayPath,
|
|
311
|
+
rootKind: helper.file.rootKind,
|
|
312
|
+
loadOrder: helper.file.loadOrder,
|
|
313
|
+
sourceHash: helper.file.sha256,
|
|
314
|
+
location: nodeLocation(helper.document, helper.node, key),
|
|
315
|
+
...(astPathFor(helper.document, helper.node) === undefined
|
|
316
|
+
? {}
|
|
317
|
+
: { astPath: astPathFor(helper.document, helper.node) }),
|
|
318
|
+
symbol: key,
|
|
319
|
+
helperChain: [...helperStack, key],
|
|
320
|
+
};
|
|
321
|
+
const evaluated = evaluateTriggerBlock(helper.value, scenario, {
|
|
322
|
+
...candidate,
|
|
323
|
+
document: helper.document,
|
|
324
|
+
provenance: [helperProvenance, ...candidate.provenance],
|
|
325
|
+
}, definitions, [...helperStack, key], scopeContext);
|
|
326
|
+
return {
|
|
327
|
+
...evaluated,
|
|
328
|
+
state: bool(right) === false ? invert(evaluated.state) : evaluated.state,
|
|
329
|
+
helperProvenance: [helperProvenance, ...(evaluated.helperProvenance ?? [])],
|
|
330
|
+
};
|
|
331
|
+
}
|
|
332
|
+
const declared = scopeStateValue(scenario, key, scopeContext) ??
|
|
333
|
+
numberValue(key, scenario, scopeContext, definitions, candidate.provenance[0]?.path);
|
|
334
|
+
if (declared === undefined)
|
|
335
|
+
return unresolved(assignment, candidate, `Scenario does not declare trigger ${key}`);
|
|
336
|
+
const expectedBoolean = bool(right);
|
|
337
|
+
const actualBoolean = bool(declared);
|
|
338
|
+
if (expectedBoolean !== undefined && actualBoolean !== undefined)
|
|
339
|
+
return { state: expectedBoolean === actualBoolean ? 'true' : 'false', unresolved: [] };
|
|
340
|
+
const leftNumber = numeric(declared);
|
|
341
|
+
const rightNumber = numberValue(right, scenario, scopeContext, definitions, candidate.provenance[0]?.path);
|
|
342
|
+
if (leftNumber !== undefined && rightNumber !== undefined)
|
|
343
|
+
return {
|
|
344
|
+
state: compare(leftNumber, rightNumber, assignment.operator.text) ? 'true' : 'false',
|
|
345
|
+
unresolved: [],
|
|
346
|
+
};
|
|
347
|
+
if (typeof declared === 'string')
|
|
348
|
+
return {
|
|
349
|
+
state: compare(declared, right, assignment.operator.text) ? 'true' : 'false',
|
|
350
|
+
unresolved: [],
|
|
351
|
+
};
|
|
352
|
+
return unresolved(assignment, candidate, `Trigger ${key} cannot compare the declared scenario value`);
|
|
353
|
+
}
|
|
354
|
+
function evaluateAssignment(assignment, scenario, candidate, definitions, helperStack, scopeContext) {
|
|
355
|
+
const evaluated = evaluateAssignmentCore(assignment, scenario, candidate, definitions, helperStack, scopeContext);
|
|
356
|
+
const source = candidate.provenance[0];
|
|
357
|
+
const provenance = candidate.document === undefined || source === undefined
|
|
358
|
+
? source
|
|
359
|
+
: {
|
|
360
|
+
...source,
|
|
361
|
+
location: nodeLocation(candidate.document, assignment, assignment.key.value),
|
|
362
|
+
...(astPathFor(candidate.document, assignment) === undefined
|
|
363
|
+
? {}
|
|
364
|
+
: { astPath: astPathFor(candidate.document, assignment) }),
|
|
365
|
+
symbol: assignment.key.value,
|
|
366
|
+
};
|
|
367
|
+
const expression = assignment.value.type === 'scalar'
|
|
368
|
+
? `${assignment.key.value} ${assignment.operator.text} ${assignment.value.value}`
|
|
369
|
+
: assignment.key.value;
|
|
370
|
+
return {
|
|
371
|
+
...evaluated,
|
|
372
|
+
trace: [
|
|
373
|
+
{
|
|
374
|
+
operation: 'eligibility',
|
|
375
|
+
expression,
|
|
376
|
+
applied: evaluated.state,
|
|
377
|
+
...(provenance === undefined ? {} : { provenance }),
|
|
378
|
+
},
|
|
379
|
+
...(evaluated.trace ?? []),
|
|
380
|
+
],
|
|
381
|
+
};
|
|
382
|
+
}
|
|
383
|
+
export function evaluateTriggerBlock(block, scenario, candidate, definitions, helperStack = [], scopeContext = rootScopeContext(scenario)) {
|
|
384
|
+
const override = scenario.candidateOverrides?.[candidate.id];
|
|
385
|
+
if (override !== undefined)
|
|
386
|
+
return { state: override ? 'true' : 'false', unresolved: [] };
|
|
387
|
+
if (block === undefined)
|
|
388
|
+
return { state: 'true', unresolved: [] };
|
|
389
|
+
const malformed = malformedBlock(block, candidate);
|
|
390
|
+
if (malformed !== undefined)
|
|
391
|
+
return malformed;
|
|
392
|
+
const children = assignments(block).map((assignment) => evaluateAssignment(assignment, scenario, candidate, definitions, helperStack, scopeContext));
|
|
393
|
+
return {
|
|
394
|
+
state: combineAnd(children.map(({ state }) => state)),
|
|
395
|
+
unresolved: children.flatMap(({ unresolved: items }) => items),
|
|
396
|
+
helperProvenance: children.flatMap(({ helperProvenance }) => helperProvenance ?? []),
|
|
397
|
+
trace: children.flatMap(({ trace }) => trace ?? []),
|
|
398
|
+
};
|
|
399
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
export const ScenarioScalarSchema = z.union([
|
|
3
|
+
z.string().max(16_384),
|
|
4
|
+
z.number(),
|
|
5
|
+
z.boolean(),
|
|
6
|
+
z.null(),
|
|
7
|
+
]);
|
|
8
|
+
export const ScenarioValueSchema = z.union([
|
|
9
|
+
ScenarioScalarSchema,
|
|
10
|
+
z.array(ScenarioScalarSchema).max(10_000),
|
|
11
|
+
]);
|
|
12
|
+
export const ConditionScopeBindingSchema = z
|
|
13
|
+
.object({
|
|
14
|
+
id: z.string().min(1).max(256),
|
|
15
|
+
type: z
|
|
16
|
+
.enum([
|
|
17
|
+
'country',
|
|
18
|
+
'state',
|
|
19
|
+
'character',
|
|
20
|
+
'unit_leader',
|
|
21
|
+
'operative',
|
|
22
|
+
'strategic_region',
|
|
23
|
+
'province',
|
|
24
|
+
'unknown',
|
|
25
|
+
])
|
|
26
|
+
.optional(),
|
|
27
|
+
actor: z.string().max(256).optional(),
|
|
28
|
+
state: z.record(z.string(), ScenarioValueSchema).default({}),
|
|
29
|
+
flags: z.array(z.string().max(256)).max(10_000).optional(),
|
|
30
|
+
eventTargets: z.record(z.string(), z.string().max(256)).optional(),
|
|
31
|
+
weight: z.union([z.number(), z.string().max(256)]).optional(),
|
|
32
|
+
})
|
|
33
|
+
.strict();
|
|
34
|
+
export const ConditionResultSchema = z
|
|
35
|
+
.object({
|
|
36
|
+
token: z.string(),
|
|
37
|
+
sourcePath: z.string(),
|
|
38
|
+
state: z.enum(['true', 'false', 'unresolved']),
|
|
39
|
+
localisationKey: z.string().optional(),
|
|
40
|
+
unresolved: z.array(z.string()),
|
|
41
|
+
})
|
|
42
|
+
.strict();
|
|
@@ -3,7 +3,8 @@ import path from 'node:path';
|
|
|
3
3
|
import { z } from 'zod/v4';
|
|
4
4
|
import { CONFIG_VERSION } from '../version.js';
|
|
5
5
|
import { ServiceError } from './result.js';
|
|
6
|
-
export const HTTP_MAX_SAFE_CONCURRENT_REQUESTS =
|
|
6
|
+
export const HTTP_MAX_SAFE_CONCURRENT_REQUESTS = 1024;
|
|
7
|
+
export const HTTP_DEFAULT_CONCURRENT_REQUESTS = 128;
|
|
7
8
|
export const HTTP_MAX_BODY_BYTES = 67_108_864;
|
|
8
9
|
export const HTTP_MAX_AGGREGATE_BODY_BYTES = 134_217_728;
|
|
9
10
|
export const HTTP_DEFAULT_SESSION_EVENT_BYTES = 2_097_152;
|
|
@@ -132,6 +133,8 @@ export const serverConfigurationSchema = z
|
|
|
132
133
|
})
|
|
133
134
|
.optional(),
|
|
134
135
|
scanMaxFiles: z.number().int().min(1).max(1_000_000).default(DEFAULT_SCAN_MAX_FILES),
|
|
136
|
+
maxConcurrentTools: z.number().int().min(1).max(32).default(2),
|
|
137
|
+
maxSharedTools: z.number().int().min(1).max(128).default(4),
|
|
135
138
|
scanMaxBytes: z
|
|
136
139
|
.number()
|
|
137
140
|
.int()
|
|
@@ -193,18 +196,18 @@ export const serverConfigurationSchema = z
|
|
|
193
196
|
headersTimeoutMs: z.number().int().min(1_000).max(120_000).default(10_000),
|
|
194
197
|
requestTimeoutMs: z.number().int().min(1_000).max(300_000).default(30_000),
|
|
195
198
|
keepAliveTimeoutMs: z.number().int().min(1_000).max(120_000).default(5_000),
|
|
196
|
-
maxConnections: z.number().int().min(1).max(100_000).default(
|
|
199
|
+
maxConnections: z.number().int().min(1).max(100_000).default(512),
|
|
197
200
|
maxRequestsPerSocket: z.number().int().min(1).max(100_000).default(100),
|
|
198
201
|
maxConcurrentRequests: z
|
|
199
202
|
.number()
|
|
200
203
|
.int()
|
|
201
204
|
.min(1)
|
|
202
205
|
.max(HTTP_MAX_SAFE_CONCURRENT_REQUESTS)
|
|
203
|
-
.default(
|
|
206
|
+
.default(HTTP_DEFAULT_CONCURRENT_REQUESTS),
|
|
204
207
|
maxSessions: z.number().int().min(1).max(10_000).default(128),
|
|
205
208
|
maxSessionsPerPrincipal: z.number().int().min(1).max(10_000).default(32),
|
|
206
|
-
maxEventStreams: z.number().int().min(1).max(10_000).default(
|
|
207
|
-
maxEventStreamsPerPrincipal: z.number().int().min(1).max(10_000).default(
|
|
209
|
+
maxEventStreams: z.number().int().min(1).max(10_000).default(128),
|
|
210
|
+
maxEventStreamsPerPrincipal: z.number().int().min(1).max(10_000).default(32),
|
|
208
211
|
maxSessionEventBytes: z
|
|
209
212
|
.number()
|
|
210
213
|
.int()
|
|
@@ -212,7 +215,7 @@ export const serverConfigurationSchema = z
|
|
|
212
215
|
.max(67_108_864)
|
|
213
216
|
.default(HTTP_DEFAULT_SESSION_EVENT_BYTES),
|
|
214
217
|
maxEventStoreBytes: z.number().int().min(65_536).max(268_435_456).default(16_777_216),
|
|
215
|
-
requestsPerMinute: z.number().int().min(1).max(100_000).default(
|
|
218
|
+
requestsPerMinute: z.number().int().min(1).max(100_000).default(6_000),
|
|
216
219
|
sessionTtlSeconds: z.number().int().min(60).max(86_400).default(3600),
|
|
217
220
|
})
|
|
218
221
|
.strict()
|
|
@@ -274,16 +277,16 @@ export const serverConfigurationSchema = z
|
|
|
274
277
|
headersTimeoutMs: 10_000,
|
|
275
278
|
requestTimeoutMs: 30_000,
|
|
276
279
|
keepAliveTimeoutMs: 5_000,
|
|
277
|
-
maxConnections:
|
|
280
|
+
maxConnections: 512,
|
|
278
281
|
maxRequestsPerSocket: 100,
|
|
279
|
-
maxConcurrentRequests:
|
|
282
|
+
maxConcurrentRequests: HTTP_DEFAULT_CONCURRENT_REQUESTS,
|
|
280
283
|
maxSessions: 128,
|
|
281
284
|
maxSessionsPerPrincipal: 32,
|
|
282
|
-
maxEventStreams:
|
|
283
|
-
maxEventStreamsPerPrincipal:
|
|
285
|
+
maxEventStreams: 128,
|
|
286
|
+
maxEventStreamsPerPrincipal: 32,
|
|
284
287
|
maxSessionEventBytes: HTTP_DEFAULT_SESSION_EVENT_BYTES,
|
|
285
288
|
maxEventStoreBytes: 16_777_216,
|
|
286
|
-
requestsPerMinute:
|
|
289
|
+
requestsPerMinute: 6_000,
|
|
287
290
|
sessionTtlSeconds: 3600,
|
|
288
291
|
}),
|
|
289
292
|
})
|
|
@@ -332,23 +335,6 @@ export const serverConfigurationSchema = z
|
|
|
332
335
|
message: 'HTTP keep-alive timeout must be shorter than the header timeout',
|
|
333
336
|
});
|
|
334
337
|
}
|
|
335
|
-
const concurrentScanBytes = value.scanMaxBytes * value.http.maxConcurrentRequests;
|
|
336
|
-
if (!Number.isSafeInteger(concurrentScanBytes) || concurrentScanBytes > 536_870_912) {
|
|
337
|
-
context.addIssue({
|
|
338
|
-
code: 'custom',
|
|
339
|
-
path: ['scanMaxBytes'],
|
|
340
|
-
message: 'Scan-byte ceiling multiplied by HTTP concurrency must not exceed 512 MiB',
|
|
341
|
-
});
|
|
342
|
-
}
|
|
343
|
-
const concurrentBodyBytes = value.http.maxBodyBytes * value.http.maxConcurrentRequests;
|
|
344
|
-
if (!Number.isSafeInteger(concurrentBodyBytes) ||
|
|
345
|
-
concurrentBodyBytes > HTTP_MAX_AGGREGATE_BODY_BYTES) {
|
|
346
|
-
context.addIssue({
|
|
347
|
-
code: 'custom',
|
|
348
|
-
path: ['http', 'maxBodyBytes'],
|
|
349
|
-
message: 'HTTP body-byte ceiling multiplied by concurrency must not exceed 128 MiB',
|
|
350
|
-
});
|
|
351
|
-
}
|
|
352
338
|
});
|
|
353
339
|
export async function loadConfiguration(filePath) {
|
|
354
340
|
const absolute = path.resolve(filePath);
|
|
@@ -3,6 +3,8 @@ import { SymbolIndex } from './index.js';
|
|
|
3
3
|
import { WorkspaceScanner } from './scanner.js';
|
|
4
4
|
import { ArtifactStore } from './artifacts.js';
|
|
5
5
|
import { TransactionManager } from './transactions.js';
|
|
6
|
+
import { RequestScheduler } from './request-scheduler.js';
|
|
7
|
+
import { SharedRequestCapacity } from './shared-request-capacity.js';
|
|
6
8
|
const RECOVERY_TTL_SECONDS = 3_600;
|
|
7
9
|
const RECOVERY_MAX_BYTES = 536_870_912;
|
|
8
10
|
const RECOVERY_MAX_RECORDS = 128;
|
|
@@ -74,6 +76,8 @@ function defaultPatterns(workspace) {
|
|
|
74
76
|
}
|
|
75
77
|
export class CoreEngine {
|
|
76
78
|
resolver;
|
|
79
|
+
requests;
|
|
80
|
+
sharedRequests;
|
|
77
81
|
scanner;
|
|
78
82
|
artifacts;
|
|
79
83
|
transactions;
|
|
@@ -84,6 +88,8 @@ export class CoreEngine {
|
|
|
84
88
|
#activeScanAdmissions = new Set();
|
|
85
89
|
constructor(resolver, services = {}) {
|
|
86
90
|
this.resolver = resolver;
|
|
91
|
+
this.requests = new RequestScheduler(resolver.config().maxConcurrentTools);
|
|
92
|
+
this.sharedRequests = new SharedRequestCapacity(resolver.serverState()?.root, resolver.config().maxSharedTools);
|
|
87
93
|
this.scanner =
|
|
88
94
|
services.scanner ??
|
|
89
95
|
new WorkspaceScanner(resolver.config().scanMaxFiles, resolver.config().scanMaxBytes, resolver.config().scanMaxFileBytes);
|
|
@@ -159,7 +165,7 @@ export class CoreEngine {
|
|
|
159
165
|
const cached = this.#scanCache.get(cacheKey);
|
|
160
166
|
if (cached !== undefined)
|
|
161
167
|
return cached;
|
|
162
|
-
const index = SymbolIndex.
|
|
168
|
+
const index = await SymbolIndex.buildAsync(files, controller.signal);
|
|
163
169
|
const snapshot = {
|
|
164
170
|
workspaceId,
|
|
165
171
|
revision,
|