@you-agent-factory/factory-emulator 0.0.0 → 0.0.1
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/LICENSE.md +19 -1
- package/README.md +305 -286
- package/dist/compatibility.d.ts +19 -0
- package/dist/compatibility.js +156 -0
- package/dist/event-sink.d.ts +29 -0
- package/dist/event-sink.js +58 -0
- package/dist/generated/scenario-schema.d.ts +268 -0
- package/dist/generated/scenario-schema.js +319 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +8 -0
- package/dist/logical-move.d.ts +10 -0
- package/dist/logical-move.js +18 -0
- package/dist/recording-sink.d.ts +22 -0
- package/dist/recording-sink.js +122 -0
- package/dist/runtime-reference-conformance.d.ts +20 -0
- package/dist/runtime-reference-conformance.js +138 -0
- package/dist/runtime-reference-evidence.d.ts +9 -0
- package/dist/runtime-reference-evidence.js +193 -0
- package/dist/runtime-reference-fixtures.d.ts +7 -0
- package/dist/runtime-reference-fixtures.js +308 -0
- package/dist/runtime-reference.d.ts +48 -0
- package/dist/runtime-reference.js +143 -0
- package/dist/scenario-contracts.d.ts +81 -0
- package/dist/scenario-contracts.js +11 -0
- package/dist/scenario.d.ts +271 -0
- package/dist/scenario.js +333 -0
- package/dist/scheduler.d.ts +24 -0
- package/dist/scheduler.js +104 -0
- package/dist/session-contracts.d.ts +262 -0
- package/dist/session-contracts.js +74 -0
- package/dist/session.d.ts +4 -0
- package/dist/session.js +1490 -0
- package/dist/submission-replay.d.ts +14 -0
- package/dist/submission-replay.js +96 -0
- package/dist/submission-validation.d.ts +3 -0
- package/dist/submission-validation.js +113 -0
- package/examples/customer-support.scenario.v1.json +45 -0
- package/package.json +44 -22
- package/schema/scenario.schema.json +171 -0
- package/generated/factory-emulator-scenario.schema.json +0 -191
- package/generated/factory.schema.json +0 -2606
- package/src/contracts.ts +0 -41
- package/src/data-error.js +0 -21
- package/src/data-only.js +0 -208
- package/src/emulator.js +0 -195
- package/src/emulator.ts +0 -86
- package/src/examples.js +0 -86
- package/src/examples.ts +0 -11
- package/src/generated/factory-schema.d.ts +0 -3
- package/src/generated/factory-schema.js +0 -5
- package/src/generated/scenario-schema.d.ts +0 -4
- package/src/generated/scenario-schema.js +0 -6
- package/src/generated/scenario.ts +0 -103
- package/src/identity.js +0 -55
- package/src/index.js +0 -34
- package/src/index.ts +0 -102
- package/src/limits.js +0 -125
- package/src/parser.js +0 -113
- package/src/parser.ts +0 -56
- package/src/scheduler.js +0 -374
- package/src/semantics.js +0 -354
- package/src/semantics.ts +0 -38
- package/src/session.js +0 -1201
- package/src/session.ts +0 -324
- package/src/sinks.js +0 -118
- package/src/sinks.ts +0 -56
- package/src/support.js +0 -334
- package/src/support.ts +0 -15
- package/src/virtual-time.js +0 -36
package/dist/session.js
ADDED
|
@@ -0,0 +1,1490 @@
|
|
|
1
|
+
import { inspectFactoryEmulatorCompatibility } from "./compatibility.js";
|
|
2
|
+
import { visitCountGuardsAllow, visitsAfterTransition, } from "./logical-move.js";
|
|
3
|
+
import { safeParseFactoryEmulatorScenario } from "./scenario.js";
|
|
4
|
+
import { compareFactorySchedulerCandidates, FACTORY_EMULATOR_SCHEDULER_CANDIDATE_LIMIT, selectFactorySchedulerCandidates, } from "./scheduler.js";
|
|
5
|
+
import { DEFAULT_FACTORY_EMULATOR_LIMITS, FACTORY_EMULATOR_LIMIT_HARD_CAPS, FactoryEmulatorConfigurationError, FactoryEmulatorDurationError, FactoryEmulatorExecutionPausedError, FactoryEmulatorLifecycleError, FactoryEmulatorPendingCommandError, FactoryEmulatorSubmissionError, } from "./session-contracts.js";
|
|
6
|
+
export * from "./session-contracts.js";
|
|
7
|
+
const LIMIT_NAMES = Object.keys(DEFAULT_FACTORY_EMULATOR_LIMITS);
|
|
8
|
+
const PRE_START_STATE = {
|
|
9
|
+
lifecycle: "pre-start",
|
|
10
|
+
virtualElapsedMs: 0,
|
|
11
|
+
counters: { commands: 0, events: 0, completedDispatches: 0 },
|
|
12
|
+
};
|
|
13
|
+
function isSubmissionArray(value) {
|
|
14
|
+
return Array.isArray(value);
|
|
15
|
+
}
|
|
16
|
+
function clone(value) {
|
|
17
|
+
return JSON.parse(JSON.stringify(value));
|
|
18
|
+
}
|
|
19
|
+
function canonicalJson(value) {
|
|
20
|
+
return JSON.stringify(value, (_key, nested) => {
|
|
21
|
+
if (nested === null ||
|
|
22
|
+
typeof nested !== "object" ||
|
|
23
|
+
Array.isArray(nested)) {
|
|
24
|
+
return nested;
|
|
25
|
+
}
|
|
26
|
+
return Object.fromEntries(Object.entries(nested).sort(([left], [right]) => left < right ? -1 : left > right ? 1 : 0));
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
function canonicalClone(value) {
|
|
30
|
+
return JSON.parse(canonicalJson(value));
|
|
31
|
+
}
|
|
32
|
+
function resolveLimits(configured, diagnostics) {
|
|
33
|
+
const limits = { ...DEFAULT_FACTORY_EMULATOR_LIMITS, ...configured };
|
|
34
|
+
for (const name of LIMIT_NAMES) {
|
|
35
|
+
const value = limits[name];
|
|
36
|
+
const hardCap = FACTORY_EMULATOR_LIMIT_HARD_CAPS[name];
|
|
37
|
+
if (!Number.isSafeInteger(value) || value < 1 || value > hardCap) {
|
|
38
|
+
diagnostics.push({
|
|
39
|
+
code: "invalid_limit",
|
|
40
|
+
path: ["limits", name],
|
|
41
|
+
message: `${name} must be a positive safe integer no greater than ${hardCap}.`,
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return Object.freeze(limits);
|
|
46
|
+
}
|
|
47
|
+
function validateConfiguration(options) {
|
|
48
|
+
const diagnostics = [];
|
|
49
|
+
const factory = options?.factory;
|
|
50
|
+
if (factory === null ||
|
|
51
|
+
typeof factory !== "object" ||
|
|
52
|
+
typeof factory.name !== "string" ||
|
|
53
|
+
factory.name.trim() === "") {
|
|
54
|
+
diagnostics.push({
|
|
55
|
+
code: "invalid_factory",
|
|
56
|
+
path: ["factory"],
|
|
57
|
+
message: "factory must be a Factory definition with a non-empty name.",
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
let scenario;
|
|
61
|
+
if (diagnostics.length === 0) {
|
|
62
|
+
const parsed = safeParseFactoryEmulatorScenario(options.scenario, factory);
|
|
63
|
+
if (parsed.success)
|
|
64
|
+
scenario = parsed.data;
|
|
65
|
+
else
|
|
66
|
+
diagnostics.push(...parsed.issues);
|
|
67
|
+
}
|
|
68
|
+
if (diagnostics.length === 0) {
|
|
69
|
+
const compatibility = inspectFactoryEmulatorCompatibility(factory);
|
|
70
|
+
if (!compatibility.supported) {
|
|
71
|
+
diagnostics.push(...compatibility.diagnostics.map((issue) => ({
|
|
72
|
+
code: "incompatible_factory",
|
|
73
|
+
path: ["factory", ...issue.path],
|
|
74
|
+
message: issue.message,
|
|
75
|
+
})));
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
if (options?.sink === null || typeof options?.sink?.write !== "function") {
|
|
79
|
+
diagnostics.push({
|
|
80
|
+
code: "invalid_sink",
|
|
81
|
+
path: ["sink"],
|
|
82
|
+
message: "sink must provide an asynchronous write(events) function.",
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
if (options?.yieldControl !== undefined &&
|
|
86
|
+
typeof options.yieldControl !== "function") {
|
|
87
|
+
diagnostics.push({
|
|
88
|
+
code: "invalid_yield_control",
|
|
89
|
+
path: ["yieldControl"],
|
|
90
|
+
message: "yieldControl must be a function when provided.",
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
const limits = resolveLimits(options?.limits, diagnostics);
|
|
94
|
+
if (diagnostics.length > 0 || scenario === undefined) {
|
|
95
|
+
throw new FactoryEmulatorConfigurationError(diagnostics);
|
|
96
|
+
}
|
|
97
|
+
return {
|
|
98
|
+
factory: canonicalClone(factory),
|
|
99
|
+
scenario: canonicalClone(scenario),
|
|
100
|
+
sink: options.sink,
|
|
101
|
+
limits,
|
|
102
|
+
yieldControl: options.yieldControl,
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
function deterministicHash(value) {
|
|
106
|
+
let hash = 0xcbf29ce484222325n;
|
|
107
|
+
for (const character of value) {
|
|
108
|
+
hash ^= BigInt(character.codePointAt(0) ?? 0);
|
|
109
|
+
hash = BigInt.asUintN(64, hash * 0x100000001b3n);
|
|
110
|
+
}
|
|
111
|
+
return hash.toString(16).padStart(16, "0");
|
|
112
|
+
}
|
|
113
|
+
function identity(kind, ...coordinates) {
|
|
114
|
+
return `emulator-${kind}-${deterministicHash(canonicalJson(coordinates))}`;
|
|
115
|
+
}
|
|
116
|
+
function sessionIdentity(factory, scenario) {
|
|
117
|
+
return identity("session", factory.id ?? factory.name, scenario);
|
|
118
|
+
}
|
|
119
|
+
function virtualTimeAt(scenario, elapsedMs) {
|
|
120
|
+
const timestamp = Date.parse(scenario.startAt) + elapsedMs;
|
|
121
|
+
const instant = new Date(timestamp);
|
|
122
|
+
if (!Number.isSafeInteger(elapsedMs) ||
|
|
123
|
+
!Number.isFinite(timestamp) ||
|
|
124
|
+
!Number.isFinite(instant.getTime())) {
|
|
125
|
+
throw new FactoryEmulatorDurationError(elapsedMs);
|
|
126
|
+
}
|
|
127
|
+
return instant.toISOString();
|
|
128
|
+
}
|
|
129
|
+
function eventContext(state, sequence, eventTime, context = {}) {
|
|
130
|
+
return {
|
|
131
|
+
sequence,
|
|
132
|
+
tick: state.counters.commands,
|
|
133
|
+
eventTime,
|
|
134
|
+
sessionId: state.sessionId,
|
|
135
|
+
sessionSequence: sequence,
|
|
136
|
+
orchestratorKind: "PETRI",
|
|
137
|
+
source: "emulator",
|
|
138
|
+
...context,
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
function bootstrapEvents(factory, scenario, sessionId) {
|
|
142
|
+
const eventTime = scenario.startAt;
|
|
143
|
+
const context = (sequence) => ({
|
|
144
|
+
sequence,
|
|
145
|
+
tick: 0,
|
|
146
|
+
eventTime,
|
|
147
|
+
sessionId,
|
|
148
|
+
sessionSequence: sequence,
|
|
149
|
+
orchestratorKind: "PETRI",
|
|
150
|
+
source: "emulator",
|
|
151
|
+
});
|
|
152
|
+
return [
|
|
153
|
+
{
|
|
154
|
+
schemaVersion: "agent-factory.event.v1",
|
|
155
|
+
id: `${sessionId}/event/0/run-request`,
|
|
156
|
+
type: "RUN_REQUEST",
|
|
157
|
+
context: context(0),
|
|
158
|
+
payload: { recordedAt: eventTime, factory: clone(factory) },
|
|
159
|
+
},
|
|
160
|
+
{
|
|
161
|
+
schemaVersion: "agent-factory.event.v1",
|
|
162
|
+
id: `${sessionId}/event/1/initial-structure-request`,
|
|
163
|
+
type: "INITIAL_STRUCTURE_REQUEST",
|
|
164
|
+
context: context(1),
|
|
165
|
+
payload: { factory: clone(factory) },
|
|
166
|
+
},
|
|
167
|
+
{
|
|
168
|
+
schemaVersion: "agent-factory.event.v1",
|
|
169
|
+
id: `${sessionId}/event/2/session-started`,
|
|
170
|
+
type: "SESSION_STARTED",
|
|
171
|
+
context: context(2),
|
|
172
|
+
payload: { factoryId: factory.id ?? factory.name, startedAt: eventTime },
|
|
173
|
+
},
|
|
174
|
+
];
|
|
175
|
+
}
|
|
176
|
+
function ruleMatches(rule, submissions, workstation) {
|
|
177
|
+
const selector = rule.selector;
|
|
178
|
+
return ((selector.workstation === undefined ||
|
|
179
|
+
selector.workstation === workstation.name) &&
|
|
180
|
+
(selector.worker === undefined || selector.worker === workstation.worker) &&
|
|
181
|
+
(selector.input === undefined ||
|
|
182
|
+
submissions.some((submission) => (selector.input?.workType === undefined ||
|
|
183
|
+
selector.input.workType === submission.workType) &&
|
|
184
|
+
(selector.input?.state === undefined ||
|
|
185
|
+
selector.input.state === submission.state) &&
|
|
186
|
+
(selector.input?.name === undefined ||
|
|
187
|
+
selector.input.name === submission.name))));
|
|
188
|
+
}
|
|
189
|
+
function executionFor(configuration, submissions, workstation, invocationIndex = 0) {
|
|
190
|
+
if (workstation.type === "LOGICAL_MOVE") {
|
|
191
|
+
return {
|
|
192
|
+
workstation,
|
|
193
|
+
outcome: { result: "accepted", durationMs: 0 },
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
const rule = configuration.scenario.rules.find((candidate) => ruleMatches(candidate, submissions, workstation));
|
|
197
|
+
if (rule === undefined) {
|
|
198
|
+
return configuration.scenario.unmatched.behavior === "outcome"
|
|
199
|
+
? { workstation, outcome: configuration.scenario.unmatched.outcome }
|
|
200
|
+
: { workstation };
|
|
201
|
+
}
|
|
202
|
+
const outcome = rule.outcomes[invocationIndex] ??
|
|
203
|
+
(rule.exhaustion === "repeat-last" ? rule.outcomes.at(-1) : undefined);
|
|
204
|
+
return { rule, workstation, outcome };
|
|
205
|
+
}
|
|
206
|
+
function submissionForWork(work) {
|
|
207
|
+
return {
|
|
208
|
+
name: work.submissionId,
|
|
209
|
+
workType: work.workType,
|
|
210
|
+
state: work.state,
|
|
211
|
+
...(work.input === undefined ? {} : { input: work.input }),
|
|
212
|
+
...(work.parent === undefined ? {} : { parent: work.parent }),
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
function bindingIndexesFor(state, workstation, observeExpansion, visit) {
|
|
216
|
+
const inputs = workstation.inputs.map((input) => state.works.flatMap((work, index) => work.phase === "ready" &&
|
|
217
|
+
work.workType === input.workType &&
|
|
218
|
+
work.state === input.state
|
|
219
|
+
? [index]
|
|
220
|
+
: []));
|
|
221
|
+
if (inputs.some((matches) => matches.length === 0))
|
|
222
|
+
return;
|
|
223
|
+
const append = (slot, selected) => {
|
|
224
|
+
if (slot === inputs.length) {
|
|
225
|
+
visit(selected);
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
for (const index of inputs[slot] ?? []) {
|
|
229
|
+
if (!selected.includes(index)) {
|
|
230
|
+
observeExpansion();
|
|
231
|
+
append(slot + 1, [...selected, index]);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
};
|
|
235
|
+
append(0, []);
|
|
236
|
+
}
|
|
237
|
+
function dependencyStatesFor(state) {
|
|
238
|
+
const current = new Map();
|
|
239
|
+
for (const work of state.works) {
|
|
240
|
+
if (work.phase !== "active")
|
|
241
|
+
current.set(work.workId, work.state);
|
|
242
|
+
else
|
|
243
|
+
current.delete(work.workId);
|
|
244
|
+
}
|
|
245
|
+
return current;
|
|
246
|
+
}
|
|
247
|
+
function dependenciesSatisfied(work, dependencyStates) {
|
|
248
|
+
return (work.relations?.every(({ targetWorkId, requiredState }) => dependencyStates.get(targetWorkId) === requiredState) ?? true);
|
|
249
|
+
}
|
|
250
|
+
function dependenciesAllow(works, indexes, dependencyStates, executableWork) {
|
|
251
|
+
if (works.every((work) => dependenciesSatisfied(work, dependencyStates))) {
|
|
252
|
+
return true;
|
|
253
|
+
}
|
|
254
|
+
for (const index of indexes)
|
|
255
|
+
executableWork.add(index);
|
|
256
|
+
return false;
|
|
257
|
+
}
|
|
258
|
+
function potentiallyBoundIndexes(state, workstation) {
|
|
259
|
+
return state.works.flatMap((work, index) => work.phase === "ready" &&
|
|
260
|
+
workstation.inputs.some((input) => input.workType === work.workType && input.state === work.state)
|
|
261
|
+
? [index]
|
|
262
|
+
: []);
|
|
263
|
+
}
|
|
264
|
+
function schedulerCandidatesFor(configuration, state, cursors, assertBindingBudget) {
|
|
265
|
+
const candidates = [];
|
|
266
|
+
const executableWork = new Set();
|
|
267
|
+
const dependencyStates = dependencyStatesFor(state);
|
|
268
|
+
let observedExpansions = 0;
|
|
269
|
+
const retainBoundedCandidate = (candidate) => {
|
|
270
|
+
candidates.push(candidate);
|
|
271
|
+
candidates.sort(compareFactorySchedulerCandidates);
|
|
272
|
+
if (candidates.length > FACTORY_EMULATOR_SCHEDULER_CANDIDATE_LIMIT) {
|
|
273
|
+
candidates.pop();
|
|
274
|
+
}
|
|
275
|
+
};
|
|
276
|
+
for (const workstation of configuration.factory.workstations ?? []) {
|
|
277
|
+
const potentiallyBound = potentiallyBoundIndexes(state, workstation);
|
|
278
|
+
let foundBinding = false;
|
|
279
|
+
const seenBindings = new Set();
|
|
280
|
+
bindingIndexesFor(state, workstation, () => {
|
|
281
|
+
observedExpansions += 1;
|
|
282
|
+
assertBindingBudget(observedExpansions);
|
|
283
|
+
}, (indexes) => {
|
|
284
|
+
foundBinding = true;
|
|
285
|
+
const key = [...indexes]
|
|
286
|
+
.map((index) => state.works[index]?.tokenId ?? "")
|
|
287
|
+
.sort()
|
|
288
|
+
.join("\u0000");
|
|
289
|
+
if (seenBindings.has(key))
|
|
290
|
+
return;
|
|
291
|
+
seenBindings.add(key);
|
|
292
|
+
const works = indexes.flatMap((index) => {
|
|
293
|
+
const work = state.works[index];
|
|
294
|
+
return work === undefined ? [] : [work];
|
|
295
|
+
});
|
|
296
|
+
const primary = works[0];
|
|
297
|
+
if (primary === undefined || works.length !== workstation.inputs.length)
|
|
298
|
+
return;
|
|
299
|
+
if (!dependenciesAllow(works, indexes, dependencyStates, executableWork))
|
|
300
|
+
return;
|
|
301
|
+
if (workstation.type === "LOGICAL_MOVE" &&
|
|
302
|
+
!visitCountGuardsAllow(workstation.guards ?? [], primary.visits)) {
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
const submissions = works.map(submissionForWork);
|
|
306
|
+
const initial = executionFor(configuration, submissions, workstation);
|
|
307
|
+
const lineage = [
|
|
308
|
+
...new Set(works.map(({ rootWorkId }) => rootWorkId)),
|
|
309
|
+
].sort();
|
|
310
|
+
const cursorKey = initial.rule === undefined
|
|
311
|
+
? undefined
|
|
312
|
+
: `${initial.rule.id}:${canonicalJson(lineage)}`;
|
|
313
|
+
const invocation = cursorKey === undefined ? 0 : (cursors[cursorKey] ?? 0);
|
|
314
|
+
const execution = executionFor(configuration, submissions, workstation, invocation);
|
|
315
|
+
if (execution.outcome === undefined)
|
|
316
|
+
return;
|
|
317
|
+
for (const index of indexes)
|
|
318
|
+
executableWork.add(index);
|
|
319
|
+
retainBoundedCandidate({
|
|
320
|
+
transitionId: workstation.name,
|
|
321
|
+
workerId: workstation.worker,
|
|
322
|
+
workstationKind: workstation.type === "LOGICAL_MOVE" ? "logical" : "normal",
|
|
323
|
+
resources: (workstation.resources ?? []).map(({ name, capacity }) => ({
|
|
324
|
+
name,
|
|
325
|
+
capacity,
|
|
326
|
+
})),
|
|
327
|
+
tokens: works.map((work) => ({
|
|
328
|
+
tokenId: work.tokenId,
|
|
329
|
+
customerWork: true,
|
|
330
|
+
processing: configuration.factory.workTypes
|
|
331
|
+
?.find(({ name }) => name === work.workType)
|
|
332
|
+
?.states.find(({ name }) => name === work.state)?.type ===
|
|
333
|
+
"PROCESSING",
|
|
334
|
+
queuedElapsedMs: work.queuedElapsedMs,
|
|
335
|
+
...(work.lastDispatchElapsedMs === undefined
|
|
336
|
+
? {}
|
|
337
|
+
: { lastDispatchElapsedMs: work.lastDispatchElapsedMs }),
|
|
338
|
+
})),
|
|
339
|
+
value: {
|
|
340
|
+
indexes,
|
|
341
|
+
execution,
|
|
342
|
+
invocation,
|
|
343
|
+
...(cursorKey === undefined ? {} : { cursorKey }),
|
|
344
|
+
},
|
|
345
|
+
});
|
|
346
|
+
});
|
|
347
|
+
if (!foundBinding) {
|
|
348
|
+
for (const index of potentiallyBound)
|
|
349
|
+
executableWork.add(index);
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
return { candidates, executableWork };
|
|
353
|
+
}
|
|
354
|
+
function availableResourcesFor(configuration, state) {
|
|
355
|
+
const allocated = state.works.reduce((claims, work) => {
|
|
356
|
+
if (work.phase !== "active" || work.dispatch === undefined)
|
|
357
|
+
return claims;
|
|
358
|
+
for (const resource of work.dispatch.resources) {
|
|
359
|
+
claims[resource.name] =
|
|
360
|
+
(claims[resource.name] ?? 0) + resource.capacity;
|
|
361
|
+
}
|
|
362
|
+
return claims;
|
|
363
|
+
}, {});
|
|
364
|
+
return Object.fromEntries((configuration.factory.resources ?? []).map((resource) => [
|
|
365
|
+
resource.name,
|
|
366
|
+
Math.max(0, resource.capacity - (allocated[resource.name] ?? 0)),
|
|
367
|
+
]));
|
|
368
|
+
}
|
|
369
|
+
function eventResourcesFor(configuration, claims) {
|
|
370
|
+
const totals = new Map((configuration.factory.resources ?? []).map(({ name, capacity }) => [
|
|
371
|
+
name,
|
|
372
|
+
capacity,
|
|
373
|
+
]));
|
|
374
|
+
return claims.map(({ name }) => ({ name, capacity: totals.get(name) ?? 0 }));
|
|
375
|
+
}
|
|
376
|
+
function defaultFailureRoutesFor(configuration, workstation) {
|
|
377
|
+
const routes = [];
|
|
378
|
+
const seen = new Set();
|
|
379
|
+
for (const input of workstation.inputs) {
|
|
380
|
+
const failedState = configuration.factory.workTypes
|
|
381
|
+
?.find(({ name }) => name === input.workType)
|
|
382
|
+
?.states.find(({ type }) => type === "FAILED")?.name;
|
|
383
|
+
if (failedState === undefined)
|
|
384
|
+
continue;
|
|
385
|
+
const key = `${input.workType}\u0000${failedState}`;
|
|
386
|
+
if (seen.has(key))
|
|
387
|
+
continue;
|
|
388
|
+
seen.add(key);
|
|
389
|
+
routes.push({ workType: input.workType, state: failedState });
|
|
390
|
+
}
|
|
391
|
+
return routes;
|
|
392
|
+
}
|
|
393
|
+
function outcomeRoutesFor(configuration, workstation, result) {
|
|
394
|
+
if (result === "accepted")
|
|
395
|
+
return workstation.outputs ?? [];
|
|
396
|
+
if (result === "continued")
|
|
397
|
+
return workstation.onContinue ?? [];
|
|
398
|
+
if (result === "failed" && (workstation.onFailure?.length ?? 0) > 0) {
|
|
399
|
+
return workstation.onFailure ?? [];
|
|
400
|
+
}
|
|
401
|
+
if (result === "rejected" && (workstation.onRejection?.length ?? 0) > 0) {
|
|
402
|
+
return workstation.onRejection ?? [];
|
|
403
|
+
}
|
|
404
|
+
if (result === "rejected" && workstation.behavior === "REPEATER") {
|
|
405
|
+
return workstation.inputs;
|
|
406
|
+
}
|
|
407
|
+
return defaultFailureRoutesFor(configuration, workstation);
|
|
408
|
+
}
|
|
409
|
+
function routedWorksFor(configuration, state, dispatch, inputs) {
|
|
410
|
+
const workstation = configuration.factory.workstations?.find(({ name }) => name === dispatch.workstation);
|
|
411
|
+
const first = inputs[0];
|
|
412
|
+
if (workstation === undefined || first === undefined)
|
|
413
|
+
return [];
|
|
414
|
+
const routes = outcomeRoutesFor(configuration, workstation, dispatch.outcome.result);
|
|
415
|
+
const visits = visitsAfterTransition(inputs.map((input) => input.visits), workstation.name);
|
|
416
|
+
return routes.map((route, ordinal) => {
|
|
417
|
+
const matching = inputs.find(({ workType }) => workType === route.workType);
|
|
418
|
+
const lineageSource = matching ?? first;
|
|
419
|
+
const workId = matching?.workId ??
|
|
420
|
+
identity("work", state.sessionId, "route", dispatch.dispatchId, ordinal, route);
|
|
421
|
+
const targetStateType = configuration.factory.workTypes
|
|
422
|
+
?.find(({ name }) => name === route.workType)
|
|
423
|
+
?.states.find(({ name }) => name === route.state)?.type;
|
|
424
|
+
const preserveInput = workstation.workPropagation?.mode === "PRESERVE_INPUT" ||
|
|
425
|
+
dispatch.outcome.result === "failed" ||
|
|
426
|
+
(dispatch.outcome.result === "rejected" && targetStateType === "FAILED");
|
|
427
|
+
const input = preserveInput
|
|
428
|
+
? lineageSource.input
|
|
429
|
+
: (dispatch.outcome.output ?? lineageSource.input);
|
|
430
|
+
return {
|
|
431
|
+
submissionId: `${first.submissionId}/${workstation.name}/${ordinal}`,
|
|
432
|
+
requestId: first.requestId,
|
|
433
|
+
traceId: lineageSource.traceId,
|
|
434
|
+
tokenId: identity("token", dispatch.dispatchId, ordinal, route),
|
|
435
|
+
workId,
|
|
436
|
+
rootWorkId: lineageSource.rootWorkId,
|
|
437
|
+
visits,
|
|
438
|
+
workType: route.workType,
|
|
439
|
+
state: route.state,
|
|
440
|
+
...(input === undefined ? {} : { input }),
|
|
441
|
+
...(matching !== undefined
|
|
442
|
+
? matching.parent === undefined
|
|
443
|
+
? {}
|
|
444
|
+
: { parent: matching.parent }
|
|
445
|
+
: { parent: first.workId }),
|
|
446
|
+
...(matching?.relations === undefined
|
|
447
|
+
? {}
|
|
448
|
+
: { relations: matching.relations }),
|
|
449
|
+
queuedElapsedMs: dispatch.dueElapsedMs,
|
|
450
|
+
lastDispatchElapsedMs: Math.min(lineageSource.lastDispatchElapsedMs ?? dispatch.dueElapsedMs, dispatch.dueElapsedMs),
|
|
451
|
+
phase: targetStateType === "TERMINAL" || targetStateType === "FAILED"
|
|
452
|
+
? "completed"
|
|
453
|
+
: "ready",
|
|
454
|
+
};
|
|
455
|
+
});
|
|
456
|
+
}
|
|
457
|
+
function eventWorkFor(configuration, work) {
|
|
458
|
+
return {
|
|
459
|
+
name: work.submissionId,
|
|
460
|
+
workId: work.workId,
|
|
461
|
+
requestId: work.requestId,
|
|
462
|
+
workTypeName: work.workType,
|
|
463
|
+
state: {
|
|
464
|
+
name: work.state,
|
|
465
|
+
type: configuration.factory.workTypes
|
|
466
|
+
?.find(({ name }) => name === work.workType)
|
|
467
|
+
?.states.find(({ name }) => name === work.state)?.type ??
|
|
468
|
+
"PROCESSING",
|
|
469
|
+
},
|
|
470
|
+
currentChainingTraceId: work.traceId,
|
|
471
|
+
traceId: work.traceId,
|
|
472
|
+
...(work.input === undefined ? {} : { payload: work.input }),
|
|
473
|
+
};
|
|
474
|
+
}
|
|
475
|
+
function stateTypeFor(configuration, work) {
|
|
476
|
+
return configuration.factory.workTypes
|
|
477
|
+
?.find(({ name }) => name === work.workType)
|
|
478
|
+
?.states.find(({ name }) => name === work.state)?.type;
|
|
479
|
+
}
|
|
480
|
+
function failedStateFor(configuration, workType) {
|
|
481
|
+
return configuration.factory.workTypes
|
|
482
|
+
?.find(({ name }) => name === workType)
|
|
483
|
+
?.states.find(({ type }) => type === "FAILED")?.name;
|
|
484
|
+
}
|
|
485
|
+
function cascadeDependencyFailures(configuration, state, eventTime, sequence) {
|
|
486
|
+
const works = [...state.works];
|
|
487
|
+
const currentIndexes = new Map();
|
|
488
|
+
for (const [index, work] of works.entries())
|
|
489
|
+
currentIndexes.set(work.workId, index);
|
|
490
|
+
const currentWorks = [...currentIndexes.values()].flatMap((index) => {
|
|
491
|
+
const work = works[index];
|
|
492
|
+
return work === undefined ? [] : [work];
|
|
493
|
+
});
|
|
494
|
+
const dependents = new Map();
|
|
495
|
+
for (const work of currentWorks) {
|
|
496
|
+
for (const relation of work.relations ?? []) {
|
|
497
|
+
const existing = dependents.get(relation.targetWorkId) ?? [];
|
|
498
|
+
existing.push(work);
|
|
499
|
+
dependents.set(relation.targetWorkId, existing);
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
const queue = currentWorks
|
|
503
|
+
.filter((work) => stateTypeFor(configuration, work) === "FAILED")
|
|
504
|
+
.map(({ workId }) => workId);
|
|
505
|
+
const cascaded = new Set();
|
|
506
|
+
const events = [];
|
|
507
|
+
for (let cursor = 0; cursor < queue.length; cursor += 1) {
|
|
508
|
+
const triggerWorkId = queue[cursor];
|
|
509
|
+
if (triggerWorkId === undefined)
|
|
510
|
+
continue;
|
|
511
|
+
for (const dependent of dependents.get(triggerWorkId) ?? []) {
|
|
512
|
+
if (cascaded.has(dependent.workId))
|
|
513
|
+
continue;
|
|
514
|
+
const index = currentIndexes.get(dependent.workId);
|
|
515
|
+
const current = index === undefined ? undefined : works[index];
|
|
516
|
+
if (index === undefined || current === undefined)
|
|
517
|
+
continue;
|
|
518
|
+
const currentType = stateTypeFor(configuration, current);
|
|
519
|
+
if (currentType === "TERMINAL" || currentType === "FAILED")
|
|
520
|
+
continue;
|
|
521
|
+
const failedState = failedStateFor(configuration, current.workType);
|
|
522
|
+
if (failedState === undefined)
|
|
523
|
+
continue;
|
|
524
|
+
cascaded.add(current.workId);
|
|
525
|
+
queue.push(current.workId);
|
|
526
|
+
works[index] = {
|
|
527
|
+
...current,
|
|
528
|
+
state: failedState,
|
|
529
|
+
phase: "completed",
|
|
530
|
+
dispatch: undefined,
|
|
531
|
+
};
|
|
532
|
+
const eventSequence = sequence + events.length;
|
|
533
|
+
const reason = `cascading failure: dependency ${triggerWorkId} failed`;
|
|
534
|
+
events.push({
|
|
535
|
+
schemaVersion: "agent-factory.event.v1",
|
|
536
|
+
id: identity("event", state.sessionId, eventSequence, "WORK_STATE_CHANGE", current.workId),
|
|
537
|
+
type: "WORK_STATE_CHANGE",
|
|
538
|
+
context: eventContext(state, eventSequence, eventTime, {
|
|
539
|
+
requestId: current.requestId,
|
|
540
|
+
workIds: [current.workId],
|
|
541
|
+
}),
|
|
542
|
+
payload: {
|
|
543
|
+
workId: current.workId,
|
|
544
|
+
workTypeName: current.workType,
|
|
545
|
+
fromState: current.state,
|
|
546
|
+
toState: failedState,
|
|
547
|
+
fromPlaceId: `${current.workType}:${current.state}`,
|
|
548
|
+
toPlaceId: `${current.workType}:${failedState}`,
|
|
549
|
+
source: "cascading-failure",
|
|
550
|
+
triggerWorkId,
|
|
551
|
+
reason,
|
|
552
|
+
},
|
|
553
|
+
});
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
return { events, works };
|
|
557
|
+
}
|
|
558
|
+
function workRequestEvent(configuration, state, sequence, requestId, works, relations) {
|
|
559
|
+
return {
|
|
560
|
+
schemaVersion: "agent-factory.event.v1",
|
|
561
|
+
id: identity("event", state.sessionId, sequence, "WORK_REQUEST"),
|
|
562
|
+
type: "WORK_REQUEST",
|
|
563
|
+
context: eventContext(state, sequence, state.virtualTime, {
|
|
564
|
+
requestId,
|
|
565
|
+
traceIds: works.map(({ traceId }) => traceId),
|
|
566
|
+
workIds: works.map(({ workId }) => workId),
|
|
567
|
+
}),
|
|
568
|
+
payload: {
|
|
569
|
+
type: "FACTORY_REQUEST_BATCH",
|
|
570
|
+
source: "emulator",
|
|
571
|
+
works: works.map((work) => ({
|
|
572
|
+
name: work.submissionId,
|
|
573
|
+
workId: work.workId,
|
|
574
|
+
requestId: work.requestId,
|
|
575
|
+
workTypeName: work.workType,
|
|
576
|
+
state: {
|
|
577
|
+
name: work.state,
|
|
578
|
+
type: configuration.factory.workTypes
|
|
579
|
+
?.find(({ name }) => name === work.workType)
|
|
580
|
+
?.states.find(({ name }) => name === work.state)?.type ??
|
|
581
|
+
"INITIAL",
|
|
582
|
+
},
|
|
583
|
+
currentChainingTraceId: work.traceId,
|
|
584
|
+
traceId: work.traceId,
|
|
585
|
+
...(work.input === undefined ? {} : { payload: work.input }),
|
|
586
|
+
})),
|
|
587
|
+
...(relations.length === 0
|
|
588
|
+
? {}
|
|
589
|
+
: { relations: relations.map((relation) => ({ ...relation })) }),
|
|
590
|
+
},
|
|
591
|
+
};
|
|
592
|
+
}
|
|
593
|
+
function relationshipChangeEvents(state, sequence, requestId, workByName, relations) {
|
|
594
|
+
return relations.map((relation, ordinal) => {
|
|
595
|
+
const source = workByName.get(relation.sourceWorkName);
|
|
596
|
+
const target = workByName.get(relation.targetWorkName);
|
|
597
|
+
if (source === undefined || target === undefined) {
|
|
598
|
+
throw new Error("Validated submission relationship endpoint is missing.");
|
|
599
|
+
}
|
|
600
|
+
const relationshipSequence = sequence + ordinal + 1;
|
|
601
|
+
return {
|
|
602
|
+
schemaVersion: "agent-factory.event.v1",
|
|
603
|
+
id: identity("event", state.sessionId, relationshipSequence, "RELATIONSHIP_CHANGE_REQUEST", requestId, ordinal),
|
|
604
|
+
type: "RELATIONSHIP_CHANGE_REQUEST",
|
|
605
|
+
context: eventContext(state, relationshipSequence, state.virtualTime, {
|
|
606
|
+
requestId,
|
|
607
|
+
traceIds: [source.traceId, target.traceId],
|
|
608
|
+
workIds: [source.workId, target.workId],
|
|
609
|
+
}),
|
|
610
|
+
payload: { relation },
|
|
611
|
+
};
|
|
612
|
+
});
|
|
613
|
+
}
|
|
614
|
+
function workRequestCalculation(configuration, state, submissionBatch, command) {
|
|
615
|
+
const requestId = identity("request", state.sessionId, command, state.counters.commands);
|
|
616
|
+
const works = submissionBatch.works.map((submission, ordinal) => {
|
|
617
|
+
const workId = identity("work", state.sessionId, command, state.counters.commands, ordinal, submission);
|
|
618
|
+
const traceId = identity("trace", workId, requestId);
|
|
619
|
+
return {
|
|
620
|
+
submissionId: submission.name,
|
|
621
|
+
requestId,
|
|
622
|
+
traceId,
|
|
623
|
+
tokenId: identity("token", workId, submission.workType, submission.state),
|
|
624
|
+
workId,
|
|
625
|
+
rootWorkId: workId,
|
|
626
|
+
visits: {},
|
|
627
|
+
workType: submission.workType,
|
|
628
|
+
state: submission.state,
|
|
629
|
+
...(submission.input === undefined ? {} : { input: submission.input }),
|
|
630
|
+
...(submission.parent === undefined ? {} : { parent: submission.parent }),
|
|
631
|
+
queuedElapsedMs: state.virtualElapsedMs,
|
|
632
|
+
phase: "ready",
|
|
633
|
+
};
|
|
634
|
+
});
|
|
635
|
+
const workByName = new Map(works.map((work) => [work.submissionId, work]));
|
|
636
|
+
const resolvedWorkId = (name) => {
|
|
637
|
+
const resolved = workByName.get(name);
|
|
638
|
+
if (resolved === undefined) {
|
|
639
|
+
throw new Error(`Validated submission target ${name} is missing.`);
|
|
640
|
+
}
|
|
641
|
+
return resolved.workId;
|
|
642
|
+
};
|
|
643
|
+
const normalizedWorks = works.map((work) => {
|
|
644
|
+
const relations = submissionBatch.relations
|
|
645
|
+
.filter(({ sourceWorkName }) => sourceWorkName === work.submissionId)
|
|
646
|
+
.map((relation) => ({
|
|
647
|
+
type: "DEPENDS_ON",
|
|
648
|
+
sourceWorkName: relation.sourceWorkName,
|
|
649
|
+
targetWorkName: relation.targetWorkName,
|
|
650
|
+
targetWorkId: resolvedWorkId(relation.targetWorkName),
|
|
651
|
+
requiredState: relation.requiredState ?? "complete",
|
|
652
|
+
}));
|
|
653
|
+
return relations.length === 0 ? work : { ...work, relations };
|
|
654
|
+
});
|
|
655
|
+
const normalizedRelations = submissionBatch.relations.map((relation) => ({
|
|
656
|
+
type: "DEPENDS_ON",
|
|
657
|
+
sourceWorkName: relation.sourceWorkName,
|
|
658
|
+
targetWorkName: relation.targetWorkName,
|
|
659
|
+
targetWorkId: resolvedWorkId(relation.targetWorkName),
|
|
660
|
+
requiredState: relation.requiredState ?? "complete",
|
|
661
|
+
}));
|
|
662
|
+
const sequence = state.counters.events;
|
|
663
|
+
const request = workRequestEvent(configuration, state, sequence, requestId, normalizedWorks, normalizedRelations);
|
|
664
|
+
const relationshipEvents = relationshipChangeEvents(state, sequence, requestId, workByName, normalizedRelations);
|
|
665
|
+
return {
|
|
666
|
+
works: normalizedWorks,
|
|
667
|
+
events: [request, ...relationshipEvents],
|
|
668
|
+
};
|
|
669
|
+
}
|
|
670
|
+
function logicalMoveCalculation(configuration, state, workstation, outcome, inputs, resources, logicalMoveId, sequence) {
|
|
671
|
+
const primary = inputs[0];
|
|
672
|
+
if (primary === undefined)
|
|
673
|
+
throw new Error("Logical move has no input Work.");
|
|
674
|
+
const routedWorks = routedWorksFor(configuration, state, {
|
|
675
|
+
dispatchId: logicalMoveId,
|
|
676
|
+
completionId: identity("completion", logicalMoveId),
|
|
677
|
+
transitionId: workstation.name,
|
|
678
|
+
workstation: workstation.name,
|
|
679
|
+
worker: "",
|
|
680
|
+
startedElapsedMs: state.virtualElapsedMs,
|
|
681
|
+
dueElapsedMs: state.virtualElapsedMs,
|
|
682
|
+
inputTokenIds: inputs.map(({ tokenId }) => tokenId),
|
|
683
|
+
resources,
|
|
684
|
+
outcome,
|
|
685
|
+
}, inputs);
|
|
686
|
+
return {
|
|
687
|
+
routedWorks,
|
|
688
|
+
event: {
|
|
689
|
+
schemaVersion: "agent-factory.event.v1",
|
|
690
|
+
id: identity("event", state.sessionId, sequence, "DISPATCH_RESPONSE", logicalMoveId),
|
|
691
|
+
type: "DISPATCH_RESPONSE",
|
|
692
|
+
context: eventContext(state, sequence, state.virtualTime, {
|
|
693
|
+
requestId: primary.requestId,
|
|
694
|
+
traceIds: inputs.map(({ traceId }) => traceId),
|
|
695
|
+
workIds: inputs.map(({ workId }) => workId),
|
|
696
|
+
currentChainingTraceId: primary.traceId,
|
|
697
|
+
previousChainingTraceIds: inputs.slice(1).map(({ traceId }) => traceId),
|
|
698
|
+
}),
|
|
699
|
+
payload: {
|
|
700
|
+
transitionId: workstation.name,
|
|
701
|
+
outcome: "ACCEPTED",
|
|
702
|
+
durationMillis: 0,
|
|
703
|
+
...(routedWorks.length === 0
|
|
704
|
+
? {}
|
|
705
|
+
: {
|
|
706
|
+
outputWork: routedWorks.map((routed) => eventWorkFor(configuration, routed)),
|
|
707
|
+
}),
|
|
708
|
+
},
|
|
709
|
+
},
|
|
710
|
+
};
|
|
711
|
+
}
|
|
712
|
+
function replaceInputPhases(replacements, state, indexes, phase) {
|
|
713
|
+
for (const index of indexes) {
|
|
714
|
+
const consumed = state.works[index];
|
|
715
|
+
if (consumed !== undefined)
|
|
716
|
+
replacements[index] = { ...consumed, phase };
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
function worksAtIndexes(works, indexes) {
|
|
720
|
+
return indexes.flatMap((index) => {
|
|
721
|
+
const work = works[index];
|
|
722
|
+
return work === undefined ? [] : [work];
|
|
723
|
+
});
|
|
724
|
+
}
|
|
725
|
+
function workerDispatchRequestEvent(state, inputs, transitionId, dispatchId, resources, sequence) {
|
|
726
|
+
const primary = inputs[0];
|
|
727
|
+
if (primary === undefined)
|
|
728
|
+
throw new Error("Worker dispatch has no input Work.");
|
|
729
|
+
return {
|
|
730
|
+
schemaVersion: "agent-factory.event.v1",
|
|
731
|
+
id: identity("event", state.sessionId, sequence, "DISPATCH_REQUEST", dispatchId),
|
|
732
|
+
type: "DISPATCH_REQUEST",
|
|
733
|
+
context: eventContext(state, sequence, state.virtualTime, {
|
|
734
|
+
dispatchId,
|
|
735
|
+
requestId: primary.requestId,
|
|
736
|
+
traceIds: inputs.map(({ traceId }) => traceId),
|
|
737
|
+
workIds: inputs.map(({ workId }) => workId),
|
|
738
|
+
currentChainingTraceId: primary.traceId,
|
|
739
|
+
previousChainingTraceIds: inputs.slice(1).map(({ traceId }) => traceId),
|
|
740
|
+
}),
|
|
741
|
+
payload: {
|
|
742
|
+
transitionId,
|
|
743
|
+
inputs: inputs.map(({ workId }) => ({ workId })),
|
|
744
|
+
...(resources.length === 0 ? {} : { resources: [...resources] }),
|
|
745
|
+
},
|
|
746
|
+
};
|
|
747
|
+
}
|
|
748
|
+
function validateSubmissions(configuration, state, value) {
|
|
749
|
+
const batch = isSubmissionArray(value)
|
|
750
|
+
? { works: value }
|
|
751
|
+
: "works" in value
|
|
752
|
+
? value
|
|
753
|
+
: { works: [value] };
|
|
754
|
+
const scenarioSubmissions = isSubmissionArray(value) || !("works" in value) ? batch.works : batch;
|
|
755
|
+
const parsed = safeParseFactoryEmulatorScenario({ ...configuration.scenario, initialSubmissions: scenarioSubmissions }, configuration.factory);
|
|
756
|
+
const issues = parsed.success
|
|
757
|
+
? []
|
|
758
|
+
: parsed.issues.map((issue) => ({
|
|
759
|
+
...issue,
|
|
760
|
+
path: ["submissions", ...issue.path.slice(1)],
|
|
761
|
+
}));
|
|
762
|
+
const known = new Set(state.works.map(({ submissionId }) => submissionId));
|
|
763
|
+
batch.works.forEach((submission, index) => {
|
|
764
|
+
if (known.has(submission?.name)) {
|
|
765
|
+
issues.push({
|
|
766
|
+
category: "semantic",
|
|
767
|
+
code: "duplicate_identity",
|
|
768
|
+
path: ["submissions", index, "name"],
|
|
769
|
+
message: `Work identity ${submission.name} already exists in this session.`,
|
|
770
|
+
});
|
|
771
|
+
}
|
|
772
|
+
});
|
|
773
|
+
if (issues.length > 0)
|
|
774
|
+
throw new FactoryEmulatorSubmissionError(issues);
|
|
775
|
+
return clone({ works: batch.works, relations: batch.relations ?? [] });
|
|
776
|
+
}
|
|
777
|
+
function rejectionMessage(rejection) {
|
|
778
|
+
if (rejection instanceof Error)
|
|
779
|
+
return String(rejection.message);
|
|
780
|
+
try {
|
|
781
|
+
return String(rejection);
|
|
782
|
+
}
|
|
783
|
+
catch {
|
|
784
|
+
return "Sink rejected with an unprintable value.";
|
|
785
|
+
}
|
|
786
|
+
}
|
|
787
|
+
function budgetUsage(state, limits) {
|
|
788
|
+
return {
|
|
789
|
+
completedDispatches: {
|
|
790
|
+
used: state.counters.completedDispatches,
|
|
791
|
+
limit: limits.maxCompletedDispatches,
|
|
792
|
+
},
|
|
793
|
+
events: { used: state.counters.events, limit: limits.maxEvents },
|
|
794
|
+
virtualElapsedMs: {
|
|
795
|
+
used: state.virtualElapsedMs,
|
|
796
|
+
limit: limits.maxVirtualElapsedMs,
|
|
797
|
+
},
|
|
798
|
+
};
|
|
799
|
+
}
|
|
800
|
+
function unfinished(state) {
|
|
801
|
+
return state.works.some(({ phase }) => phase !== "completed");
|
|
802
|
+
}
|
|
803
|
+
/** Creates one validated, caller-owned deterministic Factory emulator session. */
|
|
804
|
+
// biome-ignore lint/complexity/noExcessiveLinesPerFunction: Command closures intentionally share one explicit session state owner.
|
|
805
|
+
export function createFactoryEmulatorSession(options) {
|
|
806
|
+
const configuration = validateConfiguration(options);
|
|
807
|
+
const sessionId = sessionIdentity(configuration.factory, configuration.scenario);
|
|
808
|
+
let committedState = clone(PRE_START_STATE);
|
|
809
|
+
let pendingTransaction;
|
|
810
|
+
let pendingAdvanceContext;
|
|
811
|
+
let commandInFlight;
|
|
812
|
+
let lastError;
|
|
813
|
+
const pauseExecution = (command, diagnostic) => {
|
|
814
|
+
lastError = {
|
|
815
|
+
code: "execution_paused",
|
|
816
|
+
operation: "execute",
|
|
817
|
+
command,
|
|
818
|
+
message: `Factory emulator execution paused: ${diagnostic.kind}.`,
|
|
819
|
+
diagnostic: clone(diagnostic),
|
|
820
|
+
};
|
|
821
|
+
throw new FactoryEmulatorExecutionPausedError(diagnostic);
|
|
822
|
+
};
|
|
823
|
+
const assertCandidateWithinBudgets = (command, candidate) => {
|
|
824
|
+
const checks = [
|
|
825
|
+
{
|
|
826
|
+
limit: "completedDispatches",
|
|
827
|
+
configured: configuration.limits.maxCompletedDispatches,
|
|
828
|
+
observed: candidate.counters.completedDispatches,
|
|
829
|
+
},
|
|
830
|
+
{
|
|
831
|
+
limit: "events",
|
|
832
|
+
configured: configuration.limits.maxEvents,
|
|
833
|
+
observed: candidate.counters.events,
|
|
834
|
+
},
|
|
835
|
+
{
|
|
836
|
+
limit: "virtualElapsedMs",
|
|
837
|
+
configured: configuration.limits.maxVirtualElapsedMs,
|
|
838
|
+
observed: candidate.virtualElapsedMs,
|
|
839
|
+
},
|
|
840
|
+
];
|
|
841
|
+
const exceeded = checks.find(({ configured, observed }) => observed > configured);
|
|
842
|
+
if (exceeded !== undefined) {
|
|
843
|
+
pauseExecution(command, {
|
|
844
|
+
kind: "budget-exceeded",
|
|
845
|
+
...exceeded,
|
|
846
|
+
virtualTime: candidate.virtualTime,
|
|
847
|
+
virtualElapsedMs: candidate.virtualElapsedMs,
|
|
848
|
+
});
|
|
849
|
+
}
|
|
850
|
+
};
|
|
851
|
+
const assertWorkBound = (command, observed) => {
|
|
852
|
+
if (observed <= configuration.limits.maxSynchronousWorkItems)
|
|
853
|
+
return;
|
|
854
|
+
const state = committedState;
|
|
855
|
+
pauseExecution(command, {
|
|
856
|
+
kind: "bounded-work-exceeded",
|
|
857
|
+
limit: "synchronousWorkItems",
|
|
858
|
+
configured: configuration.limits.maxSynchronousWorkItems,
|
|
859
|
+
observed,
|
|
860
|
+
virtualTime: state.lifecycle === "pre-start"
|
|
861
|
+
? configuration.scenario.startAt
|
|
862
|
+
: state.virtualTime,
|
|
863
|
+
virtualElapsedMs: state.virtualElapsedMs,
|
|
864
|
+
});
|
|
865
|
+
};
|
|
866
|
+
const status = () => {
|
|
867
|
+
const common = {
|
|
868
|
+
virtualTime: committedState.lifecycle !== "pre-start"
|
|
869
|
+
? committedState.virtualTime
|
|
870
|
+
: configuration.scenario.startAt,
|
|
871
|
+
virtualElapsedMs: committedState.virtualElapsedMs,
|
|
872
|
+
budgetUsage: budgetUsage(committedState, configuration.limits),
|
|
873
|
+
};
|
|
874
|
+
if (lastError !== undefined) {
|
|
875
|
+
return clone({
|
|
876
|
+
...common,
|
|
877
|
+
phase: "error",
|
|
878
|
+
reason: lastError.operation === "execute"
|
|
879
|
+
? "Execution paused at a configured safety boundary."
|
|
880
|
+
: lastError.operation === "close"
|
|
881
|
+
? "The event sink rejected the pending close."
|
|
882
|
+
: "The event sink rejected the pending transaction.",
|
|
883
|
+
...(pendingTransaction === undefined
|
|
884
|
+
? {}
|
|
885
|
+
: {
|
|
886
|
+
pendingTransaction: {
|
|
887
|
+
command: pendingTransaction.command,
|
|
888
|
+
phase: pendingTransaction.phase,
|
|
889
|
+
eventCount: pendingTransaction.batch.length,
|
|
890
|
+
},
|
|
891
|
+
}),
|
|
892
|
+
error: lastError,
|
|
893
|
+
});
|
|
894
|
+
}
|
|
895
|
+
if (commandInFlight !== undefined) {
|
|
896
|
+
return clone({
|
|
897
|
+
...common,
|
|
898
|
+
phase: "active",
|
|
899
|
+
reason: `${commandInFlight} is awaiting the event sink.`,
|
|
900
|
+
...(pendingTransaction === undefined
|
|
901
|
+
? {}
|
|
902
|
+
: {
|
|
903
|
+
pendingTransaction: {
|
|
904
|
+
command: pendingTransaction.command,
|
|
905
|
+
phase: pendingTransaction.phase,
|
|
906
|
+
eventCount: pendingTransaction.batch.length,
|
|
907
|
+
},
|
|
908
|
+
}),
|
|
909
|
+
});
|
|
910
|
+
}
|
|
911
|
+
if (committedState.lifecycle === "pre-start") {
|
|
912
|
+
return clone({
|
|
913
|
+
...common,
|
|
914
|
+
phase: "idle",
|
|
915
|
+
reason: "The session is ready to start.",
|
|
916
|
+
});
|
|
917
|
+
}
|
|
918
|
+
if (committedState.lifecycle === "closed") {
|
|
919
|
+
return clone({
|
|
920
|
+
...common,
|
|
921
|
+
phase: "closed",
|
|
922
|
+
reason: "The session is closed.",
|
|
923
|
+
});
|
|
924
|
+
}
|
|
925
|
+
if (committedState.works.some(({ phase }) => phase === "active")) {
|
|
926
|
+
return clone({ ...common, phase: "active", reason: "Work is active." });
|
|
927
|
+
}
|
|
928
|
+
if (committedState.works.some(({ phase }) => phase === "ready")) {
|
|
929
|
+
return clone({
|
|
930
|
+
...common,
|
|
931
|
+
phase: "ready",
|
|
932
|
+
reason: committedState.counters.commands === 1
|
|
933
|
+
? "Initial Work is ready for a later execution command."
|
|
934
|
+
: "Work is ready.",
|
|
935
|
+
});
|
|
936
|
+
}
|
|
937
|
+
if (committedState.works.some(({ phase }) => phase === "waiting")) {
|
|
938
|
+
return clone({
|
|
939
|
+
...common,
|
|
940
|
+
phase: "waiting",
|
|
941
|
+
reason: "Work is waiting for a supported outcome.",
|
|
942
|
+
});
|
|
943
|
+
}
|
|
944
|
+
return clone({
|
|
945
|
+
...common,
|
|
946
|
+
phase: "idle",
|
|
947
|
+
reason: "The open session has no unfinished Work.",
|
|
948
|
+
});
|
|
949
|
+
};
|
|
950
|
+
const assertCommand = (command, retryKey) => {
|
|
951
|
+
if (commandInFlight !== undefined) {
|
|
952
|
+
throw new FactoryEmulatorPendingCommandError(command, commandInFlight);
|
|
953
|
+
}
|
|
954
|
+
if (pendingTransaction !== undefined) {
|
|
955
|
+
if (command === pendingTransaction.command &&
|
|
956
|
+
retryKey === pendingTransaction.retryKey) {
|
|
957
|
+
return pendingTransaction;
|
|
958
|
+
}
|
|
959
|
+
throw new FactoryEmulatorPendingCommandError(command, pendingTransaction.command);
|
|
960
|
+
}
|
|
961
|
+
if ((command === "start" && committedState.lifecycle !== "pre-start") ||
|
|
962
|
+
(command !== "start" && committedState.lifecycle !== "started")) {
|
|
963
|
+
throw new FactoryEmulatorLifecycleError(command, committedState.lifecycle);
|
|
964
|
+
}
|
|
965
|
+
return undefined;
|
|
966
|
+
};
|
|
967
|
+
const acceptPendingTransaction = async () => {
|
|
968
|
+
const transaction = pendingTransaction;
|
|
969
|
+
if (transaction === undefined)
|
|
970
|
+
return;
|
|
971
|
+
try {
|
|
972
|
+
if (transaction.phase === "sink-write") {
|
|
973
|
+
await configuration.sink.write(clone(transaction.batch));
|
|
974
|
+
if (transaction.command === "close") {
|
|
975
|
+
transaction.phase = "sink-close";
|
|
976
|
+
}
|
|
977
|
+
}
|
|
978
|
+
if (transaction.phase === "sink-close") {
|
|
979
|
+
await configuration.sink.close?.();
|
|
980
|
+
}
|
|
981
|
+
committedState = transaction.candidate;
|
|
982
|
+
pendingTransaction = undefined;
|
|
983
|
+
lastError = undefined;
|
|
984
|
+
}
|
|
985
|
+
catch (rejection) {
|
|
986
|
+
lastError =
|
|
987
|
+
transaction.phase === "sink-close"
|
|
988
|
+
? {
|
|
989
|
+
code: "sink_close_rejected",
|
|
990
|
+
operation: "close",
|
|
991
|
+
command: transaction.command,
|
|
992
|
+
message: rejectionMessage(rejection),
|
|
993
|
+
}
|
|
994
|
+
: {
|
|
995
|
+
code: "sink_write_rejected",
|
|
996
|
+
operation: "write",
|
|
997
|
+
command: transaction.command,
|
|
998
|
+
message: rejectionMessage(rejection),
|
|
999
|
+
};
|
|
1000
|
+
throw rejection;
|
|
1001
|
+
}
|
|
1002
|
+
};
|
|
1003
|
+
const write = async (command, retryKey, batch, candidate) => {
|
|
1004
|
+
assertCandidateWithinBudgets(command, candidate);
|
|
1005
|
+
pendingTransaction = {
|
|
1006
|
+
command,
|
|
1007
|
+
retryKey,
|
|
1008
|
+
batch: clone(batch),
|
|
1009
|
+
candidate: clone(candidate),
|
|
1010
|
+
phase: "sink-write",
|
|
1011
|
+
};
|
|
1012
|
+
await acceptPendingTransaction();
|
|
1013
|
+
};
|
|
1014
|
+
const start = async () => {
|
|
1015
|
+
const retryKey = "start";
|
|
1016
|
+
const retry = assertCommand("start", retryKey);
|
|
1017
|
+
commandInFlight = "start";
|
|
1018
|
+
try {
|
|
1019
|
+
if (retry !== undefined) {
|
|
1020
|
+
await acceptPendingTransaction();
|
|
1021
|
+
const candidate = retry.candidate;
|
|
1022
|
+
const batches = [
|
|
1023
|
+
retry.batch.slice(0, 3),
|
|
1024
|
+
...(retry.batch.length > 3 ? [retry.batch.slice(3)] : []),
|
|
1025
|
+
];
|
|
1026
|
+
return clone({ status: "started", batches, state: candidate });
|
|
1027
|
+
}
|
|
1028
|
+
lastError = undefined;
|
|
1029
|
+
const bootstrap = bootstrapEvents(configuration.factory, configuration.scenario, sessionId);
|
|
1030
|
+
let candidate = {
|
|
1031
|
+
lifecycle: "started",
|
|
1032
|
+
sessionId,
|
|
1033
|
+
virtualTime: configuration.scenario.startAt,
|
|
1034
|
+
virtualElapsedMs: 0,
|
|
1035
|
+
works: [],
|
|
1036
|
+
ruleCursors: {},
|
|
1037
|
+
counters: {
|
|
1038
|
+
commands: 1,
|
|
1039
|
+
events: bootstrap.length,
|
|
1040
|
+
completedDispatches: 0,
|
|
1041
|
+
},
|
|
1042
|
+
};
|
|
1043
|
+
const batches = [bootstrap];
|
|
1044
|
+
const initialValue = configuration.scenario.initialSubmissions ?? [];
|
|
1045
|
+
const initial = isSubmissionArray(initialValue)
|
|
1046
|
+
? { works: initialValue, relations: [] }
|
|
1047
|
+
: {
|
|
1048
|
+
works: initialValue.works,
|
|
1049
|
+
relations: initialValue.relations ?? [],
|
|
1050
|
+
};
|
|
1051
|
+
assertWorkBound("start", initial.works.length);
|
|
1052
|
+
let combined = bootstrap;
|
|
1053
|
+
if (initial.works.length > 0) {
|
|
1054
|
+
const calculation = workRequestCalculation(configuration, { ...candidate, counters: { ...candidate.counters, commands: 0 } }, initial, "start");
|
|
1055
|
+
const cascade = cascadeDependencyFailures(configuration, { ...candidate, works: calculation.works }, candidate.virtualTime, candidate.counters.events + calculation.events.length);
|
|
1056
|
+
const submissionBatch = [...calculation.events, ...cascade.events];
|
|
1057
|
+
batches.push(submissionBatch);
|
|
1058
|
+
combined = [...bootstrap, ...submissionBatch];
|
|
1059
|
+
candidate = {
|
|
1060
|
+
...candidate,
|
|
1061
|
+
works: cascade.works,
|
|
1062
|
+
counters: { ...candidate.counters, events: combined.length },
|
|
1063
|
+
};
|
|
1064
|
+
}
|
|
1065
|
+
await write("start", retryKey, combined, candidate);
|
|
1066
|
+
return clone({ status: "started", batches, state: candidate });
|
|
1067
|
+
}
|
|
1068
|
+
finally {
|
|
1069
|
+
commandInFlight = undefined;
|
|
1070
|
+
}
|
|
1071
|
+
};
|
|
1072
|
+
const submit = async (value) => {
|
|
1073
|
+
const retryKey = canonicalJson(Array.isArray(value) ? value : [value]);
|
|
1074
|
+
const retry = assertCommand("submit", retryKey);
|
|
1075
|
+
if (retry !== undefined) {
|
|
1076
|
+
commandInFlight = "submit";
|
|
1077
|
+
try {
|
|
1078
|
+
await acceptPendingTransaction();
|
|
1079
|
+
return clone({
|
|
1080
|
+
status: "submitted",
|
|
1081
|
+
batch: retry.batch,
|
|
1082
|
+
state: retry.candidate,
|
|
1083
|
+
});
|
|
1084
|
+
}
|
|
1085
|
+
finally {
|
|
1086
|
+
commandInFlight = undefined;
|
|
1087
|
+
}
|
|
1088
|
+
}
|
|
1089
|
+
const state = committedState;
|
|
1090
|
+
assertWorkBound("submit", isSubmissionArray(value)
|
|
1091
|
+
? value.length
|
|
1092
|
+
: "works" in value
|
|
1093
|
+
? value.works.length
|
|
1094
|
+
: 1);
|
|
1095
|
+
const submissions = validateSubmissions(configuration, state, value);
|
|
1096
|
+
commandInFlight = "submit";
|
|
1097
|
+
lastError = undefined;
|
|
1098
|
+
try {
|
|
1099
|
+
const calculation = workRequestCalculation(configuration, state, submissions, "submit");
|
|
1100
|
+
const cascade = cascadeDependencyFailures(configuration, { ...state, works: [...state.works, ...calculation.works] }, state.virtualTime, state.counters.events + calculation.events.length);
|
|
1101
|
+
const batch = [...calculation.events, ...cascade.events];
|
|
1102
|
+
const candidate = {
|
|
1103
|
+
...state,
|
|
1104
|
+
works: cascade.works,
|
|
1105
|
+
counters: {
|
|
1106
|
+
...state.counters,
|
|
1107
|
+
commands: state.counters.commands + 1,
|
|
1108
|
+
events: state.counters.events + batch.length,
|
|
1109
|
+
},
|
|
1110
|
+
};
|
|
1111
|
+
await write("submit", retryKey, batch, candidate);
|
|
1112
|
+
return clone({ status: "submitted", batch, state: candidate });
|
|
1113
|
+
}
|
|
1114
|
+
finally {
|
|
1115
|
+
commandInFlight = undefined;
|
|
1116
|
+
}
|
|
1117
|
+
};
|
|
1118
|
+
const cascadeLogicalMoveFailures = (state, replacements, events) => {
|
|
1119
|
+
const cascade = cascadeDependencyFailures(configuration, { ...state, works: replacements }, virtualTimeAt(configuration.scenario, state.virtualElapsedMs), state.counters.events + events.length);
|
|
1120
|
+
replacements.splice(0, replacements.length, ...cascade.works);
|
|
1121
|
+
events.push(...cascade.events);
|
|
1122
|
+
};
|
|
1123
|
+
const dispatchCalculation = (command, state) => {
|
|
1124
|
+
const replacements = [...state.works];
|
|
1125
|
+
const cursors = { ...state.ruleCursors };
|
|
1126
|
+
const events = [];
|
|
1127
|
+
const { candidates, executableWork } = schedulerCandidatesFor(configuration, state, cursors, (observed) => assertWorkBound(command, observed));
|
|
1128
|
+
for (const { value, resources } of selectFactorySchedulerCandidates(candidates, undefined, availableResourcesFor(configuration, state))) {
|
|
1129
|
+
const { indexes, execution, invocation, cursorKey } = value;
|
|
1130
|
+
const works = worksAtIndexes(replacements, indexes);
|
|
1131
|
+
const work = works[0];
|
|
1132
|
+
if (work === undefined ||
|
|
1133
|
+
works.length !== indexes.length ||
|
|
1134
|
+
works.some(({ phase }) => phase !== "ready") ||
|
|
1135
|
+
execution.workstation === undefined ||
|
|
1136
|
+
execution.outcome === undefined)
|
|
1137
|
+
continue;
|
|
1138
|
+
if (cursorKey !== undefined)
|
|
1139
|
+
cursors[cursorKey] = invocation + 1;
|
|
1140
|
+
const logicalMove = execution.workstation.type === "LOGICAL_MOVE";
|
|
1141
|
+
const transitionId = execution.workstation.name;
|
|
1142
|
+
const dispatchId = identity(logicalMove ? "logical-move" : "dispatch", works.map(({ tokenId }) => tokenId), transitionId, invocation);
|
|
1143
|
+
const completionId = identity("completion", dispatchId);
|
|
1144
|
+
const dueElapsedMs = state.virtualElapsedMs + execution.outcome.durationMs;
|
|
1145
|
+
const eventResources = eventResourcesFor(configuration, resources);
|
|
1146
|
+
virtualTimeAt(configuration.scenario, dueElapsedMs);
|
|
1147
|
+
if (logicalMove) {
|
|
1148
|
+
const calculation = logicalMoveCalculation(configuration, state, execution.workstation, execution.outcome, works, resources, dispatchId, state.counters.events + events.length);
|
|
1149
|
+
replaceInputPhases(replacements, state, indexes, "completed");
|
|
1150
|
+
replacements.push(...calculation.routedWorks);
|
|
1151
|
+
events.push(calculation.event);
|
|
1152
|
+
cascadeLogicalMoveFailures(state, replacements, events);
|
|
1153
|
+
continue;
|
|
1154
|
+
}
|
|
1155
|
+
replaceInputPhases(replacements, state, indexes, "active");
|
|
1156
|
+
const primaryIndex = indexes[0];
|
|
1157
|
+
if (primaryIndex === undefined)
|
|
1158
|
+
continue;
|
|
1159
|
+
replacements[primaryIndex] = {
|
|
1160
|
+
...work,
|
|
1161
|
+
phase: "active",
|
|
1162
|
+
dispatch: {
|
|
1163
|
+
dispatchId,
|
|
1164
|
+
completionId,
|
|
1165
|
+
transitionId,
|
|
1166
|
+
workstation: execution.workstation.name,
|
|
1167
|
+
worker: execution.workstation.worker,
|
|
1168
|
+
startedElapsedMs: state.virtualElapsedMs,
|
|
1169
|
+
dueElapsedMs,
|
|
1170
|
+
inputTokenIds: works.map(({ tokenId }) => tokenId),
|
|
1171
|
+
resources,
|
|
1172
|
+
outcome: execution.outcome,
|
|
1173
|
+
},
|
|
1174
|
+
};
|
|
1175
|
+
const sequence = state.counters.events + events.length;
|
|
1176
|
+
events.push(workerDispatchRequestEvent(state, works, transitionId, dispatchId, eventResources, sequence));
|
|
1177
|
+
}
|
|
1178
|
+
for (const [index, work] of state.works.entries()) {
|
|
1179
|
+
if (work.phase === "ready" &&
|
|
1180
|
+
replacements[index]?.phase === "ready" &&
|
|
1181
|
+
!executableWork.has(index)) {
|
|
1182
|
+
replacements[index] = { ...work, phase: "waiting" };
|
|
1183
|
+
}
|
|
1184
|
+
}
|
|
1185
|
+
return {
|
|
1186
|
+
batch: events,
|
|
1187
|
+
state: {
|
|
1188
|
+
...state,
|
|
1189
|
+
works: replacements,
|
|
1190
|
+
ruleCursors: cursors,
|
|
1191
|
+
counters: {
|
|
1192
|
+
...state.counters,
|
|
1193
|
+
events: state.counters.events + events.length,
|
|
1194
|
+
},
|
|
1195
|
+
},
|
|
1196
|
+
};
|
|
1197
|
+
};
|
|
1198
|
+
const completionCalculation = (state, dueElapsedMs) => {
|
|
1199
|
+
const replacements = [...state.works];
|
|
1200
|
+
const events = [];
|
|
1201
|
+
const eventTime = virtualTimeAt(configuration.scenario, dueElapsedMs);
|
|
1202
|
+
for (const work of state.works) {
|
|
1203
|
+
if (work.phase !== "active" ||
|
|
1204
|
+
work.dispatch?.dueElapsedMs !== dueElapsedMs)
|
|
1205
|
+
continue;
|
|
1206
|
+
const { dispatch } = work;
|
|
1207
|
+
const inputs = dispatch.inputTokenIds.flatMap((tokenId) => {
|
|
1208
|
+
const input = state.works.find((candidate) => candidate.tokenId === tokenId);
|
|
1209
|
+
return input === undefined ? [] : [input];
|
|
1210
|
+
});
|
|
1211
|
+
if (inputs.length !== dispatch.inputTokenIds.length)
|
|
1212
|
+
continue;
|
|
1213
|
+
const routedWorks = routedWorksFor(configuration, state, dispatch, inputs);
|
|
1214
|
+
const sequence = state.counters.events + events.length;
|
|
1215
|
+
const outcome = {
|
|
1216
|
+
accepted: "ACCEPTED",
|
|
1217
|
+
continued: "CONTINUE",
|
|
1218
|
+
rejected: "REJECTED",
|
|
1219
|
+
failed: "FAILED",
|
|
1220
|
+
};
|
|
1221
|
+
events.push({
|
|
1222
|
+
schemaVersion: "agent-factory.event.v1",
|
|
1223
|
+
id: identity("event", state.sessionId, sequence, "DISPATCH_RESPONSE", dispatch.dispatchId),
|
|
1224
|
+
type: "DISPATCH_RESPONSE",
|
|
1225
|
+
context: eventContext(state, sequence, eventTime, {
|
|
1226
|
+
dispatchId: dispatch.dispatchId,
|
|
1227
|
+
requestId: work.requestId,
|
|
1228
|
+
traceIds: inputs.map(({ traceId }) => traceId),
|
|
1229
|
+
workIds: inputs.map(({ workId }) => workId),
|
|
1230
|
+
currentChainingTraceId: work.traceId,
|
|
1231
|
+
previousChainingTraceIds: inputs
|
|
1232
|
+
.slice(1)
|
|
1233
|
+
.map(({ traceId }) => traceId),
|
|
1234
|
+
}),
|
|
1235
|
+
payload: {
|
|
1236
|
+
completionId: dispatch.completionId,
|
|
1237
|
+
transitionId: dispatch.transitionId,
|
|
1238
|
+
outcome: outcome[dispatch.outcome.result],
|
|
1239
|
+
durationMillis: dispatch.dueElapsedMs - dispatch.startedElapsedMs,
|
|
1240
|
+
...(dispatch.outcome.output === undefined
|
|
1241
|
+
? {}
|
|
1242
|
+
: { output: dispatch.outcome.output }),
|
|
1243
|
+
...(dispatch.outcome.feedback === undefined
|
|
1244
|
+
? {}
|
|
1245
|
+
: { feedback: dispatch.outcome.feedback }),
|
|
1246
|
+
...(dispatch.outcome.error === undefined
|
|
1247
|
+
? {}
|
|
1248
|
+
: { error: dispatch.outcome.error }),
|
|
1249
|
+
...(routedWorks.length === 0
|
|
1250
|
+
? {}
|
|
1251
|
+
: {
|
|
1252
|
+
outputWork: routedWorks.map((routed) => eventWorkFor(configuration, routed)),
|
|
1253
|
+
}),
|
|
1254
|
+
},
|
|
1255
|
+
});
|
|
1256
|
+
for (const input of inputs) {
|
|
1257
|
+
const inputIndex = state.works.findIndex(({ tokenId }) => tokenId === input.tokenId);
|
|
1258
|
+
if (inputIndex >= 0)
|
|
1259
|
+
replacements[inputIndex] = { ...input, phase: "completed" };
|
|
1260
|
+
}
|
|
1261
|
+
replacements.push(...routedWorks);
|
|
1262
|
+
}
|
|
1263
|
+
const completedDispatches = events.length;
|
|
1264
|
+
const cascade = cascadeDependencyFailures(configuration, { ...state, works: replacements }, eventTime, state.counters.events + events.length);
|
|
1265
|
+
events.push(...cascade.events);
|
|
1266
|
+
return {
|
|
1267
|
+
batch: events,
|
|
1268
|
+
state: {
|
|
1269
|
+
...state,
|
|
1270
|
+
virtualElapsedMs: dueElapsedMs,
|
|
1271
|
+
virtualTime: eventTime,
|
|
1272
|
+
works: cascade.works,
|
|
1273
|
+
counters: {
|
|
1274
|
+
...state.counters,
|
|
1275
|
+
events: state.counters.events + events.length,
|
|
1276
|
+
completedDispatches: state.counters.completedDispatches + completedDispatches,
|
|
1277
|
+
},
|
|
1278
|
+
},
|
|
1279
|
+
};
|
|
1280
|
+
};
|
|
1281
|
+
// biome-ignore lint/complexity/noExcessiveLinesPerFunction: Advancement keeps retry, budget, yield, and transaction state in one traceable command boundary.
|
|
1282
|
+
const runAdvance = async (command, requestedDuration) => {
|
|
1283
|
+
if (requestedDuration !== undefined &&
|
|
1284
|
+
(!Number.isSafeInteger(requestedDuration) || requestedDuration < 0)) {
|
|
1285
|
+
throw new FactoryEmulatorDurationError(requestedDuration);
|
|
1286
|
+
}
|
|
1287
|
+
const retryKey = canonicalJson([command, requestedDuration]);
|
|
1288
|
+
const retry = assertCommand(command, retryKey);
|
|
1289
|
+
const original = committedState;
|
|
1290
|
+
const target = retry === undefined
|
|
1291
|
+
? requestedDuration === undefined
|
|
1292
|
+
? undefined
|
|
1293
|
+
: original.virtualElapsedMs + requestedDuration
|
|
1294
|
+
: pendingAdvanceContext?.target;
|
|
1295
|
+
if (retry === undefined && target !== undefined) {
|
|
1296
|
+
virtualTimeAt(configuration.scenario, target);
|
|
1297
|
+
}
|
|
1298
|
+
const context = retry === undefined
|
|
1299
|
+
? {
|
|
1300
|
+
command,
|
|
1301
|
+
retryKey,
|
|
1302
|
+
fromVirtualTime: original.virtualTime,
|
|
1303
|
+
...(target === undefined ? {} : { target }),
|
|
1304
|
+
wasUnfinished: unfinished(original),
|
|
1305
|
+
batches: [],
|
|
1306
|
+
zeroDurationBatches: 0,
|
|
1307
|
+
}
|
|
1308
|
+
: pendingAdvanceContext;
|
|
1309
|
+
if (context === undefined) {
|
|
1310
|
+
throw new Error("Pending advancement context is unavailable.");
|
|
1311
|
+
}
|
|
1312
|
+
pendingAdvanceContext = context;
|
|
1313
|
+
commandInFlight = command;
|
|
1314
|
+
let synchronousBatches = 0;
|
|
1315
|
+
const acceptSchedulerBatch = async (calculation) => {
|
|
1316
|
+
const beforeElapsedMs = committedState.virtualElapsedMs;
|
|
1317
|
+
const nextZeroDurationBatches = calculation.state.virtualElapsedMs === beforeElapsedMs
|
|
1318
|
+
? context.zeroDurationBatches + 1
|
|
1319
|
+
: 0;
|
|
1320
|
+
if (nextZeroDurationBatches > configuration.limits.maxZeroDurationBatches) {
|
|
1321
|
+
pauseExecution(command, {
|
|
1322
|
+
kind: "zero-duration-cycle",
|
|
1323
|
+
limit: "zeroDurationBatches",
|
|
1324
|
+
configured: configuration.limits.maxZeroDurationBatches,
|
|
1325
|
+
observed: nextZeroDurationBatches,
|
|
1326
|
+
virtualTime: committedState.virtualTime,
|
|
1327
|
+
virtualElapsedMs: beforeElapsedMs,
|
|
1328
|
+
});
|
|
1329
|
+
}
|
|
1330
|
+
assertWorkBound(command, calculation.batch.length);
|
|
1331
|
+
context.batches.push(calculation.batch);
|
|
1332
|
+
await write(command, retryKey, calculation.batch, calculation.state);
|
|
1333
|
+
context.zeroDurationBatches = nextZeroDurationBatches;
|
|
1334
|
+
synchronousBatches += 1;
|
|
1335
|
+
if (configuration.yieldControl !== undefined &&
|
|
1336
|
+
synchronousBatches >= configuration.limits.maxSynchronousBatches) {
|
|
1337
|
+
synchronousBatches = 0;
|
|
1338
|
+
await configuration.yieldControl();
|
|
1339
|
+
}
|
|
1340
|
+
};
|
|
1341
|
+
try {
|
|
1342
|
+
if (retry !== undefined) {
|
|
1343
|
+
const beforeElapsedMs = original.virtualElapsedMs;
|
|
1344
|
+
await acceptPendingTransaction();
|
|
1345
|
+
context.zeroDurationBatches =
|
|
1346
|
+
committedState.virtualElapsedMs === beforeElapsedMs
|
|
1347
|
+
? context.zeroDurationBatches + 1
|
|
1348
|
+
: 0;
|
|
1349
|
+
synchronousBatches = 1;
|
|
1350
|
+
if (configuration.yieldControl !== undefined &&
|
|
1351
|
+
synchronousBatches >= configuration.limits.maxSynchronousBatches) {
|
|
1352
|
+
synchronousBatches = 0;
|
|
1353
|
+
await configuration.yieldControl();
|
|
1354
|
+
}
|
|
1355
|
+
}
|
|
1356
|
+
else
|
|
1357
|
+
lastError = undefined;
|
|
1358
|
+
const continueAdvancing = !(retry !== undefined && command === "advanceToNext");
|
|
1359
|
+
while (continueAdvancing) {
|
|
1360
|
+
const state = committedState;
|
|
1361
|
+
if (state.works.some(({ phase }) => phase === "ready")) {
|
|
1362
|
+
const calculation = dispatchCalculation(command, state);
|
|
1363
|
+
if (calculation.batch.length > 0) {
|
|
1364
|
+
await acceptSchedulerBatch(calculation);
|
|
1365
|
+
if (command === "advanceToNext")
|
|
1366
|
+
break;
|
|
1367
|
+
continue;
|
|
1368
|
+
}
|
|
1369
|
+
committedState = calculation.state;
|
|
1370
|
+
}
|
|
1371
|
+
const activeDeadlines = committedState.works.flatMap((work) => work.phase === "active" && work.dispatch !== undefined
|
|
1372
|
+
? [work.dispatch.dueElapsedMs]
|
|
1373
|
+
: []);
|
|
1374
|
+
if (activeDeadlines.length === 0)
|
|
1375
|
+
break;
|
|
1376
|
+
const earliest = Math.min(...activeDeadlines);
|
|
1377
|
+
if (target !== undefined && earliest > target)
|
|
1378
|
+
break;
|
|
1379
|
+
const calculation = completionCalculation(committedState, earliest);
|
|
1380
|
+
await acceptSchedulerBatch(calculation);
|
|
1381
|
+
if (command === "advanceToNext")
|
|
1382
|
+
break;
|
|
1383
|
+
}
|
|
1384
|
+
let finalState = committedState;
|
|
1385
|
+
if (target !== undefined &&
|
|
1386
|
+
context.wasUnfinished &&
|
|
1387
|
+
finalState.virtualElapsedMs < target) {
|
|
1388
|
+
finalState = {
|
|
1389
|
+
...finalState,
|
|
1390
|
+
virtualElapsedMs: target,
|
|
1391
|
+
virtualTime: virtualTimeAt(configuration.scenario, target),
|
|
1392
|
+
};
|
|
1393
|
+
}
|
|
1394
|
+
const madeProgress = context.batches.length > 0 ||
|
|
1395
|
+
finalState.virtualTime !== context.fromVirtualTime;
|
|
1396
|
+
if (madeProgress) {
|
|
1397
|
+
finalState = {
|
|
1398
|
+
...finalState,
|
|
1399
|
+
counters: {
|
|
1400
|
+
...finalState.counters,
|
|
1401
|
+
commands: finalState.counters.commands + 1,
|
|
1402
|
+
},
|
|
1403
|
+
};
|
|
1404
|
+
assertCandidateWithinBudgets(command, finalState);
|
|
1405
|
+
committedState = finalState;
|
|
1406
|
+
}
|
|
1407
|
+
const receipt = clone({
|
|
1408
|
+
status: madeProgress ? "advanced" : "idle",
|
|
1409
|
+
command,
|
|
1410
|
+
fromVirtualTime: context.fromVirtualTime,
|
|
1411
|
+
virtualTime: finalState.virtualTime,
|
|
1412
|
+
virtualElapsedMs: finalState.virtualElapsedMs,
|
|
1413
|
+
batches: context.batches,
|
|
1414
|
+
state: finalState,
|
|
1415
|
+
});
|
|
1416
|
+
pendingAdvanceContext = undefined;
|
|
1417
|
+
return receipt;
|
|
1418
|
+
}
|
|
1419
|
+
finally {
|
|
1420
|
+
commandInFlight = undefined;
|
|
1421
|
+
}
|
|
1422
|
+
};
|
|
1423
|
+
const close = async () => {
|
|
1424
|
+
const retryKey = "close";
|
|
1425
|
+
const retry = assertCommand("close", retryKey);
|
|
1426
|
+
commandInFlight = "close";
|
|
1427
|
+
try {
|
|
1428
|
+
if (retry !== undefined) {
|
|
1429
|
+
await acceptPendingTransaction();
|
|
1430
|
+
return clone({
|
|
1431
|
+
status: "closed",
|
|
1432
|
+
batch: retry.batch,
|
|
1433
|
+
state: retry.candidate,
|
|
1434
|
+
});
|
|
1435
|
+
}
|
|
1436
|
+
lastError = undefined;
|
|
1437
|
+
const state = committedState;
|
|
1438
|
+
const sequence = state.counters.events;
|
|
1439
|
+
const event = {
|
|
1440
|
+
schemaVersion: "agent-factory.event.v1",
|
|
1441
|
+
id: identity("event", state.sessionId, sequence, "SESSION_COMPLETED"),
|
|
1442
|
+
type: "SESSION_COMPLETED",
|
|
1443
|
+
context: eventContext(state, sequence, state.virtualTime),
|
|
1444
|
+
payload: {
|
|
1445
|
+
finalStatus: "TERMINATED",
|
|
1446
|
+
completedAt: state.virtualTime,
|
|
1447
|
+
durationMillis: state.virtualElapsedMs,
|
|
1448
|
+
},
|
|
1449
|
+
};
|
|
1450
|
+
const candidate = {
|
|
1451
|
+
...state,
|
|
1452
|
+
lifecycle: "closed",
|
|
1453
|
+
counters: {
|
|
1454
|
+
...state.counters,
|
|
1455
|
+
commands: state.counters.commands + 1,
|
|
1456
|
+
events: state.counters.events + 1,
|
|
1457
|
+
},
|
|
1458
|
+
};
|
|
1459
|
+
const batch = [event];
|
|
1460
|
+
await write("close", retryKey, batch, candidate);
|
|
1461
|
+
return clone({ status: "closed", batch, state: candidate });
|
|
1462
|
+
}
|
|
1463
|
+
finally {
|
|
1464
|
+
commandInFlight = undefined;
|
|
1465
|
+
}
|
|
1466
|
+
};
|
|
1467
|
+
const reset = () => {
|
|
1468
|
+
if (commandInFlight !== undefined) {
|
|
1469
|
+
throw new FactoryEmulatorPendingCommandError("reset", commandInFlight);
|
|
1470
|
+
}
|
|
1471
|
+
if (committedState.lifecycle === "closed") {
|
|
1472
|
+
throw new FactoryEmulatorLifecycleError("reset", "closed");
|
|
1473
|
+
}
|
|
1474
|
+
pendingTransaction = undefined;
|
|
1475
|
+
pendingAdvanceContext = undefined;
|
|
1476
|
+
lastError = undefined;
|
|
1477
|
+
committedState = clone(PRE_START_STATE);
|
|
1478
|
+
return clone({ status: "reset", state: committedState });
|
|
1479
|
+
};
|
|
1480
|
+
return {
|
|
1481
|
+
start,
|
|
1482
|
+
submit,
|
|
1483
|
+
advanceBy: (durationMs) => runAdvance("advanceBy", durationMs),
|
|
1484
|
+
advanceToNext: () => runAdvance("advanceToNext"),
|
|
1485
|
+
close,
|
|
1486
|
+
reset,
|
|
1487
|
+
state: () => clone(committedState),
|
|
1488
|
+
status,
|
|
1489
|
+
};
|
|
1490
|
+
}
|