ai-workflow-logic 0.1.3 → 0.1.4
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/package.json +6 -3
- package/src/agent-invocation.ts +282 -0
- package/src/agent-state.ts +293 -0
- package/src/checkpoint-filesystem.ts +1223 -0
- package/src/checkpoint-store.ts +601 -0
- package/src/index.ts +34 -4
- package/src/run-checkpoint.ts +1666 -0
- package/src/run-freeze.ts +57 -1
|
@@ -0,0 +1,1666 @@
|
|
|
1
|
+
import {
|
|
2
|
+
FLOW_INSTANCE_SCHEMA_VERSION,
|
|
3
|
+
FLOW_RUN_CHECKPOINT_SCHEMA_VERSION,
|
|
4
|
+
type AICtrlWorkflowRunProfileState,
|
|
5
|
+
type AIDataWorkflowRunProfileState,
|
|
6
|
+
type AIWorkflowFlowRunCheckpoint,
|
|
7
|
+
type AIWorkflowProfileDurableState,
|
|
8
|
+
type FlowClosedObject,
|
|
9
|
+
type FlowClosedValue,
|
|
10
|
+
type FlowDefinitionArtifactIdentity,
|
|
11
|
+
type FlowDefinitionProvenance,
|
|
12
|
+
type FlowInstanceDescriptor,
|
|
13
|
+
type FlowRunProfileState,
|
|
14
|
+
type WorkCtrlFlowRunProfileState,
|
|
15
|
+
} from 'ai-workflow-contract';
|
|
16
|
+
import { normalizeCanonicalAIAgentState } from './agent-state.js';
|
|
17
|
+
|
|
18
|
+
const SHA256_DIGEST = /^sha256:[a-f0-9]{64}$/;
|
|
19
|
+
|
|
20
|
+
export class FlowRunContractValidationError extends Error {
|
|
21
|
+
constructor(
|
|
22
|
+
public readonly code: string,
|
|
23
|
+
public readonly location: string,
|
|
24
|
+
message: string,
|
|
25
|
+
) {
|
|
26
|
+
super(`${location}: ${message}`);
|
|
27
|
+
this.name = 'FlowRunContractValidationError';
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function normalizeFlowInstanceDescriptor(value: unknown): FlowInstanceDescriptor {
|
|
32
|
+
const record = exactRecord(
|
|
33
|
+
value,
|
|
34
|
+
['schemaVersion', 'instanceId', 'definition', 'materializedAtMs'],
|
|
35
|
+
[],
|
|
36
|
+
'instance',
|
|
37
|
+
);
|
|
38
|
+
const schemaVersion = dataField(record, 'schemaVersion', 'instance.schemaVersion');
|
|
39
|
+
if (schemaVersion !== FLOW_INSTANCE_SCHEMA_VERSION) {
|
|
40
|
+
invalid('FLOW_INSTANCE_SCHEMA_INVALID', 'instance.schemaVersion', `Expected ${FLOW_INSTANCE_SCHEMA_VERSION}.`);
|
|
41
|
+
}
|
|
42
|
+
const instanceId = canonicalIdentity(dataField(record, 'instanceId', 'instance.instanceId'), 'instance.instanceId');
|
|
43
|
+
const definition = normalizeDefinitionIdentity(dataField(record, 'definition', 'instance.definition'), 'instance.definition');
|
|
44
|
+
const materializedAtMs = nonNegativeSafeInteger(
|
|
45
|
+
dataField(record, 'materializedAtMs', 'instance.materializedAtMs'),
|
|
46
|
+
'instance.materializedAtMs',
|
|
47
|
+
);
|
|
48
|
+
return Object.freeze({ schemaVersion, instanceId, definition, materializedAtMs });
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function normalizeFlowRunCheckpoint(value: unknown): AIWorkflowFlowRunCheckpoint {
|
|
52
|
+
const record = exactRecord(
|
|
53
|
+
value,
|
|
54
|
+
[
|
|
55
|
+
'schemaVersion',
|
|
56
|
+
'runId',
|
|
57
|
+
'instanceId',
|
|
58
|
+
'definition',
|
|
59
|
+
'version',
|
|
60
|
+
'input',
|
|
61
|
+
'config',
|
|
62
|
+
'state',
|
|
63
|
+
'output',
|
|
64
|
+
'controllerSidecars',
|
|
65
|
+
'nodeSidecars',
|
|
66
|
+
'profile',
|
|
67
|
+
],
|
|
68
|
+
[],
|
|
69
|
+
'checkpoint',
|
|
70
|
+
);
|
|
71
|
+
const schemaVersion = dataField(record, 'schemaVersion', 'checkpoint.schemaVersion');
|
|
72
|
+
if (schemaVersion !== FLOW_RUN_CHECKPOINT_SCHEMA_VERSION) {
|
|
73
|
+
invalid('FLOW_RUN_CHECKPOINT_SCHEMA_INVALID', 'checkpoint.schemaVersion', `Expected ${FLOW_RUN_CHECKPOINT_SCHEMA_VERSION}.`);
|
|
74
|
+
}
|
|
75
|
+
const runId = canonicalIdentity(dataField(record, 'runId', 'checkpoint.runId'), 'checkpoint.runId');
|
|
76
|
+
const instanceId = canonicalIdentity(dataField(record, 'instanceId', 'checkpoint.instanceId'), 'checkpoint.instanceId');
|
|
77
|
+
const definition = normalizeDefinitionIdentity(dataField(record, 'definition', 'checkpoint.definition'), 'checkpoint.definition');
|
|
78
|
+
const version = nonNegativeSafeInteger(dataField(record, 'version', 'checkpoint.version'), 'checkpoint.version');
|
|
79
|
+
const input = cloneClosedValue(dataField(record, 'input', 'checkpoint.input'), 'checkpoint.input');
|
|
80
|
+
const config = cloneClosedValue(dataField(record, 'config', 'checkpoint.config'), 'checkpoint.config');
|
|
81
|
+
const state = cloneClosedValue(dataField(record, 'state', 'checkpoint.state'), 'checkpoint.state');
|
|
82
|
+
const output = cloneClosedValue(dataField(record, 'output', 'checkpoint.output'), 'checkpoint.output');
|
|
83
|
+
const controllerSidecars = cloneClosedObject(
|
|
84
|
+
dataField(record, 'controllerSidecars', 'checkpoint.controllerSidecars'),
|
|
85
|
+
'checkpoint.controllerSidecars',
|
|
86
|
+
);
|
|
87
|
+
const nodeSidecars = cloneClosedObject(
|
|
88
|
+
dataField(record, 'nodeSidecars', 'checkpoint.nodeSidecars'),
|
|
89
|
+
'checkpoint.nodeSidecars',
|
|
90
|
+
);
|
|
91
|
+
const profile = normalizeProfile(dataField(record, 'profile', 'checkpoint.profile'), runId);
|
|
92
|
+
validateProfileProjection(profile, { input, state, output, controllerSidecars, nodeSidecars });
|
|
93
|
+
return Object.freeze({
|
|
94
|
+
schemaVersion,
|
|
95
|
+
runId,
|
|
96
|
+
instanceId,
|
|
97
|
+
definition,
|
|
98
|
+
version,
|
|
99
|
+
input,
|
|
100
|
+
config,
|
|
101
|
+
state,
|
|
102
|
+
output,
|
|
103
|
+
controllerSidecars,
|
|
104
|
+
nodeSidecars,
|
|
105
|
+
profile,
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function validateProfileProjection(
|
|
110
|
+
profile: FlowRunProfileState,
|
|
111
|
+
envelope: {
|
|
112
|
+
readonly input: FlowClosedValue;
|
|
113
|
+
readonly state: FlowClosedValue;
|
|
114
|
+
readonly output: FlowClosedValue;
|
|
115
|
+
readonly controllerSidecars: FlowClosedObject;
|
|
116
|
+
readonly nodeSidecars: FlowClosedObject;
|
|
117
|
+
},
|
|
118
|
+
): void {
|
|
119
|
+
if (profile.kind === 'WorkCtrlFlow' || profile.kind === 'AICtrlWorkflow') {
|
|
120
|
+
validateWorkCtrlProfileProjection(profile.snapshot, envelope);
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
validateAIDataProfileProjection(profile.runGraph, envelope);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function validateWorkCtrlProfileProjection(
|
|
127
|
+
value: FlowClosedObject,
|
|
128
|
+
envelope: {
|
|
129
|
+
readonly input: FlowClosedValue;
|
|
130
|
+
readonly state: FlowClosedValue;
|
|
131
|
+
readonly controllerSidecars: FlowClosedObject;
|
|
132
|
+
readonly nodeSidecars: FlowClosedObject;
|
|
133
|
+
},
|
|
134
|
+
): void {
|
|
135
|
+
const snapshot = plainRecord(value, 'checkpoint.profile.snapshot');
|
|
136
|
+
const expectedController = {
|
|
137
|
+
status: dataField(snapshot, 'status', 'checkpoint.profile.snapshot.status'),
|
|
138
|
+
lastTickNo: dataField(snapshot, 'lastTickNo', 'checkpoint.profile.snapshot.lastTickNo'),
|
|
139
|
+
branchDecisions: dataField(snapshot, 'branchDecisions', 'checkpoint.profile.snapshot.branchDecisions'),
|
|
140
|
+
orchestration: dataField(snapshot, 'orchestration', 'checkpoint.profile.snapshot.orchestration'),
|
|
141
|
+
openWaitHandles: dataField(snapshot, 'openWaitHandles', 'checkpoint.profile.snapshot.openWaitHandles'),
|
|
142
|
+
closedWaitHandles: dataField(snapshot, 'closedWaitHandles', 'checkpoint.profile.snapshot.closedWaitHandles'),
|
|
143
|
+
appliedResumeSignals: dataField(snapshot, 'appliedResumeSignals', 'checkpoint.profile.snapshot.appliedResumeSignals'),
|
|
144
|
+
};
|
|
145
|
+
const expectedNodes = {
|
|
146
|
+
nodeResults: dataField(snapshot, 'nodeResults', 'checkpoint.profile.snapshot.nodeResults'),
|
|
147
|
+
waitResults: dataField(snapshot, 'waitResults', 'checkpoint.profile.snapshot.waitResults'),
|
|
148
|
+
};
|
|
149
|
+
const facts: readonly [string, unknown, unknown][] = [
|
|
150
|
+
['input', envelope.input, dataField(snapshot, 'input', 'checkpoint.profile.snapshot.input')],
|
|
151
|
+
['state', envelope.state, dataField(snapshot, 'vars', 'checkpoint.profile.snapshot.vars')],
|
|
152
|
+
['controllerSidecars', envelope.controllerSidecars, expectedController],
|
|
153
|
+
['nodeSidecars', envelope.nodeSidecars, expectedNodes],
|
|
154
|
+
];
|
|
155
|
+
for (const [field, actual, expected] of facts) {
|
|
156
|
+
if (!sameProjection(actual, expected)) {
|
|
157
|
+
invalid('WORK_CTRL_ENVELOPE_PROJECTION_MISMATCH', `checkpoint.${field}`, `${field} must exactly equal its canonical snapshot projection.`);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function validateAIDataProfileProjection(
|
|
163
|
+
value: FlowClosedObject,
|
|
164
|
+
envelope: {
|
|
165
|
+
readonly state: FlowClosedValue;
|
|
166
|
+
readonly output: FlowClosedValue;
|
|
167
|
+
readonly controllerSidecars: FlowClosedObject;
|
|
168
|
+
readonly nodeSidecars: FlowClosedObject;
|
|
169
|
+
},
|
|
170
|
+
): void {
|
|
171
|
+
const graph = plainRecord(value, 'checkpoint.profile.runGraph');
|
|
172
|
+
const nodes = plainRecord(dataField(graph, 'nodes', 'checkpoint.profile.runGraph.nodes'), 'checkpoint.profile.runGraph.nodes');
|
|
173
|
+
const declarationOrder = identityArray(
|
|
174
|
+
dataField(graph, 'declarationOrder', 'checkpoint.profile.runGraph.declarationOrder'),
|
|
175
|
+
'checkpoint.profile.runGraph.declarationOrder',
|
|
176
|
+
);
|
|
177
|
+
const statuses = declarationOrder.map((nodeId) => {
|
|
178
|
+
const node = plainRecord(dataField(nodes, nodeId, `checkpoint.profile.runGraph.nodes.${nodeId}`), `checkpoint.profile.runGraph.nodes.${nodeId}`);
|
|
179
|
+
return String(dataField(node, 'status', `checkpoint.profile.runGraph.nodes.${nodeId}.status`));
|
|
180
|
+
}).filter((status) => status !== 'Removed');
|
|
181
|
+
const status = statuses.some((item) => item === 'Failed')
|
|
182
|
+
? 'Failed'
|
|
183
|
+
: statuses.some((item) => item === 'Running')
|
|
184
|
+
? 'Running'
|
|
185
|
+
: statuses.length > 0 && statuses.every((item) => item === 'Succeeded' || item === 'Reused')
|
|
186
|
+
? 'Succeeded'
|
|
187
|
+
: 'Pending';
|
|
188
|
+
const generation = dataField(graph, 'currentGeneration', 'checkpoint.profile.runGraph.currentGeneration');
|
|
189
|
+
const expectedState = { status, generation };
|
|
190
|
+
const expectedController = { status };
|
|
191
|
+
const expectedNodes = Object.fromEntries(declarationOrder.map((nodeId) => {
|
|
192
|
+
const node = plainRecord(dataField(nodes, nodeId, `checkpoint.profile.runGraph.nodes.${nodeId}`), `checkpoint.profile.runGraph.nodes.${nodeId}`);
|
|
193
|
+
return [nodeId, {
|
|
194
|
+
status: dataField(node, 'status', `checkpoint.profile.runGraph.nodes.${nodeId}.status`),
|
|
195
|
+
generation: dataField(node, 'generation', `checkpoint.profile.runGraph.nodes.${nodeId}.generation`),
|
|
196
|
+
}];
|
|
197
|
+
}));
|
|
198
|
+
const expectedOutput = status === 'Succeeded'
|
|
199
|
+
? terminalAIDataOutput(graph, nodes, declarationOrder)
|
|
200
|
+
: null;
|
|
201
|
+
const facts: readonly [string, unknown, unknown][] = [
|
|
202
|
+
['state', envelope.state, expectedState],
|
|
203
|
+
['controllerSidecars', envelope.controllerSidecars, expectedController],
|
|
204
|
+
['nodeSidecars', envelope.nodeSidecars, expectedNodes],
|
|
205
|
+
['output', envelope.output, expectedOutput],
|
|
206
|
+
];
|
|
207
|
+
for (const [field, actual, expected] of facts) {
|
|
208
|
+
if (!sameProjection(actual, expected)) {
|
|
209
|
+
invalid('AI_DATA_ENVELOPE_PROJECTION_MISMATCH', `checkpoint.${field}`, `${field} must exactly equal its canonical RunGraph projection.`);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function sameProjection(actual: unknown, expected: unknown): boolean {
|
|
215
|
+
return JSON.stringify(actual) === JSON.stringify(cloneClosedValue(expected, 'checkpoint.projection'));
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function terminalAIDataOutput(
|
|
219
|
+
_graph: Record<string, unknown>,
|
|
220
|
+
nodes: Record<string, unknown>,
|
|
221
|
+
declarationOrder: readonly string[],
|
|
222
|
+
): unknown {
|
|
223
|
+
const returnIds = declarationOrder.filter((nodeId) => {
|
|
224
|
+
const node = plainRecord(dataField(nodes, nodeId, `checkpoint.profile.runGraph.nodes.${nodeId}`), `checkpoint.profile.runGraph.nodes.${nodeId}`);
|
|
225
|
+
return dataField(node, 'tag', `checkpoint.profile.runGraph.nodes.${nodeId}.tag`) === 'ReturnNode';
|
|
226
|
+
});
|
|
227
|
+
if (returnIds.length !== 1) {
|
|
228
|
+
invalid('AI_DATA_TERMINAL_RETURN_INVALID', 'checkpoint.profile.runGraph', 'Succeeded RunGraph requires exactly one ReturnNode.');
|
|
229
|
+
}
|
|
230
|
+
const returnNode = plainRecord(dataField(nodes, returnIds[0], `checkpoint.profile.runGraph.nodes.${returnIds[0]}`), `checkpoint.profile.runGraph.nodes.${returnIds[0]}`);
|
|
231
|
+
const result = plainRecord(dataField(returnNode, 'result', `checkpoint.profile.runGraph.nodes.${returnIds[0]}.result`), `checkpoint.profile.runGraph.nodes.${returnIds[0]}.result`);
|
|
232
|
+
return dataField(result, 'output', `checkpoint.profile.runGraph.nodes.${returnIds[0]}.result.output`);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function normalizeDefinitionIdentity(value: unknown, location: string): FlowDefinitionArtifactIdentity {
|
|
236
|
+
const record = exactRecord(value, ['revision', 'digest', 'provenance'], [], location);
|
|
237
|
+
const revision = canonicalIdentity(dataField(record, 'revision', `${location}.revision`), `${location}.revision`);
|
|
238
|
+
const digest = dataField(record, 'digest', `${location}.digest`);
|
|
239
|
+
if (typeof digest !== 'string' || !SHA256_DIGEST.test(digest)) {
|
|
240
|
+
invalid('FLOW_DEFINITION_DIGEST_INVALID', `${location}.digest`, 'Expected a lowercase sha256 digest.');
|
|
241
|
+
}
|
|
242
|
+
const provenance = normalizeDefinitionProvenance(
|
|
243
|
+
dataField(record, 'provenance', `${location}.provenance`),
|
|
244
|
+
`${location}.provenance`,
|
|
245
|
+
);
|
|
246
|
+
return Object.freeze({ revision, digest: digest as `sha256:${string}`, provenance });
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function normalizeDefinitionProvenance(value: unknown, location: string): FlowDefinitionProvenance {
|
|
250
|
+
const record = exactRecord(value, ['authority', 'artifactRef'], ['metadata'], location);
|
|
251
|
+
const authority = canonicalIdentity(dataField(record, 'authority', `${location}.authority`), `${location}.authority`);
|
|
252
|
+
const artifactRef = canonicalIdentity(dataField(record, 'artifactRef', `${location}.artifactRef`), `${location}.artifactRef`);
|
|
253
|
+
const hasMetadata = Object.prototype.hasOwnProperty.call(record, 'metadata');
|
|
254
|
+
const metadata = !hasMetadata
|
|
255
|
+
? undefined
|
|
256
|
+
: cloneClosedObject(dataField(record, 'metadata', `${location}.metadata`), `${location}.metadata`);
|
|
257
|
+
return Object.freeze({ authority, artifactRef, ...(metadata === undefined ? {} : { metadata }) });
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function normalizeProfile(value: unknown, runId: string): FlowRunProfileState {
|
|
261
|
+
const base = plainRecord(value, 'checkpoint.profile');
|
|
262
|
+
const kind = dataField(base, 'kind', 'checkpoint.profile.kind');
|
|
263
|
+
switch (kind) {
|
|
264
|
+
case 'WorkCtrlFlow': {
|
|
265
|
+
const record = exactRecord(value, ['kind', 'snapshot'], [], 'checkpoint.profile');
|
|
266
|
+
const profile: WorkCtrlFlowRunProfileState = {
|
|
267
|
+
kind,
|
|
268
|
+
snapshot: normalizeWorkCtrlSnapshot(
|
|
269
|
+
dataField(record, 'snapshot', 'checkpoint.profile.snapshot'),
|
|
270
|
+
runId,
|
|
271
|
+
'checkpoint.profile.snapshot',
|
|
272
|
+
),
|
|
273
|
+
};
|
|
274
|
+
return Object.freeze(profile);
|
|
275
|
+
}
|
|
276
|
+
case 'AICtrlWorkflow': {
|
|
277
|
+
const record = exactRecord(value, ['kind', 'snapshot', 'ai'], [], 'checkpoint.profile');
|
|
278
|
+
const profile: AICtrlWorkflowRunProfileState = {
|
|
279
|
+
kind,
|
|
280
|
+
snapshot: normalizeWorkCtrlSnapshot(
|
|
281
|
+
dataField(record, 'snapshot', 'checkpoint.profile.snapshot'),
|
|
282
|
+
runId,
|
|
283
|
+
'checkpoint.profile.snapshot',
|
|
284
|
+
),
|
|
285
|
+
ai: normalizeAIWorkflowProfileDurableState(
|
|
286
|
+
dataField(record, 'ai', 'checkpoint.profile.ai'),
|
|
287
|
+
'checkpoint.profile.ai',
|
|
288
|
+
),
|
|
289
|
+
};
|
|
290
|
+
return Object.freeze(profile);
|
|
291
|
+
}
|
|
292
|
+
case 'AIDataWorkflow': {
|
|
293
|
+
const record = exactRecord(value, ['kind', 'runGraph', 'ai'], [], 'checkpoint.profile');
|
|
294
|
+
const profile: AIDataWorkflowRunProfileState = {
|
|
295
|
+
kind,
|
|
296
|
+
runGraph: normalizeAIDataRunGraph(
|
|
297
|
+
dataField(record, 'runGraph', 'checkpoint.profile.runGraph'),
|
|
298
|
+
runId,
|
|
299
|
+
'checkpoint.profile.runGraph',
|
|
300
|
+
),
|
|
301
|
+
ai: normalizeAIWorkflowProfileDurableState(
|
|
302
|
+
dataField(record, 'ai', 'checkpoint.profile.ai'),
|
|
303
|
+
'checkpoint.profile.ai',
|
|
304
|
+
),
|
|
305
|
+
};
|
|
306
|
+
return Object.freeze(profile);
|
|
307
|
+
}
|
|
308
|
+
default:
|
|
309
|
+
invalid('FLOW_RUN_PROFILE_KIND_INVALID', 'checkpoint.profile.kind', 'Expected WorkCtrlFlow, AICtrlWorkflow or AIDataWorkflow.');
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
const WORK_CTRL_SNAPSHOT_FIELDS = [
|
|
314
|
+
'schemaVersion',
|
|
315
|
+
'treeId',
|
|
316
|
+
'fqn',
|
|
317
|
+
'status',
|
|
318
|
+
'vars',
|
|
319
|
+
'input',
|
|
320
|
+
'branchDecisions',
|
|
321
|
+
'nodeResults',
|
|
322
|
+
'waitResults',
|
|
323
|
+
'openWaitHandles',
|
|
324
|
+
'closedWaitHandles',
|
|
325
|
+
'appliedResumeSignals',
|
|
326
|
+
'orchestration',
|
|
327
|
+
'lastTickNo',
|
|
328
|
+
'updatedAtMs',
|
|
329
|
+
] as const;
|
|
330
|
+
|
|
331
|
+
function normalizeWorkCtrlSnapshot(value: unknown, runId: string, location: string): FlowClosedObject {
|
|
332
|
+
const snapshot = cloneClosedObject(value, location);
|
|
333
|
+
const record = exactRecord(snapshot, WORK_CTRL_SNAPSHOT_FIELDS, [], location);
|
|
334
|
+
if (dataField(record, 'schemaVersion', `${location}.schemaVersion`) !== 'depa.flows.workflow/v4') {
|
|
335
|
+
invalid('WORK_CTRL_SNAPSHOT_SCHEMA_INVALID', `${location}.schemaVersion`, 'Expected depa.flows.workflow/v4.');
|
|
336
|
+
}
|
|
337
|
+
const treeId = canonicalIdentity(dataField(record, 'treeId', `${location}.treeId`), `${location}.treeId`);
|
|
338
|
+
if (treeId !== runId) {
|
|
339
|
+
invalid('FLOW_RUN_PROFILE_IDENTITY_MISMATCH', `${location}.treeId`, 'Snapshot tree identity must equal the checkpoint run identity.');
|
|
340
|
+
}
|
|
341
|
+
canonicalIdentity(dataField(record, 'fqn', `${location}.fqn`), `${location}.fqn`);
|
|
342
|
+
const status = dataField(record, 'status', `${location}.status`);
|
|
343
|
+
if (status !== 'Waiting' && status !== 'Completed' && status !== 'Failed') {
|
|
344
|
+
invalid('WORK_CTRL_SNAPSHOT_STATUS_INVALID', `${location}.status`, 'Expected Waiting, Completed or Failed.');
|
|
345
|
+
}
|
|
346
|
+
for (const field of ['vars', 'input'] as const) {
|
|
347
|
+
plainRecord(dataField(record, field, `${location}.${field}`), `${location}.${field}`);
|
|
348
|
+
}
|
|
349
|
+
normalizeWorkCtrlNestedFacts(record, location);
|
|
350
|
+
const openHandles = normalizeWaitHandles(
|
|
351
|
+
dataField(record, 'openWaitHandles', `${location}.openWaitHandles`),
|
|
352
|
+
treeId,
|
|
353
|
+
'open',
|
|
354
|
+
`${location}.openWaitHandles`,
|
|
355
|
+
);
|
|
356
|
+
const closedHandles = normalizeWaitHandles(
|
|
357
|
+
dataField(record, 'closedWaitHandles', `${location}.closedWaitHandles`),
|
|
358
|
+
treeId,
|
|
359
|
+
'closed',
|
|
360
|
+
`${location}.closedWaitHandles`,
|
|
361
|
+
);
|
|
362
|
+
const handleIds = [...openHandles, ...closedHandles].map((handle) => handle.waitHandleId);
|
|
363
|
+
if (new Set(handleIds).size !== handleIds.length) {
|
|
364
|
+
invalid('WORK_CTRL_WAIT_HANDLE_DUPLICATE', location, 'Wait handle identities must be unique across open and closed collections.');
|
|
365
|
+
}
|
|
366
|
+
const appliedResumeSignals = closedArray(
|
|
367
|
+
dataField(record, 'appliedResumeSignals', `${location}.appliedResumeSignals`),
|
|
368
|
+
`${location}.appliedResumeSignals`,
|
|
369
|
+
);
|
|
370
|
+
appliedResumeSignals.forEach((signal, index) => {
|
|
371
|
+
canonicalIdentity(signal, `${location}.appliedResumeSignals[${index}]`);
|
|
372
|
+
});
|
|
373
|
+
if (new Set(appliedResumeSignals).size !== appliedResumeSignals.length) {
|
|
374
|
+
invalid('WORK_CTRL_APPLIED_SIGNAL_DUPLICATE', `${location}.appliedResumeSignals`, 'Applied signal identities must be unique.');
|
|
375
|
+
}
|
|
376
|
+
const relatedSignals = new Set(
|
|
377
|
+
closedHandles.map((handle) => `${handle.waitHandleId}:${handle.resumeToken}`),
|
|
378
|
+
);
|
|
379
|
+
for (const signal of appliedResumeSignals) {
|
|
380
|
+
if (!relatedSignals.has(String(signal))) {
|
|
381
|
+
invalid('WORK_CTRL_APPLIED_SIGNAL_UNRELATED', `${location}.appliedResumeSignals`, 'Applied signals must identify a closed wait handle and its resume token.');
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
const orchestrationLocation = `${location}.orchestration`;
|
|
385
|
+
const orchestration = exactRecord(
|
|
386
|
+
dataField(record, 'orchestration', orchestrationLocation),
|
|
387
|
+
['attempts', 'deadlines', 'lanes', 'winners', 'cancellations', 'composites'],
|
|
388
|
+
[],
|
|
389
|
+
orchestrationLocation,
|
|
390
|
+
);
|
|
391
|
+
for (const field of ['attempts', 'deadlines', 'lanes', 'winners', 'cancellations', 'composites'] as const) {
|
|
392
|
+
plainRecord(dataField(orchestration, field, `${orchestrationLocation}.${field}`), `${orchestrationLocation}.${field}`);
|
|
393
|
+
}
|
|
394
|
+
if (status === 'Completed') {
|
|
395
|
+
if (openHandles.length > 0) {
|
|
396
|
+
invalid('WORK_CTRL_COMPLETED_WITH_OPEN_WAIT', `${location}.openWaitHandles`, 'Completed snapshots cannot retain open wait handles.');
|
|
397
|
+
}
|
|
398
|
+
const composites = plainRecord(dataField(orchestration, 'composites', `${orchestrationLocation}.composites`), `${orchestrationLocation}.composites`);
|
|
399
|
+
for (const key of stringDataKeys(composites, `${orchestrationLocation}.composites`)) {
|
|
400
|
+
const fact = plainRecord(dataField(composites, key, `${orchestrationLocation}.composites.${key}`), `${orchestrationLocation}.composites.${key}`);
|
|
401
|
+
if (['Open', 'Waiting'].includes(String(dataField(fact, 'status', `${orchestrationLocation}.composites.${key}.status`)))) {
|
|
402
|
+
invalid('WORK_CTRL_COMPLETED_WITH_OPEN_COMPOSITE', `${orchestrationLocation}.composites.${key}.status`, 'Completed snapshots cannot retain open composite work.');
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
nonNegativeSafeInteger(dataField(record, 'lastTickNo', `${location}.lastTickNo`), `${location}.lastTickNo`);
|
|
407
|
+
nonNegativeSafeInteger(dataField(record, 'updatedAtMs', `${location}.updatedAtMs`), `${location}.updatedAtMs`);
|
|
408
|
+
return snapshot;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
function normalizeWorkCtrlNestedFacts(snapshot: Record<string, unknown>, location: string): void {
|
|
412
|
+
const branchLocation = `${location}.branchDecisions`;
|
|
413
|
+
const branches = plainRecord(dataField(snapshot, 'branchDecisions', branchLocation), branchLocation);
|
|
414
|
+
for (const key of stringDataKeys(branches, branchLocation).sort(compareCodeUnits)) {
|
|
415
|
+
canonicalIdentity(key, `${branchLocation}.${key}`);
|
|
416
|
+
const factLocation = `${branchLocation}.${key}`;
|
|
417
|
+
const fact = exactRecord(dataField(branches, key, factLocation), ['ifPath', 'invocationKey', 'kind'], ['branchKey'], factLocation);
|
|
418
|
+
const invocationKey = canonicalIdentity(dataField(fact, 'invocationKey', `${factLocation}.invocationKey`), `${factLocation}.invocationKey`);
|
|
419
|
+
if (invocationKey !== key) invalid('WORK_CTRL_FACT_IDENTITY_MISMATCH', `${factLocation}.invocationKey`, 'Branch fact identity must equal its map key.');
|
|
420
|
+
const ifPath = canonicalIdentity(dataField(fact, 'ifPath', `${factLocation}.ifPath`), `${factLocation}.ifPath`);
|
|
421
|
+
const visitSeparator = invocationKey.lastIndexOf('#');
|
|
422
|
+
const visitIndex = visitSeparator < 0 ? '' : invocationKey.slice(visitSeparator + 1);
|
|
423
|
+
if (visitSeparator < 1 || invocationKey.slice(0, visitSeparator) !== ifPath || !/^(0|[1-9][0-9]*)$/u.test(visitIndex)) {
|
|
424
|
+
invalid('WORK_CTRL_BRANCH_PATH_PROJECTION_MISMATCH', factLocation, 'Branch invocation identity must equal ifPath#visitIndex.');
|
|
425
|
+
}
|
|
426
|
+
const kind = dataField(fact, 'kind', `${factLocation}.kind`);
|
|
427
|
+
if (!['Branch', 'Otherwise', 'NoMatch'].includes(String(kind))) invalid('WORK_CTRL_BRANCH_KIND_INVALID', `${factLocation}.kind`, 'Branch kind is not part of the public contract.');
|
|
428
|
+
const hasBranchKey = Object.prototype.hasOwnProperty.call(fact, 'branchKey');
|
|
429
|
+
if (kind === 'Branch' && !hasBranchKey) invalid('WORK_CTRL_BRANCH_KEY_MISSING', `${factLocation}.branchKey`, 'Branch decisions require a branch key.');
|
|
430
|
+
if (hasBranchKey) {
|
|
431
|
+
canonicalIdentity(dataField(fact, 'branchKey', `${factLocation}.branchKey`), `${factLocation}.branchKey`);
|
|
432
|
+
if (kind !== 'Branch') invalid('WORK_CTRL_BRANCH_KEY_INVALID', `${factLocation}.branchKey`, 'Only Branch decisions can retain a branch key.');
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
const nodeResultsLocation = `${location}.nodeResults`;
|
|
437
|
+
const nodeResults = plainRecord(dataField(snapshot, 'nodeResults', nodeResultsLocation), nodeResultsLocation);
|
|
438
|
+
for (const key of stringDataKeys(nodeResults, nodeResultsLocation).sort(compareCodeUnits)) {
|
|
439
|
+
canonicalIdentity(key, `${nodeResultsLocation}.${key}`);
|
|
440
|
+
normalizeTerminalResult(dataField(nodeResults, key, `${nodeResultsLocation}.${key}`), `${nodeResultsLocation}.${key}`);
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
const waitResultsLocation = `${location}.waitResults`;
|
|
444
|
+
const waitResults = plainRecord(dataField(snapshot, 'waitResults', waitResultsLocation), waitResultsLocation);
|
|
445
|
+
for (const key of stringDataKeys(waitResults, waitResultsLocation).sort(compareCodeUnits)) {
|
|
446
|
+
canonicalIdentity(key, `${waitResultsLocation}.${key}`);
|
|
447
|
+
const factLocation = `${waitResultsLocation}.${key}`;
|
|
448
|
+
const fact = exactRecord(dataField(waitResults, key, factLocation), ['result'], ['payload'], factLocation);
|
|
449
|
+
normalizeTerminalResult(dataField(fact, 'result', `${factLocation}.result`), `${factLocation}.result`);
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
const orchestrationLocation = `${location}.orchestration`;
|
|
453
|
+
const orchestration = exactRecord(
|
|
454
|
+
dataField(snapshot, 'orchestration', orchestrationLocation),
|
|
455
|
+
['attempts', 'deadlines', 'lanes', 'winners', 'cancellations', 'composites'],
|
|
456
|
+
[],
|
|
457
|
+
orchestrationLocation,
|
|
458
|
+
);
|
|
459
|
+
normalizeWorkCtrlAttempts(dataField(orchestration, 'attempts', `${orchestrationLocation}.attempts`), `${orchestrationLocation}.attempts`);
|
|
460
|
+
normalizeWorkCtrlDeadlines(dataField(orchestration, 'deadlines', `${orchestrationLocation}.deadlines`), `${orchestrationLocation}.deadlines`);
|
|
461
|
+
const lanes = normalizeWorkCtrlLanes(dataField(orchestration, 'lanes', `${orchestrationLocation}.lanes`), `${orchestrationLocation}.lanes`);
|
|
462
|
+
normalizeWorkCtrlComposites(dataField(orchestration, 'composites', `${orchestrationLocation}.composites`), `${orchestrationLocation}.composites`);
|
|
463
|
+
normalizeWorkCtrlWinners(dataField(orchestration, 'winners', `${orchestrationLocation}.winners`), lanes, `${orchestrationLocation}.winners`);
|
|
464
|
+
normalizeWorkCtrlCancellations(dataField(orchestration, 'cancellations', `${orchestrationLocation}.cancellations`), `${orchestrationLocation}.cancellations`);
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
function normalizeTerminalResult(value: unknown, location: string): void {
|
|
468
|
+
if (value !== 'Success' && value !== 'Failure' && value !== 'Cancelled') {
|
|
469
|
+
invalid('WORK_CTRL_TERMINAL_RESULT_INVALID', location, 'Terminal result must be Success, Failure or Cancelled.');
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
function normalizeDurableFactStatus(value: unknown, location: string): void {
|
|
474
|
+
if (!['Open', 'Completed', 'Failed', 'Cancelled', 'Expired'].includes(String(value))) {
|
|
475
|
+
invalid('WORK_CTRL_DURABLE_STATUS_INVALID', location, 'Durable fact status is not part of the public contract.');
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
function normalizeWorkCtrlAttempts(value: unknown, location: string): void {
|
|
480
|
+
for (const [key, item, itemLocation] of closedRecordEntries(value, location)) {
|
|
481
|
+
const fact = exactRecord(item, ['invocationKey', 'attempt', 'maxAttempts', 'status'], ['nextAttemptAtMs'], itemLocation);
|
|
482
|
+
assertFactKey(fact, key, itemLocation);
|
|
483
|
+
const attempt = positiveSafeInteger(dataField(fact, 'attempt', `${itemLocation}.attempt`), `${itemLocation}.attempt`);
|
|
484
|
+
const maxAttempts = positiveSafeInteger(dataField(fact, 'maxAttempts', `${itemLocation}.maxAttempts`), `${itemLocation}.maxAttempts`);
|
|
485
|
+
if (attempt > maxAttempts) invalid('WORK_CTRL_ATTEMPT_RANGE_INVALID', `${itemLocation}.attempt`, 'Attempt cannot exceed maxAttempts.');
|
|
486
|
+
normalizeDurableFactStatus(dataField(fact, 'status', `${itemLocation}.status`), `${itemLocation}.status`);
|
|
487
|
+
if (Object.prototype.hasOwnProperty.call(fact, 'nextAttemptAtMs')) nonNegativeSafeInteger(dataField(fact, 'nextAttemptAtMs', `${itemLocation}.nextAttemptAtMs`), `${itemLocation}.nextAttemptAtMs`);
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
function normalizeWorkCtrlDeadlines(value: unknown, location: string): void {
|
|
492
|
+
for (const [key, item, itemLocation] of closedRecordEntries(value, location)) {
|
|
493
|
+
const fact = exactRecord(item, ['invocationKey', 'deadlineAtMs', 'status'], [], itemLocation);
|
|
494
|
+
assertFactKey(fact, key, itemLocation);
|
|
495
|
+
nonNegativeSafeInteger(dataField(fact, 'deadlineAtMs', `${itemLocation}.deadlineAtMs`), `${itemLocation}.deadlineAtMs`);
|
|
496
|
+
normalizeDurableFactStatus(dataField(fact, 'status', `${itemLocation}.status`), `${itemLocation}.status`);
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
function normalizeWorkCtrlLanes(value: unknown, location: string): Map<string, { invocationKey: string; laneId: string; status: string }> {
|
|
501
|
+
const output = new Map<string, { invocationKey: string; laneId: string; status: string }>();
|
|
502
|
+
for (const [key, item, itemLocation] of closedRecordEntries(value, location)) {
|
|
503
|
+
const fact = exactRecord(item, ['invocationKey', 'laneId', 'status', 'bodyId'], ['item', 'flowState', 'output'], itemLocation);
|
|
504
|
+
const invocationKey = canonicalIdentity(dataField(fact, 'invocationKey', `${itemLocation}.invocationKey`), `${itemLocation}.invocationKey`);
|
|
505
|
+
const laneId = canonicalIdentity(dataField(fact, 'laneId', `${itemLocation}.laneId`), `${itemLocation}.laneId`);
|
|
506
|
+
if (key !== `${invocationKey}/${laneId}`) invalid('WORK_CTRL_LANE_IDENTITY_MISMATCH', itemLocation, 'Lane map key must equal invocationKey/laneId.');
|
|
507
|
+
canonicalIdentity(dataField(fact, 'bodyId', `${itemLocation}.bodyId`), `${itemLocation}.bodyId`);
|
|
508
|
+
const status = String(dataField(fact, 'status', `${itemLocation}.status`));
|
|
509
|
+
normalizeDurableFactStatus(status, `${itemLocation}.status`);
|
|
510
|
+
output.set(key, { invocationKey, laneId, status });
|
|
511
|
+
}
|
|
512
|
+
return output;
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
function normalizeWorkCtrlWinners(
|
|
516
|
+
value: unknown,
|
|
517
|
+
lanes: Map<string, { invocationKey: string; laneId: string; status: string }>,
|
|
518
|
+
location: string,
|
|
519
|
+
): void {
|
|
520
|
+
for (const [key, item, itemLocation] of closedRecordEntries(value, location)) {
|
|
521
|
+
const fact = exactRecord(item, ['invocationKey', 'winnerId', 'mode'], [], itemLocation);
|
|
522
|
+
assertFactKey(fact, key, itemLocation);
|
|
523
|
+
const winnerId = canonicalIdentity(dataField(fact, 'winnerId', `${itemLocation}.winnerId`), `${itemLocation}.winnerId`);
|
|
524
|
+
const mode = dataField(fact, 'mode', `${itemLocation}.mode`);
|
|
525
|
+
if (mode !== 'first-success' && mode !== 'first-completed') invalid('WORK_CTRL_WINNER_MODE_INVALID', `${itemLocation}.mode`, 'Winner mode is invalid.');
|
|
526
|
+
const winner = lanes.get(`${key}/${winnerId}`);
|
|
527
|
+
if (!winner || (mode === 'first-success' ? winner.status !== 'Completed' : !['Completed', 'Failed'].includes(winner.status))) {
|
|
528
|
+
invalid('WORK_CTRL_WINNER_PROJECTION_INVALID', itemLocation, 'Winner must project a settled lane under the same invocation.');
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
function normalizeWorkCtrlCancellations(value: unknown, location: string): void {
|
|
534
|
+
for (const [key, item, itemLocation] of closedRecordEntries(value, location)) {
|
|
535
|
+
const fact = exactRecord(item, ['invocationKey', 'reason', 'atMs'], [], itemLocation);
|
|
536
|
+
assertFactKey(fact, key, itemLocation);
|
|
537
|
+
if (!['SignalCancelled', 'DeadlineExceeded', 'RaceLost', 'ParentCancelled'].includes(String(dataField(fact, 'reason', `${itemLocation}.reason`)))) {
|
|
538
|
+
invalid('WORK_CTRL_CANCELLATION_REASON_INVALID', `${itemLocation}.reason`, 'Cancellation reason is not part of the public contract.');
|
|
539
|
+
}
|
|
540
|
+
nonNegativeSafeInteger(dataField(fact, 'atMs', `${itemLocation}.atMs`), `${itemLocation}.atMs`);
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
function normalizeWorkCtrlComposites(value: unknown, location: string): void {
|
|
545
|
+
for (const [key, item, itemLocation] of closedRecordEntries(value, location)) {
|
|
546
|
+
const fact = exactRecord(item, ['invocationKey', 'primitive', 'bodyId', 'status', 'updatedAtMs'], ['input', 'cursor', 'childTreeId'], itemLocation);
|
|
547
|
+
assertFactKey(fact, key, itemLocation);
|
|
548
|
+
const primitive = dataField(fact, 'primitive', `${itemLocation}.primitive`);
|
|
549
|
+
if (!['Retry', 'Timeout', 'ForEach', 'Parallel', 'Race', 'CallFlow'].includes(String(primitive))) invalid('WORK_CTRL_COMPOSITE_PRIMITIVE_INVALID', `${itemLocation}.primitive`, 'Composite primitive is invalid.');
|
|
550
|
+
canonicalIdentity(dataField(fact, 'bodyId', `${itemLocation}.bodyId`), `${itemLocation}.bodyId`);
|
|
551
|
+
if (!['Open', 'Waiting', 'Completed', 'Failed', 'Cancelled'].includes(String(dataField(fact, 'status', `${itemLocation}.status`)))) invalid('WORK_CTRL_COMPOSITE_STATUS_INVALID', `${itemLocation}.status`, 'Composite status is invalid.');
|
|
552
|
+
nonNegativeSafeInteger(dataField(fact, 'updatedAtMs', `${itemLocation}.updatedAtMs`), `${itemLocation}.updatedAtMs`);
|
|
553
|
+
if (Object.prototype.hasOwnProperty.call(fact, 'cursor')) nonNegativeSafeInteger(dataField(fact, 'cursor', `${itemLocation}.cursor`), `${itemLocation}.cursor`);
|
|
554
|
+
if (Object.prototype.hasOwnProperty.call(fact, 'childTreeId')) {
|
|
555
|
+
canonicalIdentity(dataField(fact, 'childTreeId', `${itemLocation}.childTreeId`), `${itemLocation}.childTreeId`);
|
|
556
|
+
if (primitive !== 'CallFlow') invalid('WORK_CTRL_CHILD_IDENTITY_INVALID', `${itemLocation}.childTreeId`, 'Only CallFlow composites can own a child tree identity.');
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
function closedRecordEntries(value: unknown, location: string): readonly [string, unknown, string][] {
|
|
562
|
+
const record = plainRecord(value, location);
|
|
563
|
+
return stringDataKeys(record, location).sort(compareCodeUnits).map((key) => {
|
|
564
|
+
canonicalIdentity(key, `${location}.${key}`);
|
|
565
|
+
const itemLocation = `${location}.${key}`;
|
|
566
|
+
return [key, dataField(record, key, itemLocation), itemLocation] as const;
|
|
567
|
+
});
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
function assertFactKey(fact: Record<string, unknown>, key: string, location: string): void {
|
|
571
|
+
const invocationKey = canonicalIdentity(dataField(fact, 'invocationKey', `${location}.invocationKey`), `${location}.invocationKey`);
|
|
572
|
+
if (invocationKey !== key) invalid('WORK_CTRL_FACT_IDENTITY_MISMATCH', `${location}.invocationKey`, 'Fact identity must equal its map key.');
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
function positiveSafeInteger(value: unknown, location: string): number {
|
|
576
|
+
const normalized = nonNegativeSafeInteger(value, location);
|
|
577
|
+
if (normalized === 0) invalid('FLOW_CONTRACT_POSITIVE_INTEGER_REQUIRED', location, 'Expected a positive safe integer.');
|
|
578
|
+
return normalized;
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
function normalizeWaitHandles(
|
|
582
|
+
value: unknown,
|
|
583
|
+
treeId: string,
|
|
584
|
+
collection: 'open' | 'closed',
|
|
585
|
+
location: string,
|
|
586
|
+
): readonly { readonly waitHandleId: string; readonly resumeToken: string }[] {
|
|
587
|
+
return closedArray(value, location).map((item, index) => {
|
|
588
|
+
const itemLocation = `${location}[${index}]`;
|
|
589
|
+
const handle = exactRecord(
|
|
590
|
+
item,
|
|
591
|
+
[
|
|
592
|
+
'waitHandleId', 'treeId', 'nodeKey', 'invocationKey', 'waitKind', 'status',
|
|
593
|
+
'signalKind', 'signalKey', 'resumeToken', 'deadlineAtMs',
|
|
594
|
+
],
|
|
595
|
+
['assignTo', 'closedAtMs', 'metadata'],
|
|
596
|
+
itemLocation,
|
|
597
|
+
);
|
|
598
|
+
const waitHandleId = canonicalIdentity(
|
|
599
|
+
dataField(handle, 'waitHandleId', `${itemLocation}.waitHandleId`),
|
|
600
|
+
`${itemLocation}.waitHandleId`,
|
|
601
|
+
);
|
|
602
|
+
const ownerTreeId = canonicalIdentity(dataField(handle, 'treeId', `${itemLocation}.treeId`), `${itemLocation}.treeId`);
|
|
603
|
+
if (ownerTreeId !== treeId) {
|
|
604
|
+
invalid('FLOW_RUN_PROFILE_IDENTITY_MISMATCH', `${itemLocation}.treeId`, 'Wait handle owner must equal the snapshot tree identity.');
|
|
605
|
+
}
|
|
606
|
+
for (const field of ['nodeKey', 'invocationKey', 'signalKind', 'signalKey'] as const) {
|
|
607
|
+
canonicalIdentity(dataField(handle, field, `${itemLocation}.${field}`), `${itemLocation}.${field}`);
|
|
608
|
+
}
|
|
609
|
+
const resumeToken = canonicalIdentity(dataField(handle, 'resumeToken', `${itemLocation}.resumeToken`), `${itemLocation}.resumeToken`);
|
|
610
|
+
const waitKind = dataField(handle, 'waitKind', `${itemLocation}.waitKind`);
|
|
611
|
+
if (waitKind !== 'external-job' && waitKind !== 'timer' && waitKind !== 'task-step') {
|
|
612
|
+
invalid('WORK_CTRL_WAIT_KIND_INVALID', `${itemLocation}.waitKind`, 'Wait kind is not part of the public contract.');
|
|
613
|
+
}
|
|
614
|
+
const status = dataField(handle, 'status', `${itemLocation}.status`);
|
|
615
|
+
if (collection === 'open' ? status !== 'Open' : !['Consumed', 'Expired', 'Cancelled'].includes(String(status))) {
|
|
616
|
+
invalid('WORK_CTRL_WAIT_STATUS_INVALID', `${itemLocation}.status`, `Wait handle status does not belong in the ${collection} collection.`);
|
|
617
|
+
}
|
|
618
|
+
nonNegativeSafeInteger(dataField(handle, 'deadlineAtMs', `${itemLocation}.deadlineAtMs`), `${itemLocation}.deadlineAtMs`);
|
|
619
|
+
if (Object.prototype.hasOwnProperty.call(handle, 'assignTo')) {
|
|
620
|
+
canonicalIdentity(dataField(handle, 'assignTo', `${itemLocation}.assignTo`), `${itemLocation}.assignTo`);
|
|
621
|
+
}
|
|
622
|
+
if (Object.prototype.hasOwnProperty.call(handle, 'closedAtMs')) {
|
|
623
|
+
nonNegativeSafeInteger(dataField(handle, 'closedAtMs', `${itemLocation}.closedAtMs`), `${itemLocation}.closedAtMs`);
|
|
624
|
+
if (collection === 'open') {
|
|
625
|
+
invalid('WORK_CTRL_OPEN_WAIT_HAS_CLOSED_AT', `${itemLocation}.closedAtMs`, 'Open wait handles cannot carry a close time.');
|
|
626
|
+
}
|
|
627
|
+
} else if (collection === 'closed') {
|
|
628
|
+
invalid('WORK_CTRL_WAIT_CLOSED_AT_MISSING', `${itemLocation}.closedAtMs`, 'Closed wait handles require their close time.');
|
|
629
|
+
}
|
|
630
|
+
if (Object.prototype.hasOwnProperty.call(handle, 'metadata')) {
|
|
631
|
+
plainRecord(dataField(handle, 'metadata', `${itemLocation}.metadata`), `${itemLocation}.metadata`);
|
|
632
|
+
}
|
|
633
|
+
return { waitHandleId, resumeToken };
|
|
634
|
+
});
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
function normalizeAIDataRunGraph(value: unknown, runId: string, location: string): FlowClosedObject {
|
|
638
|
+
const graph = cloneClosedObject(value, location);
|
|
639
|
+
const record = exactRecord(
|
|
640
|
+
graph,
|
|
641
|
+
['runId', 'binding', 'currentGeneration', 'nodes', 'declarationOrder', 'patchHistory', 'invalidations'],
|
|
642
|
+
['platformDefaultPolicy'],
|
|
643
|
+
location,
|
|
644
|
+
);
|
|
645
|
+
let platformDefaultPolicy: 'semantic-hash' | 'never' | undefined;
|
|
646
|
+
if (Object.prototype.hasOwnProperty.call(record, 'platformDefaultPolicy')) {
|
|
647
|
+
const policy = dataField(record, 'platformDefaultPolicy', `${location}.platformDefaultPolicy`);
|
|
648
|
+
if (policy !== 'semantic-hash' && policy !== 'never') {
|
|
649
|
+
invalid('AI_DATA_PLATFORM_REUSE_POLICY_INVALID', `${location}.platformDefaultPolicy`, 'Platform default policy must be semantic-hash or never.');
|
|
650
|
+
}
|
|
651
|
+
platformDefaultPolicy = policy;
|
|
652
|
+
}
|
|
653
|
+
const graphRunId = canonicalIdentity(dataField(record, 'runId', `${location}.runId`), `${location}.runId`);
|
|
654
|
+
if (graphRunId !== runId) {
|
|
655
|
+
invalid('FLOW_RUN_PROFILE_IDENTITY_MISMATCH', `${location}.runId`, 'Run graph identity must equal the checkpoint run identity.');
|
|
656
|
+
}
|
|
657
|
+
const currentGeneration = nonNegativeSafeInteger(
|
|
658
|
+
dataField(record, 'currentGeneration', `${location}.currentGeneration`),
|
|
659
|
+
`${location}.currentGeneration`,
|
|
660
|
+
);
|
|
661
|
+
const bindingLocation = `${location}.binding`;
|
|
662
|
+
const binding = exactRecord(
|
|
663
|
+
dataField(record, 'binding', bindingLocation),
|
|
664
|
+
['kind', 'substrate', 'definition'],
|
|
665
|
+
['descriptor'],
|
|
666
|
+
bindingLocation,
|
|
667
|
+
);
|
|
668
|
+
if (dataField(binding, 'kind', `${bindingLocation}.kind`) !== 'AIDataWorkflow'
|
|
669
|
+
|| dataField(binding, 'substrate', `${bindingLocation}.substrate`) !== 'EagerDataFlow') {
|
|
670
|
+
invalid('AI_DATA_BINDING_INVALID', bindingLocation, 'Expected the AIDataWorkflow/EagerDataFlow binding.');
|
|
671
|
+
}
|
|
672
|
+
if (Object.prototype.hasOwnProperty.call(binding, 'descriptor')) {
|
|
673
|
+
plainRecord(dataField(binding, 'descriptor', `${bindingLocation}.descriptor`), `${bindingLocation}.descriptor`);
|
|
674
|
+
}
|
|
675
|
+
normalizeAIDataDescriptor(binding, bindingLocation);
|
|
676
|
+
const bindingDefinition = plainRecord(
|
|
677
|
+
dataField(binding, 'definition', `${bindingLocation}.definition`),
|
|
678
|
+
`${bindingLocation}.definition`,
|
|
679
|
+
);
|
|
680
|
+
normalizeEagerDataDefinition(bindingDefinition, `${bindingLocation}.definition`);
|
|
681
|
+
validateEagerDataDefinitionReferences(bindingDefinition, `${bindingLocation}.definition`);
|
|
682
|
+
|
|
683
|
+
const nodesLocation = `${location}.nodes`;
|
|
684
|
+
const nodes = plainRecord(dataField(record, 'nodes', nodesLocation), nodesLocation);
|
|
685
|
+
const nodeIds = stringDataKeys(nodes, nodesLocation).sort(compareCodeUnits);
|
|
686
|
+
const definitionNodeIds = uniqueIdentityArray(
|
|
687
|
+
dataField(bindingDefinition, 'declarationOrder', `${bindingLocation}.definition.declarationOrder`),
|
|
688
|
+
`${bindingLocation}.definition.declarationOrder`,
|
|
689
|
+
);
|
|
690
|
+
for (const definitionNodeId of definitionNodeIds) {
|
|
691
|
+
if (!Object.prototype.hasOwnProperty.call(nodes, definitionNodeId)) {
|
|
692
|
+
invalid('AI_DATA_DEFINITION_NODE_MISSING', nodesLocation, `Definition node ${definitionNodeId} must remain in the durable RunGraph.`);
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
const declarationOrder = uniqueIdentityArray(
|
|
696
|
+
dataField(record, 'declarationOrder', `${location}.declarationOrder`),
|
|
697
|
+
`${location}.declarationOrder`,
|
|
698
|
+
);
|
|
699
|
+
if (nodeIds.length !== declarationOrder.length
|
|
700
|
+
|| nodeIds.some((nodeId) => !declarationOrder.includes(nodeId))) {
|
|
701
|
+
invalid('AI_DATA_DECLARATION_ORDER_INVALID', `${location}.declarationOrder`, 'Declaration order must contain every run node exactly once.');
|
|
702
|
+
}
|
|
703
|
+
const nodeInvalidationFacts = new Map<string, string[]>();
|
|
704
|
+
const nodeGenerations = new Map<string, number>();
|
|
705
|
+
const nodeStatuses = new Map<string, string>();
|
|
706
|
+
const nodeResultGenerations: number[] = [];
|
|
707
|
+
for (const nodeId of nodeIds) {
|
|
708
|
+
canonicalIdentity(nodeId, `${nodesLocation}.${nodeId}`);
|
|
709
|
+
const nodeLocation = `${nodesLocation}.${nodeId}`;
|
|
710
|
+
const node = exactRecord(
|
|
711
|
+
dataField(nodes, nodeId, nodeLocation),
|
|
712
|
+
[
|
|
713
|
+
'id', 'tag', 'nodeType', 'generation', 'status', 'declarationIndex', 'inputs',
|
|
714
|
+
'outputs', 'config', 'dependsOn', 'dependents', 'reusePolicy',
|
|
715
|
+
],
|
|
716
|
+
['semanticFingerprint', 'result', 'invalidatedBy'],
|
|
717
|
+
nodeLocation,
|
|
718
|
+
);
|
|
719
|
+
const storedId = canonicalIdentity(dataField(node, 'id', `${nodeLocation}.id`), `${nodeLocation}.id`);
|
|
720
|
+
if (storedId !== nodeId) {
|
|
721
|
+
invalid('AI_DATA_NODE_IDENTITY_MISMATCH', `${nodeLocation}.id`, 'Node identity must equal its map key.');
|
|
722
|
+
}
|
|
723
|
+
const generation = nonNegativeSafeInteger(dataField(node, 'generation', `${nodeLocation}.generation`), `${nodeLocation}.generation`);
|
|
724
|
+
nodeGenerations.set(nodeId, generation);
|
|
725
|
+
if (generation > currentGeneration) {
|
|
726
|
+
invalid('AI_DATA_NODE_GENERATION_INVALID', `${nodeLocation}.generation`, 'Node generation cannot exceed graph generation.');
|
|
727
|
+
}
|
|
728
|
+
const status = dataField(node, 'status', `${nodeLocation}.status`);
|
|
729
|
+
nodeStatuses.set(nodeId, String(status));
|
|
730
|
+
if (!['Pending', 'Ready', 'Running', 'Succeeded', 'Failed', 'Invalidated', 'Reused', 'Removed'].includes(String(status))) {
|
|
731
|
+
invalid('AI_DATA_NODE_STATUS_INVALID', `${nodeLocation}.status`, 'Node status is not part of the durable graph contract.');
|
|
732
|
+
}
|
|
733
|
+
canonicalIdentity(dataField(node, 'tag', `${nodeLocation}.tag`), `${nodeLocation}.tag`);
|
|
734
|
+
const nodeType = canonicalIdentity(dataField(node, 'nodeType', `${nodeLocation}.nodeType`), `${nodeLocation}.nodeType`);
|
|
735
|
+
nonNegativeSafeInteger(dataField(node, 'declarationIndex', `${nodeLocation}.declarationIndex`), `${nodeLocation}.declarationIndex`);
|
|
736
|
+
normalizeAIDataInputs(dataField(node, 'inputs', `${nodeLocation}.inputs`), `${nodeLocation}.inputs`);
|
|
737
|
+
uniqueIdentityArray(dataField(node, 'outputs', `${nodeLocation}.outputs`), `${nodeLocation}.outputs`);
|
|
738
|
+
const config = plainRecord(dataField(node, 'config', `${nodeLocation}.config`), `${nodeLocation}.config`);
|
|
739
|
+
const dependsOn = uniqueIdentityArray(dataField(node, 'dependsOn', `${nodeLocation}.dependsOn`), `${nodeLocation}.dependsOn`);
|
|
740
|
+
const dependents = uniqueIdentityArray(dataField(node, 'dependents', `${nodeLocation}.dependents`), `${nodeLocation}.dependents`);
|
|
741
|
+
normalizeReusePolicy(
|
|
742
|
+
dataField(node, 'reusePolicy', `${nodeLocation}.reusePolicy`),
|
|
743
|
+
nodeType,
|
|
744
|
+
config,
|
|
745
|
+
platformDefaultPolicy,
|
|
746
|
+
`${nodeLocation}.reusePolicy`,
|
|
747
|
+
);
|
|
748
|
+
if (Object.prototype.hasOwnProperty.call(node, 'semanticFingerprint')) {
|
|
749
|
+
normalizeSemanticFingerprint(
|
|
750
|
+
dataField(node, 'semanticFingerprint', `${nodeLocation}.semanticFingerprint`),
|
|
751
|
+
`${nodeLocation}.semanticFingerprint`,
|
|
752
|
+
);
|
|
753
|
+
}
|
|
754
|
+
const hasResult = Object.prototype.hasOwnProperty.call(node, 'result');
|
|
755
|
+
if (['Succeeded', 'Failed', 'Reused'].includes(String(status)) && !hasResult) {
|
|
756
|
+
invalid('AI_DATA_TERMINAL_RESULT_MISSING', `${nodeLocation}.result`, 'Terminal execution nodes require a durable result.');
|
|
757
|
+
}
|
|
758
|
+
if (hasResult) {
|
|
759
|
+
nodeResultGenerations.push(generation);
|
|
760
|
+
const resultValue = dataField(node, 'result', `${nodeLocation}.result`);
|
|
761
|
+
normalizeAIDataNodeResult(
|
|
762
|
+
resultValue,
|
|
763
|
+
nodeId,
|
|
764
|
+
generation,
|
|
765
|
+
String(status),
|
|
766
|
+
Object.prototype.hasOwnProperty.call(node, 'semanticFingerprint')
|
|
767
|
+
? dataField(node, 'semanticFingerprint', `${nodeLocation}.semanticFingerprint`)
|
|
768
|
+
: undefined,
|
|
769
|
+
`${nodeLocation}.result`,
|
|
770
|
+
);
|
|
771
|
+
normalizeAIDataResultOutput(resultValue, node, String(status), bindingDefinition, `${nodeLocation}.result`);
|
|
772
|
+
}
|
|
773
|
+
const invalidationFacts: string[] = [];
|
|
774
|
+
if (Object.prototype.hasOwnProperty.call(node, 'invalidatedBy')) {
|
|
775
|
+
for (const [index, invalidation] of closedArray(
|
|
776
|
+
dataField(node, 'invalidatedBy', `${nodeLocation}.invalidatedBy`),
|
|
777
|
+
`${nodeLocation}.invalidatedBy`,
|
|
778
|
+
).entries()) {
|
|
779
|
+
const normalized = normalizeAIDataInvalidation(invalidation, currentGeneration, `${nodeLocation}.invalidatedBy[${index}]`);
|
|
780
|
+
if (normalized.nodeId !== nodeId) {
|
|
781
|
+
invalid('AI_DATA_INVALIDATION_TARGET_MISMATCH', `${nodeLocation}.invalidatedBy[${index}].nodeId`, 'Node invalidation facts must identify their owning node.');
|
|
782
|
+
}
|
|
783
|
+
invalidationFacts.push(normalized.identity);
|
|
784
|
+
}
|
|
785
|
+
}
|
|
786
|
+
nodeInvalidationFacts.set(nodeId, invalidationFacts);
|
|
787
|
+
for (const dependency of dependsOn) {
|
|
788
|
+
if (dependency === nodeId || !Object.prototype.hasOwnProperty.call(nodes, dependency)) {
|
|
789
|
+
invalid('AI_DATA_NODE_DEPENDENCY_INVALID', `${nodeLocation}.dependsOn`, 'Dependencies must reference another run node.');
|
|
790
|
+
}
|
|
791
|
+
}
|
|
792
|
+
for (const dependent of dependents) {
|
|
793
|
+
if (dependent === nodeId || !Object.prototype.hasOwnProperty.call(nodes, dependent)) {
|
|
794
|
+
invalid('AI_DATA_NODE_DEPENDENT_INVALID', `${nodeLocation}.dependents`, 'Dependents must reference another run node.');
|
|
795
|
+
}
|
|
796
|
+
}
|
|
797
|
+
if (generation === 0) {
|
|
798
|
+
normalizeInitialAIDataNodeProjection(node, nodeId, bindingDefinition, nodeLocation);
|
|
799
|
+
}
|
|
800
|
+
}
|
|
801
|
+
normalizeAIDataDependencyProjection(nodes, declarationOrder, nodesLocation);
|
|
802
|
+
normalizeAIDataInputReferenceProjection(nodes, declarationOrder, nodesLocation);
|
|
803
|
+
const patchIds = new Set<string>();
|
|
804
|
+
let previousPatchGeneration = 0;
|
|
805
|
+
let maximumHistoryGeneration = Math.max(0, ...nodeResultGenerations);
|
|
806
|
+
const removedNodeGenerations = new Map<string, number>();
|
|
807
|
+
const seenRemovedNodeIds = new Set<string>();
|
|
808
|
+
for (const [index, patch] of closedArray(
|
|
809
|
+
dataField(record, 'patchHistory', `${location}.patchHistory`),
|
|
810
|
+
`${location}.patchHistory`,
|
|
811
|
+
).entries()) {
|
|
812
|
+
const normalized = normalizeAIDataPatchRecord(patch, currentGeneration, `${location}.patchHistory[${index}]`);
|
|
813
|
+
const { patchId } = normalized;
|
|
814
|
+
if (normalized.generation < previousPatchGeneration) {
|
|
815
|
+
invalid('AI_DATA_PATCH_GENERATION_ROLLBACK', `${location}.patchHistory[${index}].generation`, 'Patch generations must be append-monotonic.');
|
|
816
|
+
}
|
|
817
|
+
previousPatchGeneration = normalized.generation;
|
|
818
|
+
maximumHistoryGeneration = Math.max(maximumHistoryGeneration, normalized.generation);
|
|
819
|
+
for (const targetNodeId of normalized.targetNodeIds) {
|
|
820
|
+
if ((nodeGenerations.get(targetNodeId) ?? -1) < normalized.generation) {
|
|
821
|
+
invalid('AI_DATA_PATCH_TARGET_GENERATION_INVALID', `${location}.patchHistory[${index}]`, 'Patch target generation cannot precede its patch fact.');
|
|
822
|
+
}
|
|
823
|
+
}
|
|
824
|
+
for (const removedNodeId of normalized.removedNodeIds) {
|
|
825
|
+
if (seenRemovedNodeIds.has(removedNodeId)) {
|
|
826
|
+
invalid('AI_DATA_NODE_REMOVE_REPEATED', `${location}.patchHistory[${index}]`, 'A Removed tombstone cannot be removed again.');
|
|
827
|
+
}
|
|
828
|
+
seenRemovedNodeIds.add(removedNodeId);
|
|
829
|
+
if (nodeStatuses.get(removedNodeId) !== 'Removed') {
|
|
830
|
+
invalid('AI_DATA_REMOVED_NODE_STATUS_INVALID', `${location}.patchHistory[${index}]`, 'A remove operation must retain its target as a Removed node tombstone.');
|
|
831
|
+
}
|
|
832
|
+
removedNodeGenerations.set(removedNodeId, normalized.generation);
|
|
833
|
+
}
|
|
834
|
+
if (patchIds.has(patchId)) {
|
|
835
|
+
invalid('AI_DATA_PATCH_ID_DUPLICATE', `${location}.patchHistory[${index}].patchId`, 'Patch identities must be unique.');
|
|
836
|
+
}
|
|
837
|
+
patchIds.add(patchId);
|
|
838
|
+
}
|
|
839
|
+
const graphInvalidationFacts: string[] = [];
|
|
840
|
+
let previousInvalidationGeneration = 0;
|
|
841
|
+
for (const [index, invalidation] of closedArray(
|
|
842
|
+
dataField(record, 'invalidations', `${location}.invalidations`),
|
|
843
|
+
`${location}.invalidations`,
|
|
844
|
+
).entries()) {
|
|
845
|
+
const normalized = normalizeAIDataInvalidation(invalidation, currentGeneration, `${location}.invalidations[${index}]`);
|
|
846
|
+
if (normalized.generation < previousInvalidationGeneration) {
|
|
847
|
+
invalid('AI_DATA_INVALIDATION_GENERATION_ROLLBACK', `${location}.invalidations[${index}].generation`, 'Invalidation generations must be append-monotonic.');
|
|
848
|
+
}
|
|
849
|
+
previousInvalidationGeneration = normalized.generation;
|
|
850
|
+
maximumHistoryGeneration = Math.max(maximumHistoryGeneration, normalized.generation);
|
|
851
|
+
if (!Object.prototype.hasOwnProperty.call(nodes, normalized.nodeId)
|
|
852
|
+
|| !Object.prototype.hasOwnProperty.call(nodes, normalized.sourceNodeId)) {
|
|
853
|
+
invalid('AI_DATA_INVALIDATION_ENDPOINT_INVALID', `${location}.invalidations[${index}]`, 'Invalidation endpoints must reference run graph nodes.');
|
|
854
|
+
}
|
|
855
|
+
if ((nodeGenerations.get(normalized.nodeId) ?? -1) < normalized.generation) {
|
|
856
|
+
invalid('AI_DATA_INVALIDATION_TARGET_GENERATION_INVALID', `${location}.invalidations[${index}]`, 'Invalidation target generation cannot precede its invalidation fact.');
|
|
857
|
+
}
|
|
858
|
+
const targetLocation = `${nodesLocation}.${normalized.nodeId}`;
|
|
859
|
+
const target = plainRecord(dataField(nodes, normalized.nodeId, targetLocation), targetLocation);
|
|
860
|
+
if (nodeStatuses.get(normalized.nodeId) !== 'Invalidated'
|
|
861
|
+
|| nodeGenerations.get(normalized.nodeId) !== normalized.generation
|
|
862
|
+
|| Object.prototype.hasOwnProperty.call(target, 'result')
|
|
863
|
+
|| Object.prototype.hasOwnProperty.call(target, 'semanticFingerprint')) {
|
|
864
|
+
invalid('AI_DATA_INVALIDATION_TARGET_PROJECTION_INVALID', `${location}.invalidations[${index}]`, 'Invalidation facts require an exact Invalidated target at the same generation without a result or fingerprint.');
|
|
865
|
+
}
|
|
866
|
+
graphInvalidationFacts.push(normalized.identity);
|
|
867
|
+
}
|
|
868
|
+
if (new Set(graphInvalidationFacts).size !== graphInvalidationFacts.length) {
|
|
869
|
+
invalid('AI_DATA_INVALIDATION_DUPLICATE', `${location}.invalidations`, 'Graph invalidation facts must be unique.');
|
|
870
|
+
}
|
|
871
|
+
const projectedInvalidationFacts = declarationOrder.flatMap((nodeId) => nodeInvalidationFacts.get(nodeId) ?? []);
|
|
872
|
+
if (graphInvalidationFacts.length !== projectedInvalidationFacts.length
|
|
873
|
+
|| graphInvalidationFacts.some((identity) => !projectedInvalidationFacts.includes(identity))) {
|
|
874
|
+
invalid('AI_DATA_INVALIDATION_PROJECTION_MISMATCH', `${location}.invalidations`, 'Graph invalidations and node invalidatedBy facts must be reciprocal projections.');
|
|
875
|
+
}
|
|
876
|
+
if (maximumHistoryGeneration !== currentGeneration) {
|
|
877
|
+
invalid('AI_DATA_HISTORY_GENERATION_MISMATCH', `${location}.currentGeneration`, 'Current generation must equal the latest patch or invalidation generation.');
|
|
878
|
+
}
|
|
879
|
+
for (const [nodeId, status] of nodeStatuses) {
|
|
880
|
+
if (status === 'Removed' && removedNodeGenerations.get(nodeId) !== nodeGenerations.get(nodeId)) {
|
|
881
|
+
invalid('AI_DATA_REMOVED_NODE_FACT_MISSING', `${nodesLocation}.${nodeId}.status`, 'Removed nodes require a same-generation remove patch fact.');
|
|
882
|
+
}
|
|
883
|
+
}
|
|
884
|
+
return graph;
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
function normalizeAIDataResultOutput(
|
|
888
|
+
value: unknown,
|
|
889
|
+
node: Record<string, unknown>,
|
|
890
|
+
nodeStatus: string,
|
|
891
|
+
definition: Record<string, unknown>,
|
|
892
|
+
location: string,
|
|
893
|
+
): void {
|
|
894
|
+
const result = plainRecord(value, location);
|
|
895
|
+
const hasOutput = Object.prototype.hasOwnProperty.call(result, 'output');
|
|
896
|
+
const tag = String(dataField(node, 'tag', `${location}.nodeTag`));
|
|
897
|
+
if (nodeStatus === 'Failed') {
|
|
898
|
+
if (hasOutput) invalid('AI_DATA_FAILED_RESULT_OUTPUT_INVALID', `${location}.output`, 'Failed node results cannot retain successful output ports.');
|
|
899
|
+
return;
|
|
900
|
+
}
|
|
901
|
+
if (nodeStatus !== 'Succeeded' && nodeStatus !== 'Reused') return;
|
|
902
|
+
if (tag === 'SinkNode') {
|
|
903
|
+
if (hasOutput) invalid('AI_DATA_SINK_RESULT_OUTPUT_INVALID', `${location}.output`, 'Sink node results cannot retain output ports.');
|
|
904
|
+
return;
|
|
905
|
+
}
|
|
906
|
+
if (!hasOutput) {
|
|
907
|
+
invalid('AI_DATA_RESULT_OUTPUT_MISSING', `${location}.output`, 'Successful data-producing nodes require durable output ports.');
|
|
908
|
+
}
|
|
909
|
+
const output = plainRecord(dataField(result, 'output', `${location}.output`), `${location}.output`);
|
|
910
|
+
const expected = tag === 'ReturnNode'
|
|
911
|
+
? identityArray(
|
|
912
|
+
dataField(
|
|
913
|
+
plainRecord(dataField(definition, 'contract', `${location}.definitionContract`), `${location}.definitionContract`),
|
|
914
|
+
'outputPorts',
|
|
915
|
+
`${location}.definitionContract.outputPorts`,
|
|
916
|
+
),
|
|
917
|
+
`${location}.definitionContract.outputPorts`,
|
|
918
|
+
)
|
|
919
|
+
: identityArray(dataField(node, 'outputs', `${location}.nodeOutputs`), `${location}.nodeOutputs`);
|
|
920
|
+
const actual = stringDataKeys(output, `${location}.output`).sort(compareCodeUnits);
|
|
921
|
+
const sortedExpected = [...expected].sort(compareCodeUnits);
|
|
922
|
+
if (JSON.stringify(actual) !== JSON.stringify(sortedExpected)) {
|
|
923
|
+
invalid('AI_DATA_RESULT_OUTPUT_PROJECTION_MISMATCH', `${location}.output`, 'Result output keys must equal the node output port projection.');
|
|
924
|
+
}
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
function normalizeInitialAIDataNodeProjection(
|
|
928
|
+
node: Record<string, unknown>,
|
|
929
|
+
nodeId: string,
|
|
930
|
+
definition: Record<string, unknown>,
|
|
931
|
+
location: string,
|
|
932
|
+
): void {
|
|
933
|
+
const nodeById = plainRecord(dataField(definition, 'nodeById', `${location}.bindingNodeById`), `${location}.bindingNodeById`);
|
|
934
|
+
if (!Object.prototype.hasOwnProperty.call(nodeById, nodeId)) return;
|
|
935
|
+
const definitionNode = plainRecord(dataField(nodeById, nodeId, `${location}.bindingNode`), `${location}.bindingNode`);
|
|
936
|
+
const projected = {
|
|
937
|
+
tag: dataField(definitionNode, 'tag', `${location}.bindingNode.tag`),
|
|
938
|
+
declarationIndex: dataField(definitionNode, 'declarationIndex', `${location}.bindingNode.declarationIndex`),
|
|
939
|
+
inputs: Object.prototype.hasOwnProperty.call(definitionNode, 'inputs')
|
|
940
|
+
? dataField(definitionNode, 'inputs', `${location}.bindingNode.inputs`)
|
|
941
|
+
: {},
|
|
942
|
+
outputs: Object.prototype.hasOwnProperty.call(definitionNode, 'outputs')
|
|
943
|
+
? dataField(definitionNode, 'outputs', `${location}.bindingNode.outputs`)
|
|
944
|
+
: [],
|
|
945
|
+
config: Object.prototype.hasOwnProperty.call(definitionNode, 'config')
|
|
946
|
+
? dataField(definitionNode, 'config', `${location}.bindingNode.config`)
|
|
947
|
+
: {},
|
|
948
|
+
dependsOn: eagerDefinitionDependencies(definition, definitionNode, nodeId, location),
|
|
949
|
+
};
|
|
950
|
+
for (const field of ['tag', 'declarationIndex', 'inputs', 'outputs', 'config', 'dependsOn'] as const) {
|
|
951
|
+
if (JSON.stringify(dataField(node, field, `${location}.${field}`)) !== JSON.stringify(projected[field])) {
|
|
952
|
+
invalid('AI_DATA_NODE_PROJECTION_MISMATCH', `${location}.${field}`, `Initial run node ${field} differs from its binding projection.`);
|
|
953
|
+
}
|
|
954
|
+
}
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
function eagerDefinitionDependencies(
|
|
958
|
+
definition: Record<string, unknown>,
|
|
959
|
+
definitionNode: Record<string, unknown>,
|
|
960
|
+
nodeId: string,
|
|
961
|
+
location: string,
|
|
962
|
+
): readonly string[] {
|
|
963
|
+
const dependencies = new Set<string>();
|
|
964
|
+
const dataEdges = closedArray(dataField(definition, 'dataEdges', `${location}.bindingDataEdges`), `${location}.bindingDataEdges`);
|
|
965
|
+
const waitEdges = closedArray(dataField(definition, 'waitForEdges', `${location}.bindingWaitEdges`), `${location}.bindingWaitEdges`);
|
|
966
|
+
for (const edgeValue of [...dataEdges, ...waitEdges]) {
|
|
967
|
+
const edge = plainRecord(edgeValue, `${location}.bindingEdge`);
|
|
968
|
+
if (dataField(edge, 'toNodeId', `${location}.bindingEdge.toNodeId`) === nodeId) {
|
|
969
|
+
dependencies.add(canonicalIdentity(dataField(edge, 'fromNodeId', `${location}.bindingEdge.fromNodeId`), `${location}.bindingEdge.fromNodeId`));
|
|
970
|
+
}
|
|
971
|
+
}
|
|
972
|
+
for (const referenceValue of closedArray(dataField(definitionNode, 'waitFor', `${location}.bindingNode.waitFor`), `${location}.bindingNode.waitFor`)) {
|
|
973
|
+
const reference = plainRecord(referenceValue, `${location}.bindingNode.waitFor`);
|
|
974
|
+
dependencies.add(canonicalIdentity(dataField(reference, 'nodeId', `${location}.bindingNode.waitFor.nodeId`), `${location}.bindingNode.waitFor.nodeId`));
|
|
975
|
+
}
|
|
976
|
+
if (Object.prototype.hasOwnProperty.call(definitionNode, 'inputs')) {
|
|
977
|
+
const inputs = plainRecord(dataField(definitionNode, 'inputs', `${location}.bindingNode.inputs`), `${location}.bindingNode.inputs`);
|
|
978
|
+
for (const key of stringDataKeys(inputs, `${location}.bindingNode.inputs`).sort(compareCodeUnits)) {
|
|
979
|
+
const reference = plainRecord(dataField(inputs, key, `${location}.bindingNode.inputs.${key}`), `${location}.bindingNode.inputs.${key}`);
|
|
980
|
+
dependencies.add(canonicalIdentity(dataField(reference, 'nodeId', `${location}.bindingNode.inputs.${key}.nodeId`), `${location}.bindingNode.inputs.${key}.nodeId`));
|
|
981
|
+
}
|
|
982
|
+
}
|
|
983
|
+
return [...dependencies];
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
function normalizeAIDataDependencyProjection(
|
|
987
|
+
nodes: Record<string, unknown>,
|
|
988
|
+
declarationOrder: readonly string[],
|
|
989
|
+
location: string,
|
|
990
|
+
): void {
|
|
991
|
+
const dependencies = new Map<string, readonly string[]>();
|
|
992
|
+
for (const nodeId of declarationOrder) {
|
|
993
|
+
const node = plainRecord(dataField(nodes, nodeId, `${location}.${nodeId}`), `${location}.${nodeId}`);
|
|
994
|
+
dependencies.set(nodeId, identityArray(dataField(node, 'dependsOn', `${location}.${nodeId}.dependsOn`), `${location}.${nodeId}.dependsOn`));
|
|
995
|
+
}
|
|
996
|
+
for (const nodeId of declarationOrder) {
|
|
997
|
+
const node = plainRecord(dataField(nodes, nodeId, `${location}.${nodeId}`), `${location}.${nodeId}`);
|
|
998
|
+
const actual = identityArray(dataField(node, 'dependents', `${location}.${nodeId}.dependents`), `${location}.${nodeId}.dependents`);
|
|
999
|
+
for (const dependency of dependencies.get(nodeId) ?? []) {
|
|
1000
|
+
const source = plainRecord(dataField(nodes, dependency, `${location}.${dependency}`), `${location}.${dependency}`);
|
|
1001
|
+
if (dataField(source, 'status', `${location}.${dependency}.status`) === 'Removed') {
|
|
1002
|
+
invalid('AI_DATA_REMOVED_NODE_REFERENCED', `${location}.${nodeId}.dependsOn`, 'A Removed node cannot remain a dependency.');
|
|
1003
|
+
}
|
|
1004
|
+
}
|
|
1005
|
+
const expected = declarationOrder.filter((candidate) => dependencies.get(candidate)?.includes(nodeId));
|
|
1006
|
+
if (JSON.stringify(actual) !== JSON.stringify(expected)) {
|
|
1007
|
+
invalid('AI_DATA_DEPENDENT_PROJECTION_MISMATCH', `${location}.${nodeId}.dependents`, 'Dependents must be the declaration-ordered inverse dependency projection.');
|
|
1008
|
+
}
|
|
1009
|
+
}
|
|
1010
|
+
}
|
|
1011
|
+
|
|
1012
|
+
function normalizeAIDataInputReferenceProjection(
|
|
1013
|
+
nodes: Record<string, unknown>,
|
|
1014
|
+
declarationOrder: readonly string[],
|
|
1015
|
+
location: string,
|
|
1016
|
+
): void {
|
|
1017
|
+
for (const nodeId of declarationOrder) {
|
|
1018
|
+
const nodeLocation = `${location}.${nodeId}`;
|
|
1019
|
+
const node = plainRecord(dataField(nodes, nodeId, nodeLocation), nodeLocation);
|
|
1020
|
+
const inputs = plainRecord(dataField(node, 'inputs', `${nodeLocation}.inputs`), `${nodeLocation}.inputs`);
|
|
1021
|
+
const dependsOn = identityArray(dataField(node, 'dependsOn', `${nodeLocation}.dependsOn`), `${nodeLocation}.dependsOn`);
|
|
1022
|
+
for (const inputName of stringDataKeys(inputs, `${nodeLocation}.inputs`).sort(compareCodeUnits)) {
|
|
1023
|
+
const input = dataField(inputs, inputName, `${nodeLocation}.inputs.${inputName}`);
|
|
1024
|
+
if (typeof input !== 'object' || input === null || Array.isArray(input)) continue;
|
|
1025
|
+
const reference = plainRecord(input, `${nodeLocation}.inputs.${inputName}`);
|
|
1026
|
+
const fields = stringDataKeys(reference, `${nodeLocation}.inputs.${inputName}`);
|
|
1027
|
+
if (!fields.some((field) => field === 'ref' || field === 'nodeId' || field === 'port')) continue;
|
|
1028
|
+
const sourceNodeId = canonicalIdentity(dataField(reference, 'nodeId', `${nodeLocation}.inputs.${inputName}.nodeId`), `${nodeLocation}.inputs.${inputName}.nodeId`);
|
|
1029
|
+
const sourcePort = canonicalIdentity(dataField(reference, 'port', `${nodeLocation}.inputs.${inputName}.port`), `${nodeLocation}.inputs.${inputName}.port`);
|
|
1030
|
+
if (dataField(reference, 'ref', `${nodeLocation}.inputs.${inputName}.ref`) !== `flow-port://#${sourceNodeId}/${sourcePort}`) {
|
|
1031
|
+
invalid('AI_DATA_INPUT_REF_IDENTITY_MISMATCH', `${nodeLocation}.inputs.${inputName}.ref`, 'Input ref must exactly project its source node and port.');
|
|
1032
|
+
}
|
|
1033
|
+
if (!Object.prototype.hasOwnProperty.call(nodes, sourceNodeId)) {
|
|
1034
|
+
invalid('AI_DATA_INPUT_SOURCE_MISSING', `${nodeLocation}.inputs.${inputName}.nodeId`, 'Input source node must exist in the RunGraph.');
|
|
1035
|
+
}
|
|
1036
|
+
const source = plainRecord(dataField(nodes, sourceNodeId, `${location}.${sourceNodeId}`), `${location}.${sourceNodeId}`);
|
|
1037
|
+
if (dataField(source, 'status', `${location}.${sourceNodeId}.status`) === 'Removed') {
|
|
1038
|
+
invalid('AI_DATA_REMOVED_NODE_REFERENCED', `${nodeLocation}.inputs.${inputName}`, 'A Removed node cannot remain an input source.');
|
|
1039
|
+
}
|
|
1040
|
+
const outputs = identityArray(dataField(source, 'outputs', `${location}.${sourceNodeId}.outputs`), `${location}.${sourceNodeId}.outputs`);
|
|
1041
|
+
if (!outputs.includes(sourcePort)) {
|
|
1042
|
+
invalid('AI_DATA_INPUT_SOURCE_PORT_MISSING', `${nodeLocation}.inputs.${inputName}.port`, 'Input source port must be declared by its source node.');
|
|
1043
|
+
}
|
|
1044
|
+
if (!dependsOn.includes(sourceNodeId)) {
|
|
1045
|
+
invalid('AI_DATA_INPUT_DEPENDENCY_MISMATCH', `${nodeLocation}.dependsOn`, 'Every input source must be projected by dependsOn.');
|
|
1046
|
+
}
|
|
1047
|
+
}
|
|
1048
|
+
}
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
function normalizeAIDataDescriptor(binding: Record<string, unknown>, location: string): void {
|
|
1052
|
+
if (!Object.prototype.hasOwnProperty.call(binding, 'descriptor')) return;
|
|
1053
|
+
const descriptorLocation = `${location}.descriptor`;
|
|
1054
|
+
const descriptor = exactRecord(
|
|
1055
|
+
dataField(binding, 'descriptor', descriptorLocation),
|
|
1056
|
+
['kind', 'substrate', 'defaultReusePolicy', 'manualNodeDefaultReusePolicy'],
|
|
1057
|
+
[],
|
|
1058
|
+
descriptorLocation,
|
|
1059
|
+
);
|
|
1060
|
+
if (dataField(descriptor, 'kind', `${descriptorLocation}.kind`) !== 'AIDataWorkflow'
|
|
1061
|
+
|| dataField(descriptor, 'substrate', `${descriptorLocation}.substrate`) !== 'EagerDataFlow'
|
|
1062
|
+
|| !['semantic-hash', 'never'].includes(String(dataField(descriptor, 'defaultReusePolicy', `${descriptorLocation}.defaultReusePolicy`)))
|
|
1063
|
+
|| dataField(descriptor, 'manualNodeDefaultReusePolicy', `${descriptorLocation}.manualNodeDefaultReusePolicy`) !== 'never') {
|
|
1064
|
+
invalid('AI_DATA_DESCRIPTOR_INVALID', descriptorLocation, 'AI Data descriptor fields do not match the public profile contract.');
|
|
1065
|
+
}
|
|
1066
|
+
}
|
|
1067
|
+
|
|
1068
|
+
function normalizeAIDataInputs(value: unknown, location: string): void {
|
|
1069
|
+
const inputs = plainRecord(value, location);
|
|
1070
|
+
for (const name of stringDataKeys(inputs, location).sort(compareCodeUnits)) {
|
|
1071
|
+
canonicalIdentity(name, `${location}.${name}`);
|
|
1072
|
+
const input = dataField(inputs, name, `${location}.${name}`);
|
|
1073
|
+
if (typeof input !== 'object' || input === null || Array.isArray(input)) continue;
|
|
1074
|
+
const record = plainRecord(input, `${location}.${name}`);
|
|
1075
|
+
const fields = stringDataKeys(record, `${location}.${name}`);
|
|
1076
|
+
if (fields.some((field) => field === 'ref' || field === 'nodeId' || field === 'port')) {
|
|
1077
|
+
normalizeFlowPortReference(record, `${location}.${name}`);
|
|
1078
|
+
}
|
|
1079
|
+
}
|
|
1080
|
+
}
|
|
1081
|
+
|
|
1082
|
+
function normalizeFlowPortReference(value: unknown, location: string): void {
|
|
1083
|
+
const reference = exactRecord(value, ['ref', 'nodeId', 'port'], [], location);
|
|
1084
|
+
for (const field of ['ref', 'nodeId', 'port'] as const) {
|
|
1085
|
+
canonicalIdentity(dataField(reference, field, `${location}.${field}`), `${location}.${field}`);
|
|
1086
|
+
}
|
|
1087
|
+
}
|
|
1088
|
+
|
|
1089
|
+
function normalizeFlowNodeReference(value: unknown, location: string): void {
|
|
1090
|
+
const reference = exactRecord(value, ['ref', 'nodeId'], [], location);
|
|
1091
|
+
for (const field of ['ref', 'nodeId'] as const) {
|
|
1092
|
+
canonicalIdentity(dataField(reference, field, `${location}.${field}`), `${location}.${field}`);
|
|
1093
|
+
}
|
|
1094
|
+
}
|
|
1095
|
+
|
|
1096
|
+
function normalizeReusePolicy(
|
|
1097
|
+
value: unknown,
|
|
1098
|
+
nodeType: string,
|
|
1099
|
+
config: Record<string, unknown>,
|
|
1100
|
+
platformDefaultPolicy: 'semantic-hash' | 'never' | undefined,
|
|
1101
|
+
location: string,
|
|
1102
|
+
): void {
|
|
1103
|
+
const policy = exactRecord(value, ['policy', 'source', 'nodeType'], [], location);
|
|
1104
|
+
const policyValue = dataField(policy, 'policy', `${location}.policy`);
|
|
1105
|
+
if (!['semantic-hash', 'never'].includes(String(policyValue))) {
|
|
1106
|
+
invalid('AI_DATA_REUSE_POLICY_INVALID', `${location}.policy`, 'Reuse policy must be semantic-hash or never.');
|
|
1107
|
+
}
|
|
1108
|
+
const source = dataField(policy, 'source', `${location}.source`);
|
|
1109
|
+
if (!['node', 'node-type-default', 'platform-default'].includes(String(source))) {
|
|
1110
|
+
invalid('AI_DATA_REUSE_POLICY_SOURCE_INVALID', `${location}.source`, 'Reuse policy source is not part of the public contract.');
|
|
1111
|
+
}
|
|
1112
|
+
if (canonicalIdentity(dataField(policy, 'nodeType', `${location}.nodeType`), `${location}.nodeType`) !== nodeType) {
|
|
1113
|
+
invalid('AI_DATA_REUSE_POLICY_NODE_TYPE_MISMATCH', `${location}.nodeType`, 'Reuse policy node type must equal its run node.');
|
|
1114
|
+
}
|
|
1115
|
+
const explicit = config.reuse_policy ?? config.reusePolicy;
|
|
1116
|
+
const expected = explicit === 'semantic-hash' || explicit === 'never'
|
|
1117
|
+
? { policy: explicit, source: 'node' }
|
|
1118
|
+
: nodeType === 'manual'
|
|
1119
|
+
? { policy: 'never', source: 'node-type-default' }
|
|
1120
|
+
: platformDefaultPolicy !== undefined
|
|
1121
|
+
? { policy: platformDefaultPolicy, source: 'platform-default' }
|
|
1122
|
+
: { policy: 'semantic-hash', source: 'node-type-default' };
|
|
1123
|
+
if (policyValue !== expected.policy || source !== expected.source) {
|
|
1124
|
+
invalid('AI_DATA_REUSE_POLICY_AUTHORITY_MISMATCH', location, 'Reuse policy must exactly match its node config, node-type default, or declared platform authority.');
|
|
1125
|
+
}
|
|
1126
|
+
}
|
|
1127
|
+
|
|
1128
|
+
function normalizeSemanticFingerprint(value: unknown, location: string): void {
|
|
1129
|
+
const fingerprint = exactRecord(value, ['algorithm', 'value'], ['parts'], location);
|
|
1130
|
+
canonicalIdentity(dataField(fingerprint, 'algorithm', `${location}.algorithm`), `${location}.algorithm`);
|
|
1131
|
+
canonicalIdentity(dataField(fingerprint, 'value', `${location}.value`), `${location}.value`);
|
|
1132
|
+
if (!Object.prototype.hasOwnProperty.call(fingerprint, 'parts')) return;
|
|
1133
|
+
const names = new Set<string>();
|
|
1134
|
+
for (const [index, item] of closedArray(dataField(fingerprint, 'parts', `${location}.parts`), `${location}.parts`).entries()) {
|
|
1135
|
+
const partLocation = `${location}.parts[${index}]`;
|
|
1136
|
+
const part = exactRecord(item, ['name', 'value'], [], partLocation);
|
|
1137
|
+
const name = canonicalIdentity(dataField(part, 'name', `${partLocation}.name`), `${partLocation}.name`);
|
|
1138
|
+
canonicalIdentity(dataField(part, 'value', `${partLocation}.value`), `${partLocation}.value`);
|
|
1139
|
+
if (names.has(name)) invalid('AI_DATA_FINGERPRINT_PART_DUPLICATE', `${partLocation}.name`, 'Fingerprint part names must be unique.');
|
|
1140
|
+
names.add(name);
|
|
1141
|
+
}
|
|
1142
|
+
}
|
|
1143
|
+
|
|
1144
|
+
function normalizeAIDataNodeResult(
|
|
1145
|
+
value: unknown,
|
|
1146
|
+
nodeId: string,
|
|
1147
|
+
nodeGeneration: number,
|
|
1148
|
+
nodeStatus: string,
|
|
1149
|
+
nodeFingerprint: unknown,
|
|
1150
|
+
location: string,
|
|
1151
|
+
): void {
|
|
1152
|
+
const result = exactRecord(
|
|
1153
|
+
value,
|
|
1154
|
+
['nodeId', 'generation', 'status'],
|
|
1155
|
+
['output', 'semanticFingerprint', 'reusedFrom'],
|
|
1156
|
+
location,
|
|
1157
|
+
);
|
|
1158
|
+
if (canonicalIdentity(dataField(result, 'nodeId', `${location}.nodeId`), `${location}.nodeId`) !== nodeId) {
|
|
1159
|
+
invalid('AI_DATA_RESULT_NODE_IDENTITY_MISMATCH', `${location}.nodeId`, 'Result node identity must equal its run node.');
|
|
1160
|
+
}
|
|
1161
|
+
if (nonNegativeSafeInteger(dataField(result, 'generation', `${location}.generation`), `${location}.generation`) !== nodeGeneration) {
|
|
1162
|
+
invalid('AI_DATA_RESULT_GENERATION_INVALID', `${location}.generation`, 'Result generation must equal its run node generation.');
|
|
1163
|
+
}
|
|
1164
|
+
const resultStatus = String(dataField(result, 'status', `${location}.status`));
|
|
1165
|
+
if (!['Pending', 'Running', 'Waiting', 'Succeeded', 'Failed', 'Cancelled', 'Invalidated', 'Reused'].includes(resultStatus)) {
|
|
1166
|
+
invalid('AI_DATA_RESULT_STATUS_INVALID', `${location}.status`, 'Result status is not part of the public contract.');
|
|
1167
|
+
}
|
|
1168
|
+
const projectedStatus = resultStatus === 'Waiting'
|
|
1169
|
+
? 'Running'
|
|
1170
|
+
: resultStatus === 'Cancelled'
|
|
1171
|
+
? 'Failed'
|
|
1172
|
+
: resultStatus;
|
|
1173
|
+
if (nodeStatus !== 'Removed' && projectedStatus !== nodeStatus) {
|
|
1174
|
+
invalid('AI_DATA_RESULT_STATUS_MISMATCH', `${location}.status`, 'Result status must project to its run node status.');
|
|
1175
|
+
}
|
|
1176
|
+
if (Object.prototype.hasOwnProperty.call(result, 'semanticFingerprint')) {
|
|
1177
|
+
const resultFingerprint = dataField(result, 'semanticFingerprint', `${location}.semanticFingerprint`);
|
|
1178
|
+
normalizeSemanticFingerprint(resultFingerprint, `${location}.semanticFingerprint`);
|
|
1179
|
+
if (nodeFingerprint === undefined || JSON.stringify(resultFingerprint) !== JSON.stringify(nodeFingerprint)) {
|
|
1180
|
+
invalid('AI_DATA_RESULT_FINGERPRINT_MISMATCH', `${location}.semanticFingerprint`, 'Result fingerprint must equal its run node fingerprint.');
|
|
1181
|
+
}
|
|
1182
|
+
}
|
|
1183
|
+
if (Object.prototype.hasOwnProperty.call(result, 'reusedFrom')) {
|
|
1184
|
+
const reusedLocation = `${location}.reusedFrom`;
|
|
1185
|
+
const reused = exactRecord(dataField(result, 'reusedFrom', reusedLocation), ['runId', 'generation', 'nodeId'], [], reusedLocation);
|
|
1186
|
+
canonicalIdentity(dataField(reused, 'runId', `${reusedLocation}.runId`), `${reusedLocation}.runId`);
|
|
1187
|
+
nonNegativeSafeInteger(dataField(reused, 'generation', `${reusedLocation}.generation`), `${reusedLocation}.generation`);
|
|
1188
|
+
if (canonicalIdentity(dataField(reused, 'nodeId', `${reusedLocation}.nodeId`), `${reusedLocation}.nodeId`) !== nodeId) {
|
|
1189
|
+
invalid('AI_DATA_REUSED_NODE_IDENTITY_MISMATCH', `${reusedLocation}.nodeId`, 'Reused source node identity must equal its target node identity.');
|
|
1190
|
+
}
|
|
1191
|
+
if (nodeStatus !== 'Reused') {
|
|
1192
|
+
invalid('AI_DATA_REUSED_SOURCE_UNEXPECTED', reusedLocation, 'Only Reused nodes can retain a reusedFrom identity.');
|
|
1193
|
+
}
|
|
1194
|
+
}
|
|
1195
|
+
if (nodeStatus === 'Reused') {
|
|
1196
|
+
if (!Object.prototype.hasOwnProperty.call(result, 'reusedFrom')) {
|
|
1197
|
+
invalid('AI_DATA_REUSED_SOURCE_MISSING', `${location}.reusedFrom`, 'Reused nodes require their source identity.');
|
|
1198
|
+
}
|
|
1199
|
+
if (nodeFingerprint === undefined || !Object.prototype.hasOwnProperty.call(result, 'semanticFingerprint')) {
|
|
1200
|
+
invalid('AI_DATA_REUSED_FINGERPRINT_MISSING', location, 'Reused nodes require matching node and result fingerprints.');
|
|
1201
|
+
}
|
|
1202
|
+
}
|
|
1203
|
+
}
|
|
1204
|
+
|
|
1205
|
+
function normalizeAIDataInvalidation(
|
|
1206
|
+
value: unknown,
|
|
1207
|
+
generation: number,
|
|
1208
|
+
location: string,
|
|
1209
|
+
): { readonly nodeId: string; readonly sourceNodeId: string; readonly generation: number; readonly identity: string } {
|
|
1210
|
+
const invalidation = exactRecord(value, ['nodeId', 'generation', 'reason', 'sourceNodeId'], ['atMs'], location);
|
|
1211
|
+
const nodeId = canonicalIdentity(dataField(invalidation, 'nodeId', `${location}.nodeId`), `${location}.nodeId`);
|
|
1212
|
+
const itemGeneration = nonNegativeSafeInteger(dataField(invalidation, 'generation', `${location}.generation`), `${location}.generation`);
|
|
1213
|
+
if (itemGeneration > generation) invalid('AI_DATA_INVALIDATION_GENERATION_INVALID', `${location}.generation`, 'Invalidation generation cannot exceed graph generation.');
|
|
1214
|
+
canonicalIdentity(dataField(invalidation, 'reason', `${location}.reason`), `${location}.reason`);
|
|
1215
|
+
const sourceNodeId = canonicalIdentity(dataField(invalidation, 'sourceNodeId', `${location}.sourceNodeId`), `${location}.sourceNodeId`);
|
|
1216
|
+
if (Object.prototype.hasOwnProperty.call(invalidation, 'atMs')) {
|
|
1217
|
+
nonNegativeSafeInteger(dataField(invalidation, 'atMs', `${location}.atMs`), `${location}.atMs`);
|
|
1218
|
+
}
|
|
1219
|
+
return { nodeId, sourceNodeId, generation: itemGeneration, identity: JSON.stringify(invalidation) };
|
|
1220
|
+
}
|
|
1221
|
+
|
|
1222
|
+
function normalizeAIDataPatchRecord(
|
|
1223
|
+
value: unknown,
|
|
1224
|
+
generation: number,
|
|
1225
|
+
location: string,
|
|
1226
|
+
): {
|
|
1227
|
+
readonly patchId: string;
|
|
1228
|
+
readonly generation: number;
|
|
1229
|
+
readonly targetNodeIds: readonly string[];
|
|
1230
|
+
readonly removedNodeIds: readonly string[];
|
|
1231
|
+
} {
|
|
1232
|
+
const patch = exactRecord(value, ['patchId', 'generation', 'operations'], ['reason', 'atMs'], location);
|
|
1233
|
+
const patchId = canonicalIdentity(dataField(patch, 'patchId', `${location}.patchId`), `${location}.patchId`);
|
|
1234
|
+
const patchGeneration = nonNegativeSafeInteger(dataField(patch, 'generation', `${location}.generation`), `${location}.generation`);
|
|
1235
|
+
if (patchGeneration === 0 || patchGeneration > generation) {
|
|
1236
|
+
invalid('AI_DATA_PATCH_GENERATION_INVALID', `${location}.generation`, 'Patch generation must be positive and cannot exceed graph generation.');
|
|
1237
|
+
}
|
|
1238
|
+
if (Object.prototype.hasOwnProperty.call(patch, 'reason')) canonicalIdentity(dataField(patch, 'reason', `${location}.reason`), `${location}.reason`);
|
|
1239
|
+
if (Object.prototype.hasOwnProperty.call(patch, 'atMs')) nonNegativeSafeInteger(dataField(patch, 'atMs', `${location}.atMs`), `${location}.atMs`);
|
|
1240
|
+
const targetNodeIds: string[] = [];
|
|
1241
|
+
const targetSet = new Set<string>();
|
|
1242
|
+
const removedNodeIds: string[] = [];
|
|
1243
|
+
for (const [index, operation] of closedArray(dataField(patch, 'operations', `${location}.operations`), `${location}.operations`).entries()) {
|
|
1244
|
+
const normalized = normalizeAIDataPatchOperation(operation, `${location}.operations[${index}]`);
|
|
1245
|
+
if (targetSet.has(normalized.nodeId)) {
|
|
1246
|
+
invalid('AI_DATA_PATCH_TARGET_DUPLICATE', `${location}.operations[${index}]`, `Patch targets node ${normalized.nodeId} more than once.`);
|
|
1247
|
+
}
|
|
1248
|
+
targetSet.add(normalized.nodeId);
|
|
1249
|
+
targetNodeIds.push(normalized.nodeId);
|
|
1250
|
+
if (normalized.op === 'remove-node') removedNodeIds.push(normalized.nodeId);
|
|
1251
|
+
}
|
|
1252
|
+
return { patchId, generation: patchGeneration, targetNodeIds, removedNodeIds };
|
|
1253
|
+
}
|
|
1254
|
+
|
|
1255
|
+
function normalizeAIDataPatchOperation(
|
|
1256
|
+
value: unknown,
|
|
1257
|
+
location: string,
|
|
1258
|
+
): { readonly op: 'add-node' | 'update-node' | 'remove-node'; readonly nodeId: string } {
|
|
1259
|
+
const operation = plainRecord(value, location);
|
|
1260
|
+
const op = dataField(operation, 'op', `${location}.op`);
|
|
1261
|
+
if (op === 'add-node') {
|
|
1262
|
+
const record = exactRecord(value, ['op', 'node'], [], location);
|
|
1263
|
+
return { op: 'add-node', nodeId: normalizeAIDataPatchNodeSpec(dataField(record, 'node', `${location}.node`), `${location}.node`) };
|
|
1264
|
+
}
|
|
1265
|
+
if (op === 'update-node') {
|
|
1266
|
+
const record = exactRecord(value, ['op', 'nodeId', 'changes'], [], location);
|
|
1267
|
+
const nodeId = canonicalIdentity(dataField(record, 'nodeId', `${location}.nodeId`), `${location}.nodeId`);
|
|
1268
|
+
normalizeAIDataPatchNodeChanges(dataField(record, 'changes', `${location}.changes`), `${location}.changes`);
|
|
1269
|
+
return { op: 'update-node', nodeId };
|
|
1270
|
+
}
|
|
1271
|
+
if (op === 'remove-node') {
|
|
1272
|
+
const record = exactRecord(value, ['op', 'nodeId'], [], location);
|
|
1273
|
+
return { op: 'remove-node', nodeId: canonicalIdentity(dataField(record, 'nodeId', `${location}.nodeId`), `${location}.nodeId`) };
|
|
1274
|
+
}
|
|
1275
|
+
invalid('AI_DATA_PATCH_OPERATION_INVALID', `${location}.op`, 'Patch operation is not part of the public contract.');
|
|
1276
|
+
}
|
|
1277
|
+
|
|
1278
|
+
function normalizeAIDataPatchNodeSpec(value: unknown, location: string): string {
|
|
1279
|
+
const spec = exactRecord(
|
|
1280
|
+
value,
|
|
1281
|
+
['id'],
|
|
1282
|
+
['tag', 'nodeType', 'declarationIndex', 'inputs', 'outputs', 'config', 'dependsOn', 'reusePolicy'],
|
|
1283
|
+
location,
|
|
1284
|
+
);
|
|
1285
|
+
const nodeId = canonicalIdentity(dataField(spec, 'id', `${location}.id`), `${location}.id`);
|
|
1286
|
+
normalizeAIDataPatchNodeFields(spec, location);
|
|
1287
|
+
return nodeId;
|
|
1288
|
+
}
|
|
1289
|
+
|
|
1290
|
+
function normalizeAIDataPatchNodeChanges(value: unknown, location: string): void {
|
|
1291
|
+
const changes = exactRecord(
|
|
1292
|
+
value,
|
|
1293
|
+
[],
|
|
1294
|
+
['tag', 'nodeType', 'inputs', 'outputs', 'config', 'dependsOn', 'reusePolicy'],
|
|
1295
|
+
location,
|
|
1296
|
+
);
|
|
1297
|
+
normalizeAIDataPatchNodeFields(changes, location);
|
|
1298
|
+
}
|
|
1299
|
+
|
|
1300
|
+
function normalizeAIDataPatchNodeFields(record: Record<string, unknown>, location: string): void {
|
|
1301
|
+
for (const field of ['tag', 'nodeType'] as const) {
|
|
1302
|
+
if (Object.prototype.hasOwnProperty.call(record, field)) canonicalIdentity(dataField(record, field, `${location}.${field}`), `${location}.${field}`);
|
|
1303
|
+
}
|
|
1304
|
+
if (Object.prototype.hasOwnProperty.call(record, 'declarationIndex')) nonNegativeSafeInteger(dataField(record, 'declarationIndex', `${location}.declarationIndex`), `${location}.declarationIndex`);
|
|
1305
|
+
if (Object.prototype.hasOwnProperty.call(record, 'inputs')) normalizeAIDataInputs(dataField(record, 'inputs', `${location}.inputs`), `${location}.inputs`);
|
|
1306
|
+
for (const field of ['outputs', 'dependsOn'] as const) {
|
|
1307
|
+
if (Object.prototype.hasOwnProperty.call(record, field)) uniqueIdentityArray(dataField(record, field, `${location}.${field}`), `${location}.${field}`);
|
|
1308
|
+
}
|
|
1309
|
+
if (Object.prototype.hasOwnProperty.call(record, 'config')) plainRecord(dataField(record, 'config', `${location}.config`), `${location}.config`);
|
|
1310
|
+
if (Object.prototype.hasOwnProperty.call(record, 'reusePolicy')) {
|
|
1311
|
+
const reuse = exactRecord(dataField(record, 'reusePolicy', `${location}.reusePolicy`), ['policy', 'source', 'nodeType'], [], `${location}.reusePolicy`);
|
|
1312
|
+
canonicalIdentity(dataField(reuse, 'nodeType', `${location}.reusePolicy.nodeType`), `${location}.reusePolicy.nodeType`);
|
|
1313
|
+
const policyValue = dataField(reuse, 'policy', `${location}.reusePolicy.policy`);
|
|
1314
|
+
if (policyValue !== 'semantic-hash' && policyValue !== 'never') {
|
|
1315
|
+
invalid('AI_DATA_REUSE_POLICY_INVALID', `${location}.reusePolicy.policy`, 'Reuse policy must be semantic-hash or never.');
|
|
1316
|
+
}
|
|
1317
|
+
const source = dataField(reuse, 'source', `${location}.reusePolicy.source`);
|
|
1318
|
+
if (source !== 'node' && source !== 'node-type-default' && source !== 'platform-default') {
|
|
1319
|
+
invalid('AI_DATA_REUSE_POLICY_SOURCE_INVALID', `${location}.reusePolicy.source`, 'Reuse policy source is not part of the public contract.');
|
|
1320
|
+
}
|
|
1321
|
+
}
|
|
1322
|
+
}
|
|
1323
|
+
|
|
1324
|
+
function normalizeEagerDataDefinition(value: unknown, location: string): void {
|
|
1325
|
+
const definition = exactRecord(
|
|
1326
|
+
value,
|
|
1327
|
+
[
|
|
1328
|
+
'form', 'fqn', 'apiVersion', 'version', 'sourceNames', 'contract', 'nodes', 'nodeById',
|
|
1329
|
+
'declarationOrder', 'dataEdges', 'waitForEdges', 'subflowEdges', 'diagnostics',
|
|
1330
|
+
],
|
|
1331
|
+
['title', 'baseUri'],
|
|
1332
|
+
location,
|
|
1333
|
+
);
|
|
1334
|
+
if (dataField(definition, 'form', `${location}.form`) !== 'EagerDataFlow') {
|
|
1335
|
+
invalid('AI_DATA_DEFINITION_FORM_INVALID', `${location}.form`, 'Expected EagerDataFlow.');
|
|
1336
|
+
}
|
|
1337
|
+
for (const field of ['fqn', 'apiVersion', 'version'] as const) {
|
|
1338
|
+
canonicalIdentity(dataField(definition, field, `${location}.${field}`), `${location}.${field}`);
|
|
1339
|
+
}
|
|
1340
|
+
for (const field of ['title', 'baseUri'] as const) {
|
|
1341
|
+
if (Object.prototype.hasOwnProperty.call(definition, field)) {
|
|
1342
|
+
const normalized = canonicalIdentity(dataField(definition, field, `${location}.${field}`), `${location}.${field}`);
|
|
1343
|
+
if (field === 'baseUri' && isHostLocalBaseUri(normalized)) {
|
|
1344
|
+
invalid('AI_DATA_DEFINITION_BASE_URI_INVALID', `${location}.baseUri`, 'Durable bindings cannot retain a host-local base URI.');
|
|
1345
|
+
}
|
|
1346
|
+
}
|
|
1347
|
+
}
|
|
1348
|
+
identityArray(dataField(definition, 'sourceNames', `${location}.sourceNames`), `${location}.sourceNames`);
|
|
1349
|
+
normalizeEagerDataContract(dataField(definition, 'contract', `${location}.contract`), `${location}.contract`);
|
|
1350
|
+
const nodes = closedArray(dataField(definition, 'nodes', `${location}.nodes`), `${location}.nodes`);
|
|
1351
|
+
const nodeIds = nodes.map((node, index) => normalizeEagerDataNode(node, `${location}.nodes[${index}]`));
|
|
1352
|
+
if (new Set(nodeIds).size !== nodeIds.length) invalid('AI_DATA_DEFINITION_NODE_DUPLICATE', `${location}.nodes`, 'Definition node identities must be unique.');
|
|
1353
|
+
const nodeByIdLocation = `${location}.nodeById`;
|
|
1354
|
+
const nodeById = plainRecord(dataField(definition, 'nodeById', nodeByIdLocation), nodeByIdLocation);
|
|
1355
|
+
const mappedIds = stringDataKeys(nodeById, nodeByIdLocation).sort(compareCodeUnits);
|
|
1356
|
+
for (const nodeId of mappedIds) {
|
|
1357
|
+
canonicalIdentity(nodeId, `${nodeByIdLocation}.${nodeId}`);
|
|
1358
|
+
const mappedId = normalizeEagerDataNode(dataField(nodeById, nodeId, `${nodeByIdLocation}.${nodeId}`), `${nodeByIdLocation}.${nodeId}`);
|
|
1359
|
+
if (mappedId !== nodeId) invalid('AI_DATA_DEFINITION_NODE_IDENTITY_MISMATCH', `${nodeByIdLocation}.${nodeId}.id`, 'Mapped definition node identity must equal its key.');
|
|
1360
|
+
}
|
|
1361
|
+
const declarationOrder = uniqueIdentityArray(
|
|
1362
|
+
dataField(definition, 'declarationOrder', `${location}.declarationOrder`),
|
|
1363
|
+
`${location}.declarationOrder`,
|
|
1364
|
+
);
|
|
1365
|
+
if (mappedIds.length !== nodeIds.length
|
|
1366
|
+
|| nodeIds.some((nodeId) => !mappedIds.includes(nodeId))
|
|
1367
|
+
|| declarationOrder.length !== nodeIds.length
|
|
1368
|
+
|| nodeIds.some((nodeId) => !declarationOrder.includes(nodeId))) {
|
|
1369
|
+
invalid('AI_DATA_DEFINITION_NODE_COLLECTION_MISMATCH', location, 'nodes, nodeById and declarationOrder must describe the same identities.');
|
|
1370
|
+
}
|
|
1371
|
+
for (const [index, nodeId] of nodeIds.entries()) {
|
|
1372
|
+
const mappedNode = dataField(nodeById, nodeId, `${nodeByIdLocation}.${nodeId}`);
|
|
1373
|
+
if (JSON.stringify(nodes[index]) !== JSON.stringify(mappedNode)) {
|
|
1374
|
+
invalid('AI_DATA_DEFINITION_NODE_PROJECTION_MISMATCH', `${nodeByIdLocation}.${nodeId}`, 'nodeById must be the exact projection of the matching nodes entry.');
|
|
1375
|
+
}
|
|
1376
|
+
}
|
|
1377
|
+
normalizeEagerDataEdges(dataField(definition, 'dataEdges', `${location}.dataEdges`), 'data', `${location}.dataEdges`);
|
|
1378
|
+
normalizeEagerDataEdges(dataField(definition, 'waitForEdges', `${location}.waitForEdges`), 'waitFor', `${location}.waitForEdges`);
|
|
1379
|
+
normalizeEagerDataEdges(dataField(definition, 'subflowEdges', `${location}.subflowEdges`), 'subflow', `${location}.subflowEdges`);
|
|
1380
|
+
for (const [index, item] of closedArray(dataField(definition, 'diagnostics', `${location}.diagnostics`), `${location}.diagnostics`).entries()) {
|
|
1381
|
+
const itemLocation = `${location}.diagnostics[${index}]`;
|
|
1382
|
+
const diagnostic = exactRecord(item, ['code', 'message'], ['source', 'nodeId'], itemLocation);
|
|
1383
|
+
for (const field of ['code', 'message', 'source', 'nodeId'] as const) {
|
|
1384
|
+
if (Object.prototype.hasOwnProperty.call(diagnostic, field)) canonicalIdentity(dataField(diagnostic, field, `${itemLocation}.${field}`), `${itemLocation}.${field}`);
|
|
1385
|
+
}
|
|
1386
|
+
}
|
|
1387
|
+
}
|
|
1388
|
+
|
|
1389
|
+
function validateEagerDataDefinitionReferences(definition: Record<string, unknown>, location: string): void {
|
|
1390
|
+
const nodeById = plainRecord(dataField(definition, 'nodeById', `${location}.nodeById`), `${location}.nodeById`);
|
|
1391
|
+
const node = (nodeId: string, edgeLocation: string): Record<string, unknown> => {
|
|
1392
|
+
if (!Object.prototype.hasOwnProperty.call(nodeById, nodeId)) {
|
|
1393
|
+
invalid('AI_DATA_DEFINITION_EDGE_ENDPOINT_INVALID', edgeLocation, 'Definition edge endpoints must reference declared nodes.');
|
|
1394
|
+
}
|
|
1395
|
+
return plainRecord(dataField(nodeById, nodeId, `${location}.nodeById.${nodeId}`), `${location}.nodeById.${nodeId}`);
|
|
1396
|
+
};
|
|
1397
|
+
for (const [index, item] of closedArray(dataField(definition, 'dataEdges', `${location}.dataEdges`), `${location}.dataEdges`).entries()) {
|
|
1398
|
+
const edgeLocation = `${location}.dataEdges[${index}]`;
|
|
1399
|
+
const edge = plainRecord(item, edgeLocation);
|
|
1400
|
+
const fromNodeId = canonicalIdentity(dataField(edge, 'fromNodeId', `${edgeLocation}.fromNodeId`), `${edgeLocation}.fromNodeId`);
|
|
1401
|
+
const toNodeId = canonicalIdentity(dataField(edge, 'toNodeId', `${edgeLocation}.toNodeId`), `${edgeLocation}.toNodeId`);
|
|
1402
|
+
const fromPort = canonicalIdentity(dataField(edge, 'fromPort', `${edgeLocation}.fromPort`), `${edgeLocation}.fromPort`);
|
|
1403
|
+
const toInput = canonicalIdentity(dataField(edge, 'toInput', `${edgeLocation}.toInput`), `${edgeLocation}.toInput`);
|
|
1404
|
+
const source = node(fromNodeId, edgeLocation);
|
|
1405
|
+
const target = node(toNodeId, edgeLocation);
|
|
1406
|
+
const outputs = Object.prototype.hasOwnProperty.call(source, 'outputs')
|
|
1407
|
+
? identityArray(dataField(source, 'outputs', `${edgeLocation}.source.outputs`), `${edgeLocation}.source.outputs`)
|
|
1408
|
+
: [];
|
|
1409
|
+
const inputs = Object.prototype.hasOwnProperty.call(target, 'inputs')
|
|
1410
|
+
? plainRecord(dataField(target, 'inputs', `${edgeLocation}.target.inputs`), `${edgeLocation}.target.inputs`)
|
|
1411
|
+
: {};
|
|
1412
|
+
if (!outputs.includes(fromPort) || !Object.prototype.hasOwnProperty.call(inputs, toInput)) {
|
|
1413
|
+
invalid('AI_DATA_DEFINITION_EDGE_PORT_INVALID', edgeLocation, 'Data edges must reference declared source ports and target inputs.');
|
|
1414
|
+
}
|
|
1415
|
+
const targetRef = plainRecord(dataField(inputs, toInput, `${edgeLocation}.target.inputs.${toInput}`), `${edgeLocation}.target.inputs.${toInput}`);
|
|
1416
|
+
if (dataField(targetRef, 'nodeId', `${edgeLocation}.target.inputs.${toInput}.nodeId`) !== fromNodeId
|
|
1417
|
+
|| dataField(targetRef, 'port', `${edgeLocation}.target.inputs.${toInput}.port`) !== fromPort
|
|
1418
|
+
|| dataField(targetRef, 'ref', `${edgeLocation}.target.inputs.${toInput}.ref`) !== dataField(edge, 'ref', `${edgeLocation}.ref`)) {
|
|
1419
|
+
invalid('AI_DATA_DEFINITION_EDGE_PROJECTION_MISMATCH', edgeLocation, 'Data edge must equal the target input port projection.');
|
|
1420
|
+
}
|
|
1421
|
+
}
|
|
1422
|
+
for (const collection of ['waitForEdges'] as const) {
|
|
1423
|
+
for (const [index, item] of closedArray(dataField(definition, collection, `${location}.${collection}`), `${location}.${collection}`).entries()) {
|
|
1424
|
+
const edgeLocation = `${location}.${collection}[${index}]`;
|
|
1425
|
+
const edge = plainRecord(item, edgeLocation);
|
|
1426
|
+
node(canonicalIdentity(dataField(edge, 'fromNodeId', `${edgeLocation}.fromNodeId`), `${edgeLocation}.fromNodeId`), edgeLocation);
|
|
1427
|
+
node(canonicalIdentity(dataField(edge, 'toNodeId', `${edgeLocation}.toNodeId`), `${edgeLocation}.toNodeId`), edgeLocation);
|
|
1428
|
+
}
|
|
1429
|
+
}
|
|
1430
|
+
}
|
|
1431
|
+
|
|
1432
|
+
function normalizeEagerDataContract(value: unknown, location: string): void {
|
|
1433
|
+
const contract = exactRecord(value, ['id', 'inputPorts', 'outputPorts'], ['input', 'output'], location);
|
|
1434
|
+
canonicalIdentity(dataField(contract, 'id', `${location}.id`), `${location}.id`);
|
|
1435
|
+
uniqueIdentityArray(dataField(contract, 'inputPorts', `${location}.inputPorts`), `${location}.inputPorts`);
|
|
1436
|
+
uniqueIdentityArray(dataField(contract, 'outputPorts', `${location}.outputPorts`), `${location}.outputPorts`);
|
|
1437
|
+
for (const field of ['input', 'output'] as const) {
|
|
1438
|
+
if (Object.prototype.hasOwnProperty.call(contract, field)) canonicalIdentity(dataField(contract, field, `${location}.${field}`), `${location}.${field}`);
|
|
1439
|
+
}
|
|
1440
|
+
}
|
|
1441
|
+
|
|
1442
|
+
function normalizeEagerDataNode(value: unknown, location: string): string {
|
|
1443
|
+
const base = plainRecord(value, location);
|
|
1444
|
+
const tag = dataField(base, 'tag', `${location}.tag`);
|
|
1445
|
+
const shapes: Record<string, { required: readonly string[]; optional: readonly string[] }> = {
|
|
1446
|
+
EntryNode: { required: ['tag', 'id', 'declarationIndex', 'waitFor', 'outputs'], optional: [] },
|
|
1447
|
+
TransformNode: { required: ['tag', 'id', 'declarationIndex', 'waitFor', 'inputs', 'outputs', 'config'], optional: ['type', 'src', 'impl'] },
|
|
1448
|
+
SinkNode: { required: ['tag', 'id', 'declarationIndex', 'waitFor', 'inputs', 'config'], optional: ['type', 'src', 'impl'] },
|
|
1449
|
+
SubFlowNode: { required: ['tag', 'id', 'declarationIndex', 'waitFor', 'flow', 'inputs', 'outputs'], optional: [] },
|
|
1450
|
+
ReturnNode: { required: ['tag', 'id', 'declarationIndex', 'waitFor', 'inputs'], optional: [] },
|
|
1451
|
+
};
|
|
1452
|
+
const shape = typeof tag === 'string' ? shapes[tag] : undefined;
|
|
1453
|
+
if (!shape) invalid('AI_DATA_DEFINITION_NODE_TAG_INVALID', `${location}.tag`, 'Definition node tag is not part of EagerDataFlow.');
|
|
1454
|
+
const node = exactRecord(value, shape.required, shape.optional, location);
|
|
1455
|
+
const id = canonicalIdentity(dataField(node, 'id', `${location}.id`), `${location}.id`);
|
|
1456
|
+
nonNegativeSafeInteger(dataField(node, 'declarationIndex', `${location}.declarationIndex`), `${location}.declarationIndex`);
|
|
1457
|
+
for (const [index, reference] of closedArray(dataField(node, 'waitFor', `${location}.waitFor`), `${location}.waitFor`).entries()) {
|
|
1458
|
+
normalizeFlowNodeReference(reference, `${location}.waitFor[${index}]`);
|
|
1459
|
+
}
|
|
1460
|
+
if (Object.prototype.hasOwnProperty.call(node, 'inputs')) {
|
|
1461
|
+
const inputs = plainRecord(dataField(node, 'inputs', `${location}.inputs`), `${location}.inputs`);
|
|
1462
|
+
for (const name of stringDataKeys(inputs, `${location}.inputs`).sort(compareCodeUnits)) {
|
|
1463
|
+
canonicalIdentity(name, `${location}.inputs.${name}`);
|
|
1464
|
+
normalizeFlowPortReference(dataField(inputs, name, `${location}.inputs.${name}`), `${location}.inputs.${name}`);
|
|
1465
|
+
}
|
|
1466
|
+
}
|
|
1467
|
+
if (Object.prototype.hasOwnProperty.call(node, 'outputs')) uniqueIdentityArray(dataField(node, 'outputs', `${location}.outputs`), `${location}.outputs`);
|
|
1468
|
+
if (Object.prototype.hasOwnProperty.call(node, 'config')) plainRecord(dataField(node, 'config', `${location}.config`), `${location}.config`);
|
|
1469
|
+
for (const field of ['type', 'src', 'impl', 'flow'] as const) {
|
|
1470
|
+
if (Object.prototype.hasOwnProperty.call(node, field)) canonicalIdentity(dataField(node, field, `${location}.${field}`), `${location}.${field}`);
|
|
1471
|
+
}
|
|
1472
|
+
return id;
|
|
1473
|
+
}
|
|
1474
|
+
|
|
1475
|
+
function normalizeEagerDataEdges(value: unknown, kind: 'data' | 'waitFor' | 'subflow', location: string): void {
|
|
1476
|
+
for (const [index, item] of closedArray(value, location).entries()) {
|
|
1477
|
+
const itemLocation = `${location}[${index}]`;
|
|
1478
|
+
const fields = kind === 'data'
|
|
1479
|
+
? ['kind', 'fromNodeId', 'fromPort', 'toNodeId', 'toInput', 'ref']
|
|
1480
|
+
: kind === 'waitFor'
|
|
1481
|
+
? ['kind', 'fromNodeId', 'toNodeId', 'ref']
|
|
1482
|
+
: ['kind', 'fromFlowId', 'nodeId', 'targetFlowId', 'ref'];
|
|
1483
|
+
const edge = exactRecord(item, fields, [], itemLocation);
|
|
1484
|
+
if (dataField(edge, 'kind', `${itemLocation}.kind`) !== kind) invalid('AI_DATA_DEFINITION_EDGE_KIND_INVALID', `${itemLocation}.kind`, 'Edge kind differs from its collection.');
|
|
1485
|
+
for (const field of fields.filter((field) => field !== 'kind')) {
|
|
1486
|
+
canonicalIdentity(dataField(edge, field, `${itemLocation}.${field}`), `${itemLocation}.${field}`);
|
|
1487
|
+
}
|
|
1488
|
+
}
|
|
1489
|
+
}
|
|
1490
|
+
|
|
1491
|
+
function isHostLocalBaseUri(value: string): boolean {
|
|
1492
|
+
return value.startsWith('/')
|
|
1493
|
+
|| value.startsWith('\\\\')
|
|
1494
|
+
|| value.startsWith('file:')
|
|
1495
|
+
|| /^[A-Za-z]:[\\/]/u.test(value);
|
|
1496
|
+
}
|
|
1497
|
+
|
|
1498
|
+
function closedArray(value: unknown, location: string): readonly FlowClosedValue[] {
|
|
1499
|
+
if (!Array.isArray(value)) invalid('FLOW_CLOSED_ARRAY_REQUIRED', location, 'Expected a closed array.');
|
|
1500
|
+
return value as readonly FlowClosedValue[];
|
|
1501
|
+
}
|
|
1502
|
+
|
|
1503
|
+
function identityArray(value: unknown, location: string): readonly string[] {
|
|
1504
|
+
const values = closedArray(value, location);
|
|
1505
|
+
return values.map((item, index) => canonicalIdentity(item, `${location}[${index}]`));
|
|
1506
|
+
}
|
|
1507
|
+
|
|
1508
|
+
function uniqueIdentityArray(value: unknown, location: string): readonly string[] {
|
|
1509
|
+
const values = identityArray(value, location);
|
|
1510
|
+
if (new Set(values).size !== values.length) invalid('FLOW_IDENTITY_ARRAY_DUPLICATE', location, 'Identity arrays cannot contain duplicates.');
|
|
1511
|
+
return values;
|
|
1512
|
+
}
|
|
1513
|
+
|
|
1514
|
+
export function normalizeAIWorkflowProfileDurableState(
|
|
1515
|
+
value: unknown,
|
|
1516
|
+
location = 'ai',
|
|
1517
|
+
): AIWorkflowProfileDurableState {
|
|
1518
|
+
return normalizeCanonicalAIAgentState(value, location);
|
|
1519
|
+
}
|
|
1520
|
+
|
|
1521
|
+
function cloneClosedObject(value: unknown, location: string): FlowClosedObject {
|
|
1522
|
+
const cloned = cloneClosedValue(value, location);
|
|
1523
|
+
if (typeof cloned !== 'object' || cloned === null || Array.isArray(cloned)) {
|
|
1524
|
+
invalid('FLOW_CLOSED_OBJECT_REQUIRED', location, 'Expected a closed plain-data object.');
|
|
1525
|
+
}
|
|
1526
|
+
return cloned as FlowClosedObject;
|
|
1527
|
+
}
|
|
1528
|
+
|
|
1529
|
+
/** Public closed-data boundary for adapters that must inspect normalized records. */
|
|
1530
|
+
export function normalizeFlowClosedObject(value: unknown, location = 'value'): FlowClosedObject {
|
|
1531
|
+
return cloneClosedObject(value, location);
|
|
1532
|
+
}
|
|
1533
|
+
|
|
1534
|
+
function cloneClosedValue(
|
|
1535
|
+
value: unknown,
|
|
1536
|
+
location: string,
|
|
1537
|
+
ancestors: WeakSet<object> = new WeakSet(),
|
|
1538
|
+
): FlowClosedValue {
|
|
1539
|
+
if (value === null || typeof value === 'string' || typeof value === 'boolean') return value;
|
|
1540
|
+
if (typeof value === 'number') {
|
|
1541
|
+
if (!Number.isFinite(value)) invalid('FLOW_CLOSED_NUMBER_INVALID', location, 'Numbers must be finite.');
|
|
1542
|
+
return Object.is(value, -0) ? 0 : value;
|
|
1543
|
+
}
|
|
1544
|
+
if (typeof value !== 'object') {
|
|
1545
|
+
invalid('FLOW_CLOSED_VALUE_INVALID', location, 'Expected JSON-compatible closed data.');
|
|
1546
|
+
}
|
|
1547
|
+
if (ancestors.has(value)) invalid('FLOW_CLOSED_CYCLE', location, 'Closed data must not contain cycles.');
|
|
1548
|
+
ancestors.add(value);
|
|
1549
|
+
try {
|
|
1550
|
+
if (Array.isArray(value)) {
|
|
1551
|
+
if (Object.getPrototypeOf(value) !== Array.prototype) {
|
|
1552
|
+
invalid('FLOW_CLOSED_ARRAY_INVALID', location, 'Arrays must use the standard Array prototype.');
|
|
1553
|
+
}
|
|
1554
|
+
const keys = Reflect.ownKeys(value);
|
|
1555
|
+
const expected = new Set<string>(['length']);
|
|
1556
|
+
for (let index = 0; index < value.length; index += 1) expected.add(String(index));
|
|
1557
|
+
if (keys.some((key) => typeof key !== 'string' || !expected.has(key))) {
|
|
1558
|
+
invalid('FLOW_CLOSED_ARRAY_INVALID', location, 'Arrays must be dense and contain no additional properties.');
|
|
1559
|
+
}
|
|
1560
|
+
const output: FlowClosedValue[] = [];
|
|
1561
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
1562
|
+
const descriptor = Object.getOwnPropertyDescriptor(value, String(index));
|
|
1563
|
+
if (!descriptor || !descriptor.enumerable || !('value' in descriptor)) {
|
|
1564
|
+
invalid('FLOW_CLOSED_ARRAY_INVALID', `${location}[${index}]`, 'Array entries must be enumerable data properties.');
|
|
1565
|
+
}
|
|
1566
|
+
output.push(cloneClosedValue(descriptor.value, `${location}[${index}]`, ancestors));
|
|
1567
|
+
}
|
|
1568
|
+
return Object.freeze(output);
|
|
1569
|
+
}
|
|
1570
|
+
const record = plainRecord(value, location);
|
|
1571
|
+
const output: Record<string, FlowClosedValue> = {};
|
|
1572
|
+
for (const key of stringDataKeys(record, location).sort(compareCodeUnits)) {
|
|
1573
|
+
Object.defineProperty(output, key, {
|
|
1574
|
+
value: cloneClosedValue(dataField(record, key, `${location}.${key}`), `${location}.${key}`, ancestors),
|
|
1575
|
+
enumerable: true,
|
|
1576
|
+
configurable: false,
|
|
1577
|
+
writable: false,
|
|
1578
|
+
});
|
|
1579
|
+
}
|
|
1580
|
+
return Object.freeze(output);
|
|
1581
|
+
} finally {
|
|
1582
|
+
ancestors.delete(value);
|
|
1583
|
+
}
|
|
1584
|
+
}
|
|
1585
|
+
|
|
1586
|
+
/** Public closed-data normalizer for complete scalar/array/object values. */
|
|
1587
|
+
export function normalizeFlowClosedValue(value: unknown, location = 'value'): FlowClosedValue {
|
|
1588
|
+
return cloneClosedValue(value, location);
|
|
1589
|
+
}
|
|
1590
|
+
|
|
1591
|
+
function exactRecord(
|
|
1592
|
+
value: unknown,
|
|
1593
|
+
required: readonly string[],
|
|
1594
|
+
optional: readonly string[],
|
|
1595
|
+
location: string,
|
|
1596
|
+
): Record<string, unknown> {
|
|
1597
|
+
const record = plainRecord(value, location);
|
|
1598
|
+
const allowed = new Set([...required, ...optional]);
|
|
1599
|
+
const keys = stringDataKeys(record, location);
|
|
1600
|
+
const unsupported = keys.filter((key) => !allowed.has(key));
|
|
1601
|
+
if (unsupported.length > 0) {
|
|
1602
|
+
invalid('FLOW_CONTRACT_FIELD_UNSUPPORTED', `${location}.${unsupported.sort(compareCodeUnits)[0]}`, 'Field is not part of the closed contract.');
|
|
1603
|
+
}
|
|
1604
|
+
for (const field of required) {
|
|
1605
|
+
if (!Object.prototype.hasOwnProperty.call(record, field)) {
|
|
1606
|
+
invalid('FLOW_CONTRACT_FIELD_MISSING', `${location}.${field}`, 'Required field is missing.');
|
|
1607
|
+
}
|
|
1608
|
+
}
|
|
1609
|
+
return record;
|
|
1610
|
+
}
|
|
1611
|
+
|
|
1612
|
+
function plainRecord(value: unknown, location: string): Record<string, unknown> {
|
|
1613
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
|
1614
|
+
invalid('FLOW_CONTRACT_OBJECT_INVALID', location, 'Expected a plain-data object.');
|
|
1615
|
+
}
|
|
1616
|
+
const prototype = Object.getPrototypeOf(value);
|
|
1617
|
+
if (prototype !== Object.prototype && prototype !== null) {
|
|
1618
|
+
invalid('FLOW_CONTRACT_OBJECT_INVALID', location, 'Expected a plain-data object.');
|
|
1619
|
+
}
|
|
1620
|
+
return value as Record<string, unknown>;
|
|
1621
|
+
}
|
|
1622
|
+
|
|
1623
|
+
function stringDataKeys(record: Record<string, unknown>, location: string): string[] {
|
|
1624
|
+
const keys = Reflect.ownKeys(record);
|
|
1625
|
+
if (keys.some((key) => typeof key !== 'string')) {
|
|
1626
|
+
invalid('FLOW_CONTRACT_SYMBOL_FIELD', location, 'Symbol fields are not closed data.');
|
|
1627
|
+
}
|
|
1628
|
+
const strings = keys as string[];
|
|
1629
|
+
for (const key of strings) {
|
|
1630
|
+
const descriptor = Object.getOwnPropertyDescriptor(record, key);
|
|
1631
|
+
if (!descriptor || !descriptor.enumerable || !('value' in descriptor)) {
|
|
1632
|
+
invalid('FLOW_CONTRACT_DATA_FIELD_REQUIRED', `${location}.${key}`, 'Fields must be enumerable data properties.');
|
|
1633
|
+
}
|
|
1634
|
+
}
|
|
1635
|
+
return strings;
|
|
1636
|
+
}
|
|
1637
|
+
|
|
1638
|
+
function dataField(record: Record<string, unknown>, field: string, location: string): unknown {
|
|
1639
|
+
const descriptor = Object.getOwnPropertyDescriptor(record, field);
|
|
1640
|
+
if (!descriptor || !descriptor.enumerable || !('value' in descriptor)) {
|
|
1641
|
+
invalid('FLOW_CONTRACT_DATA_FIELD_REQUIRED', location, 'Expected an enumerable data property.');
|
|
1642
|
+
}
|
|
1643
|
+
return descriptor.value;
|
|
1644
|
+
}
|
|
1645
|
+
|
|
1646
|
+
function canonicalIdentity(value: unknown, location: string): string {
|
|
1647
|
+
if (typeof value !== 'string' || value.length === 0 || value !== value.trim()) {
|
|
1648
|
+
invalid('FLOW_CONTRACT_IDENTITY_INVALID', location, 'Expected a non-empty canonical string.');
|
|
1649
|
+
}
|
|
1650
|
+
return value;
|
|
1651
|
+
}
|
|
1652
|
+
|
|
1653
|
+
function nonNegativeSafeInteger(value: unknown, location: string): number {
|
|
1654
|
+
if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < 0) {
|
|
1655
|
+
invalid('FLOW_CONTRACT_VERSION_INVALID', location, 'Expected a non-negative safe integer.');
|
|
1656
|
+
}
|
|
1657
|
+
return value;
|
|
1658
|
+
}
|
|
1659
|
+
|
|
1660
|
+
function invalid(code: string, location: string, message: string): never {
|
|
1661
|
+
throw new FlowRunContractValidationError(code, location, message);
|
|
1662
|
+
}
|
|
1663
|
+
|
|
1664
|
+
function compareCodeUnits(left: string, right: string): number {
|
|
1665
|
+
return left < right ? -1 : left > right ? 1 : 0;
|
|
1666
|
+
}
|