ai-workflow-logic 0.2.1 → 0.2.2
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 +10 -9
- package/src/agent-invocation.ts +62 -2
- package/src/agent-state.ts +3 -2
- package/src/checkpoint-filesystem.ts +23 -10
- package/src/checkpoint-profile.ts +3 -0
- package/src/checkpoint-sidecar.ts +77 -16
- package/src/checkpoint-store.ts +58 -8
- package/src/core.ts +2 -21
- package/src/definitions.ts +30 -0
- package/src/delegation.ts +387 -0
- package/src/index.ts +1 -0
- package/src/run-checkpoint.ts +30 -6
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ai-workflow-logic",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.2",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "AI workflow domain projections and pure runtime helpers with an explicit Halfcode filesystem adapter",
|
|
6
6
|
"type": "module",
|
|
@@ -11,19 +11,20 @@
|
|
|
11
11
|
"./checkpoint-filesystem": "./src/checkpoint-filesystem.ts",
|
|
12
12
|
"./filesystem": "./src/filesystem.ts",
|
|
13
13
|
"./run-freeze": "./src/run-freeze.ts",
|
|
14
|
-
"./agent-code-execution": "./src/agent-code-execution.ts"
|
|
14
|
+
"./agent-code-execution": "./src/agent-code-execution.ts",
|
|
15
|
+
"./definitions": "./src/definitions.ts"
|
|
15
16
|
},
|
|
16
17
|
"dependencies": {
|
|
17
|
-
"ai-workflow-contract": "0.2.
|
|
18
|
+
"ai-workflow-contract": "0.2.2",
|
|
18
19
|
"ajv": "8.20.0",
|
|
19
20
|
"flow-step-space-contract": "0.1.1",
|
|
20
|
-
"flow-step-space-logic": "0.1.
|
|
21
|
-
"halfcode-compiler.xnl": "0.3.
|
|
21
|
+
"flow-step-space-logic": "0.1.2",
|
|
22
|
+
"halfcode-compiler.xnl": "0.3.2",
|
|
22
23
|
"holarchy-core-contract": "0.2.0",
|
|
23
|
-
"task-manager-contract": "0.1.
|
|
24
|
-
"task-manager-logic": "0.1.
|
|
25
|
-
"work-ctrl-flow-contract": "0.1.
|
|
26
|
-
"work-ctrl-flow-logic": "0.1.
|
|
24
|
+
"task-manager-contract": "0.1.4",
|
|
25
|
+
"task-manager-logic": "0.1.7",
|
|
26
|
+
"work-ctrl-flow-contract": "0.1.4",
|
|
27
|
+
"work-ctrl-flow-logic": "0.1.6"
|
|
27
28
|
},
|
|
28
29
|
"files": [
|
|
29
30
|
"src"
|
package/src/agent-invocation.ts
CHANGED
|
@@ -8,9 +8,11 @@ import type {
|
|
|
8
8
|
AIAgentRunResult,
|
|
9
9
|
AIAgentSelector,
|
|
10
10
|
AIWorkflowAuthoredEffects,
|
|
11
|
+
AIWorkflowNodeTasks,
|
|
11
12
|
AIWorkflowFlowRunCheckpoint,
|
|
12
13
|
AIWorkflowProfileDurableState,
|
|
13
14
|
FlowClosedValue,
|
|
15
|
+
FlowClosedObject,
|
|
14
16
|
} from 'ai-workflow-contract';
|
|
15
17
|
import {
|
|
16
18
|
AIAgentInvocationError,
|
|
@@ -58,6 +60,37 @@ function same(left: unknown, right: unknown): boolean {
|
|
|
58
60
|
return JSON.stringify(left) === JSON.stringify(right);
|
|
59
61
|
}
|
|
60
62
|
|
|
63
|
+
function assertNoTaskSourceOverride(checkpoint: AIWorkflowFlowRunCheckpoint, nodeId: string): void {
|
|
64
|
+
const object = (value: FlowClosedValue): value is FlowClosedObject => value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
65
|
+
const bindings = object(checkpoint.config) ? checkpoint.config.workflowDelegations : undefined;
|
|
66
|
+
if (bindings && typeof bindings === 'object' && !Array.isArray(bindings) && Object.hasOwn(bindings, nodeId)) {
|
|
67
|
+
fail('AI_AGENT_TASK_DELEGATION_REQUIRED', 'checkpoint.config.workflowDelegations.' + nodeId,
|
|
68
|
+
'This node has an explicit task source. Use ai.delegation.delegateTask so its admitted owner and context cannot be silently ignored.');
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** The canonical instance index owns context reuse; the Host still owns the Actor. */
|
|
73
|
+
function frozenAgentContext(checkpoint:AIWorkflowFlowRunCheckpoint,runtime:AIAgentProcessorRuntime,config:AIAgentEffectConfig):{name?:string;generation:number}|undefined {
|
|
74
|
+
const object=(value:FlowClosedValue|undefined):value is FlowClosedObject=>value!==undefined&&value!==null&&typeof value==='object'&&!Array.isArray(value);
|
|
75
|
+
const bindings=normalizeFlowClosedObject(checkpoint.config).workflowAgentContexts;
|
|
76
|
+
if(bindings===undefined)return;
|
|
77
|
+
if(!object(bindings))fail('AI_AGENT_CONTEXT_INVALID','checkpoint.config.workflowAgentContexts','Frozen Agent contexts must be an object.');
|
|
78
|
+
if(!Object.hasOwn(bindings,runtime.nodeId))return;
|
|
79
|
+
const choice=bindings[runtime.nodeId];
|
|
80
|
+
if(!object(choice)||choice.schemaVersion!=='workflow.agent-context/v1'
|
|
81
|
+
||choice.agentDefinitionRef!==config.agentDefinitionRef||!['authored','shared','independent-per-generation'].includes(String(choice.sessionPolicy))
|
|
82
|
+
||choice.nodeIdentity!==undefined&&choice.nodeIdentity!==true
|
|
83
|
+
||Object.keys(choice).some(key=>!['schemaVersion','agentDefinitionRef','sessionPolicy','nodeIdentity'].includes(key)))
|
|
84
|
+
fail('AI_AGENT_CONTEXT_INVALID','checkpoint.config.workflowAgentContexts.'+runtime.nodeId,'Frozen Agent context must match the exact node task definition and supported policy.');
|
|
85
|
+
const generation=runtime.nodeContext?.generation??runtime.generation,scope=runtime.nodeContext?.scope??'';
|
|
86
|
+
if(generation===undefined||!Number.isSafeInteger(generation)||generation<0||typeof scope!=='string'||scope.normalize('NFC')!==scope)
|
|
87
|
+
fail('AI_AGENT_CONTEXT_IDENTITY_REQUIRED','runtime.nodeContext','A frozen Agent context requires its actual native generation and scope.');
|
|
88
|
+
if(choice.sessionPolicy==='authored')return {generation};
|
|
89
|
+
const name='workflow-agent-context:'+JSON.stringify([runtime.nodeId,scope,choice.sessionPolicy==='shared'?null:generation]);
|
|
90
|
+
if(config.instanceName!==undefined&&config.instanceName!==name)fail('AI_AGENT_CONTEXT_NAME_CONFLICT','config.instanceName','An explicit instance name cannot replace the frozen node context identity.');
|
|
91
|
+
return {name,generation};
|
|
92
|
+
}
|
|
93
|
+
|
|
61
94
|
function semanticFacts(config: AIAgentEffectConfig, invocationMetadata?: Readonly<Record<string, FlowClosedValue>>): Readonly<Record<string, FlowClosedValue>> {
|
|
62
95
|
return normalizeFlowClosedObject({
|
|
63
96
|
materialRefs: config.materialRefs ?? [],
|
|
@@ -201,11 +234,16 @@ async function complete(runtime: AIAgentProcessorRuntime, checkpoint: AIWorkflow
|
|
|
201
234
|
|
|
202
235
|
export async function runAgent<Input extends FlowClosedValue = FlowClosedValue, Output extends FlowClosedValue = FlowClosedValue>(runtime: AIAgentProcessorRuntime, input: Input, configInput: AIAgentEffectConfig): Promise<AIAgentRunResult<Output>> {
|
|
203
236
|
const inputValue = normalizeFlowClosedValue(input, 'input') as Input;
|
|
204
|
-
|
|
237
|
+
let config = normalizeAIAgentEffectConfig(configInput);
|
|
205
238
|
assertTaskAuthority(runtime, config);
|
|
206
239
|
let checkpoint = await loadCheckpoint(runtime);
|
|
240
|
+
assertNoTaskSourceOverride(checkpoint, runtime.nodeId);
|
|
241
|
+
const context=frozenAgentContext(checkpoint,runtime,config);
|
|
242
|
+
if(context){runtime={...runtime,generation:context.generation};if(context.name!==undefined)config={...config,instanceName:context.name}}
|
|
207
243
|
let ai = profileAI(checkpoint);
|
|
208
244
|
let receipt = ai.invocationsByKey[runtime.invocationKey];
|
|
245
|
+
if(context?.name!==undefined&&(receipt?.mode==='targeted'||!receipt&&ai.instanceIdByName[context.name]!==undefined))
|
|
246
|
+
return runTargetedAgent<Input,Output>(runtime,{byName:context.name},{kind:'ai.agent',payload:inputValue},config);
|
|
209
247
|
if (receipt) {
|
|
210
248
|
assertSameInvocation(receipt, runtime, 'new', inputValue, config);
|
|
211
249
|
if (receipt.status === 'completed') return resultFromReceipt(ai, receipt) as AIAgentRunResult<Output>;
|
|
@@ -238,11 +276,15 @@ export async function runAgent<Input extends FlowClosedValue = FlowClosedValue,
|
|
|
238
276
|
export async function runTargetedAgent<Input extends FlowClosedValue = FlowClosedValue, Output extends FlowClosedValue = FlowClosedValue>(runtime: AIAgentProcessorRuntime, selectorInput: AIAgentSelector, invocationInput: AIAgentInvocation<Input>, configInput: AIAgentEffectConfig): Promise<AIAgentRunResult<Output>> {
|
|
239
277
|
const selector = normalizeAIAgentSelector(selectorInput);
|
|
240
278
|
const invocation = normalizeAIAgentInvocation<Input>(invocationInput);
|
|
241
|
-
|
|
279
|
+
let config = normalizeAIAgentEffectConfig(configInput);
|
|
242
280
|
assertTaskAuthority(runtime, config);
|
|
243
281
|
let checkpoint = await loadCheckpoint(runtime);
|
|
282
|
+
assertNoTaskSourceOverride(checkpoint, runtime.nodeId);
|
|
283
|
+
const context=frozenAgentContext(checkpoint,runtime,config);
|
|
284
|
+
if(context){runtime={...runtime,generation:context.generation};if(context.name!==undefined)config={...config,instanceName:context.name}}
|
|
244
285
|
let ai = profileAI(checkpoint);
|
|
245
286
|
const instance = resolveSelector(ai, selector, config);
|
|
287
|
+
if(context?.name!==undefined&&instance.instanceName!==context.name)fail('AI_AGENT_CONTEXT_TARGET_CONFLICT','selector','Target must be the original instance for this frozen node generation and scope.');
|
|
246
288
|
let receipt = ai.invocationsByKey[runtime.invocationKey];
|
|
247
289
|
if (receipt) {
|
|
248
290
|
assertSameInvocation(receipt, runtime, 'targeted', invocation.payload, config, invocation.metadata);
|
|
@@ -280,3 +322,21 @@ export function bindAIAgentProcessors(runtime: AIAgentProcessorRuntime): AIWorkf
|
|
|
280
322
|
runTargetedAgent: <Input extends FlowClosedValue, Output extends FlowClosedValue>(selector: AIAgentSelector, invocation: AIAgentInvocation<Input>, config: AIAgentEffectConfig) => runTargetedAgent<Input, Output>(runtime, selector, invocation, config),
|
|
281
323
|
});
|
|
282
324
|
}
|
|
325
|
+
|
|
326
|
+
/** Source selection is an immutable admission fact; authored input cannot select another owner. */
|
|
327
|
+
export function bindWorkflowNodeTasks(runtime: AIAgentProcessorRuntime, delegation?: { delegateTask(input: FlowClosedValue): Promise<FlowClosedValue> }): AIWorkflowNodeTasks {
|
|
328
|
+
return Object.freeze({ async invoke(input: FlowClosedValue, configInput: AIAgentEffectConfig): Promise<FlowClosedValue> {
|
|
329
|
+
const config=normalizeAIAgentEffectConfig(configInput),value=normalizeFlowClosedValue(input);
|
|
330
|
+
assertTaskAuthority(runtime,config);
|
|
331
|
+
const checkpoint=await loadCheckpoint(runtime),closed=normalizeFlowClosedObject(checkpoint.config),bindings=closed.workflowDelegations;
|
|
332
|
+
if(bindings!==undefined&&(bindings===null||typeof bindings!=='object'||Array.isArray(bindings)))
|
|
333
|
+
fail('AI_AGENT_TASK_SOURCES_INVALID','checkpoint.config.workflowDelegations','Frozen node task sources must be an object.');
|
|
334
|
+
if(bindings&&Object.hasOwn(bindings,runtime.nodeId)){
|
|
335
|
+
if(Object.keys(config).some(key=>key!=='agentDefinitionRef'))
|
|
336
|
+
fail('AI_AGENT_TASK_EFFECT_OVERRIDE_UNSUPPORTED','config','Delegated tasks use their frozen materials, model and context; Agent instance or effect overrides cannot be ignored.');
|
|
337
|
+
if(!delegation)fail('AI_AGENT_TASK_OWNER_REQUIRED','runtime.delegation','The admitted node task owner is unavailable.');
|
|
338
|
+
return delegation.delegateTask(value);
|
|
339
|
+
}
|
|
340
|
+
return (await runAgent(runtime,value,config)).output;
|
|
341
|
+
}});
|
|
342
|
+
}
|
package/src/agent-state.ts
CHANGED
|
@@ -12,6 +12,7 @@ import type {
|
|
|
12
12
|
LegacyAIWorkflowProfileDurableState,
|
|
13
13
|
} from 'ai-workflow-contract';
|
|
14
14
|
import { normalizeFlowClosedObject, normalizeFlowClosedValue } from './run-checkpoint.js';
|
|
15
|
+
import { normalizeWorkflowDelegations } from './delegation.js';
|
|
15
16
|
|
|
16
17
|
export class AIAgentInvocationError extends Error {
|
|
17
18
|
constructor(public readonly code: string, public readonly location: string, message: string) {
|
|
@@ -231,7 +232,7 @@ function normalizeReceipt(value: unknown, key: string, instances: Readonly<Recor
|
|
|
231
232
|
}
|
|
232
233
|
|
|
233
234
|
export function normalizeCanonicalAIAgentState(value: unknown, location = 'ai'): AIWorkflowProfileDurableState {
|
|
234
|
-
const input = record(value, ['schemaVersion', 'instancesById', 'instanceIdByName', 'invocationsByKey'], [], location);
|
|
235
|
+
const input = record(value, ['schemaVersion', 'instancesById', 'instanceIdByName', 'invocationsByKey'], ['delegationsByKey'], location);
|
|
235
236
|
if (input.schemaVersion !== 'depa.ai-agent-state/v1') fail('AI_AGENT_STATE_SCHEMA_UNSUPPORTED', `${location}.schemaVersion`, 'Unsupported Agent state schema.');
|
|
236
237
|
const instanceValues = objectMap(input.instancesById, `${location}.instancesById`);
|
|
237
238
|
const instancesById: Record<string, AIAgentInstanceReference> = {};
|
|
@@ -259,7 +260,7 @@ export function normalizeCanonicalAIAgentState(value: unknown, location = 'ai'):
|
|
|
259
260
|
}
|
|
260
261
|
invocationsByKey[key] = receipt;
|
|
261
262
|
}
|
|
262
|
-
return Object.freeze({ schemaVersion: 'depa.ai-agent-state/v1', instancesById: Object.freeze(instancesById), instanceIdByName: Object.freeze(instanceIdByName), invocationsByKey: Object.freeze(invocationsByKey) });
|
|
263
|
+
return Object.freeze({ schemaVersion: 'depa.ai-agent-state/v1', instancesById: Object.freeze(instancesById), instanceIdByName: Object.freeze(instanceIdByName), invocationsByKey: Object.freeze(invocationsByKey), ...(input.delegationsByKey === undefined ? {} : { delegationsByKey: normalizeWorkflowDelegations(input.delegationsByKey) }) });
|
|
263
264
|
}
|
|
264
265
|
|
|
265
266
|
export function upgradeLegacyAIWorkflowProfileDurableState(value: LegacyAIWorkflowProfileDurableState, agentDefinitionRefs: Readonly<Record<string, `resource://${string}`>>, location = 'legacyAI'): AIWorkflowProfileDurableState {
|
|
@@ -16,6 +16,7 @@ import type { WorkCtrlFlowSnapshot } from 'work-ctrl-flow-contract';
|
|
|
16
16
|
import {
|
|
17
17
|
createMaterializedWorkCtrlFlowEngineFromDirectory,
|
|
18
18
|
loadFrozenCtrlDefinitionStepIds,
|
|
19
|
+
loadFrozenCtrlDefinitionMembership,
|
|
19
20
|
loadMaterializedFlowInstance,
|
|
20
21
|
materializeFlowInstance,
|
|
21
22
|
type MaterializedWorkCtrlFlowDirectoryOptions,
|
|
@@ -293,7 +294,7 @@ export class FilesystemFlowRunCheckpointStore implements FlowRunCheckpointStore<
|
|
|
293
294
|
if (!fs.existsSync(locations.runDir)) return undefined;
|
|
294
295
|
assertExistingDirectory(locations.runDir, this.#realStoreRoot, 'flow-run-symlink-boundary');
|
|
295
296
|
if (!fs.existsSync(locations.checkpointFile)) return undefined;
|
|
296
|
-
const checkpoint =
|
|
297
|
+
const checkpoint = this.#readStorage(locations.checkpointFile).checkpoint;
|
|
297
298
|
this.#assertCheckpointIdentity(checkpoint, key, descriptor.definition);
|
|
298
299
|
this.#validateFrozenProfile(checkpoint);
|
|
299
300
|
return checkpoint;
|
|
@@ -317,7 +318,7 @@ export class FilesystemFlowRunCheckpointStore implements FlowRunCheckpointStore<
|
|
|
317
318
|
this.#assertRunPhysicalBoundary(locations);
|
|
318
319
|
removeUncommittedStaging(locations.stagingFile, locations.runDir, this.#realStoreRoot);
|
|
319
320
|
const currentStorage = fs.existsSync(locations.checkpointFile)
|
|
320
|
-
?
|
|
321
|
+
? this.#readStorage(locations.checkpointFile)
|
|
321
322
|
: undefined;
|
|
322
323
|
const current = currentStorage?.checkpoint;
|
|
323
324
|
if (current) {
|
|
@@ -338,13 +339,15 @@ export class FilesystemFlowRunCheckpointStore implements FlowRunCheckpointStore<
|
|
|
338
339
|
));
|
|
339
340
|
const encoded = encodeCheckpointSidecars(checkpoint, currentStorage?.tree, {
|
|
340
341
|
definitionStepIds,
|
|
342
|
+
...(checkpoint.profile.kind === 'AIDataWorkflow' ? {} : { definitionInvocationOwners: frozenAuthority?.definitionInvocationOwners
|
|
343
|
+
?? loadFrozenCtrlDefinitionMembership(path.join(locations.instanceDir, 'definition'), checkpoint.profile.kind, this.#extensionCodecs).invocationOwners }),
|
|
341
344
|
previousMaterialRecordDigests: currentStorage?.materialRecordDigests,
|
|
342
345
|
});
|
|
343
346
|
removeUnacceptedSidecarReceipt(locations.runDir, checkpoint.version, this.#realStoreRoot);
|
|
344
347
|
persistCheckpointSidecars(locations.runDir, encoded, this.#durability);
|
|
345
348
|
assertSidecarDirectories(locations.runDir, this.#realStoreRoot);
|
|
346
349
|
writeCheckpointCandidate(locations.stagingFile, encoded.head, this.#realStoreRoot, this.#durability);
|
|
347
|
-
const staged =
|
|
350
|
+
const staged = this.#readStorage(locations.stagingFile).checkpoint;
|
|
348
351
|
this.#assertCheckpointIdentity(staged, key, descriptor.definition);
|
|
349
352
|
this.#validateFrozenProfile(staged);
|
|
350
353
|
if (stableJson(staged) !== stableJson(checkpoint)) {
|
|
@@ -359,7 +362,7 @@ export class FilesystemFlowRunCheckpointStore implements FlowRunCheckpointStore<
|
|
|
359
362
|
assertRegularFile(locations.checkpointFile, this.#realStoreRoot, 'flow-run-checkpoint-invalid');
|
|
360
363
|
}
|
|
361
364
|
renameDurably(locations.stagingFile, locations.checkpointFile, 'head', this.#durability);
|
|
362
|
-
const accepted =
|
|
365
|
+
const accepted = this.#readStorage(locations.checkpointFile).checkpoint;
|
|
363
366
|
this.#assertCheckpointIdentity(accepted, key, descriptor.definition);
|
|
364
367
|
this.#validateFrozenProfile(accepted);
|
|
365
368
|
if (stableJson(accepted) !== stableJson(checkpoint)) {
|
|
@@ -452,7 +455,7 @@ export class FilesystemFlowRunCheckpointStore implements FlowRunCheckpointStore<
|
|
|
452
455
|
try {
|
|
453
456
|
this.#assertRunPhysicalBoundary(locations);
|
|
454
457
|
const checkpoint = fs.existsSync(locations.checkpointFile)
|
|
455
|
-
?
|
|
458
|
+
? this.#readStorage(locations.checkpointFile).checkpoint
|
|
456
459
|
: undefined;
|
|
457
460
|
if (!checkpoint) {
|
|
458
461
|
throw new LegacyWorkCtrlImportError(
|
|
@@ -574,6 +577,13 @@ export class FilesystemFlowRunCheckpointStore implements FlowRunCheckpointStore<
|
|
|
574
577
|
}, { requireAIDataAuthority: true });
|
|
575
578
|
}
|
|
576
579
|
|
|
580
|
+
#readStorage(file: string) {
|
|
581
|
+
return readCheckpointStorage(file, this.#realStoreRoot, {
|
|
582
|
+
profileAuthority: this.#profileAuthority,
|
|
583
|
+
extensionCodecs: this.#extensionCodecs,
|
|
584
|
+
});
|
|
585
|
+
}
|
|
586
|
+
|
|
577
587
|
#ensureRunDirectory(locations: FlowRunFilesystemLocations): void {
|
|
578
588
|
ensureChildDirectory(locations.instanceDir, locations.runsDir, this.#realStoreRoot, this.#durability, 'head');
|
|
579
589
|
ensureChildDirectory(locations.runsDir, locations.runDir, this.#realStoreRoot, this.#durability, 'head');
|
|
@@ -1018,13 +1028,10 @@ function ensureChildDirectory(
|
|
|
1018
1028
|
assertExistingDirectory(child, realRoot, 'flow-run-boundary-invalid');
|
|
1019
1029
|
}
|
|
1020
1030
|
|
|
1021
|
-
function readCheckpointFile(file: string, realRoot: string): AIWorkflowFlowRunCheckpoint {
|
|
1022
|
-
return readCheckpointStorage(file, realRoot).checkpoint;
|
|
1023
|
-
}
|
|
1024
|
-
|
|
1025
1031
|
function readCheckpointStorage(
|
|
1026
1032
|
file: string,
|
|
1027
1033
|
realRoot: string,
|
|
1034
|
+
runtime: { readonly profileAuthority?: import('./checkpoint-profile.js').FlowRunProfileAuthorityPort; readonly extensionCodecs: import('flow-step-space-contract').DefinitionStepExtensionCodecRegistryPort },
|
|
1028
1035
|
): {
|
|
1029
1036
|
readonly checkpoint: AIWorkflowFlowRunCheckpoint;
|
|
1030
1037
|
readonly tree?: StepMerkleTree;
|
|
@@ -1034,7 +1041,13 @@ function readCheckpointStorage(
|
|
|
1034
1041
|
try {
|
|
1035
1042
|
if (isCheckpointHead(value)) {
|
|
1036
1043
|
assertSidecarDirectories(path.dirname(file), realRoot);
|
|
1037
|
-
const loaded = readCheckpointHeadAndLogical(file)
|
|
1044
|
+
const loaded = readCheckpointHeadAndLogical(file,{ctrlMembership(head,kind){
|
|
1045
|
+
assertSafeSegment(head.instanceId);
|
|
1046
|
+
const authority=runtime.profileAuthority?.load({instanceId:head.instanceId,definition:head.definition,profileKind:kind});
|
|
1047
|
+
return authority?.definitionInvocationOwners
|
|
1048
|
+
? {definitionStepIds:authority.definitionStepIds,invocationOwners:authority.definitionInvocationOwners}
|
|
1049
|
+
: loadFrozenCtrlDefinitionMembership(path.join(realRoot,'instances',head.instanceId,'definition'),kind,runtime.extensionCodecs);
|
|
1050
|
+
}});
|
|
1038
1051
|
return {
|
|
1039
1052
|
checkpoint: loaded.checkpoint,
|
|
1040
1053
|
tree: loaded.tree,
|
|
@@ -18,6 +18,7 @@ export interface FrozenRunStepExtensionDefinition {
|
|
|
18
18
|
export interface FrozenFlowRunProfileAuthority {
|
|
19
19
|
readonly profileKind: FlowRunProfileState['kind'];
|
|
20
20
|
readonly definitionStepIds: readonly string[];
|
|
21
|
+
readonly definitionInvocationOwners?: Readonly<Record<string, string>>;
|
|
21
22
|
readonly binding?: FlowClosedObject;
|
|
22
23
|
readonly extensionsByStepId: Readonly<
|
|
23
24
|
Record<string, Readonly<Record<string, FrozenRunStepExtensionDefinition>>>
|
|
@@ -103,6 +104,8 @@ export function validateCheckpointAgainstFrozenProfile(
|
|
|
103
104
|
if (ids.some((id) => typeof id !== 'string' || !id || id !== id.trim()) || new Set(ids).size !== ids.length) {
|
|
104
105
|
throw new FlowRunCheckpointTransitionError('Frozen profile step membership is invalid.');
|
|
105
106
|
}
|
|
107
|
+
if (Object.entries(authority.definitionInvocationOwners ?? {}).some(([key, owner]) => !key || !ids.includes(owner)))
|
|
108
|
+
throw new FlowRunCheckpointTransitionError('Frozen compiled action ownership is invalid.');
|
|
106
109
|
for (const stepId of Object.keys(authority.extensionsByStepId)) {
|
|
107
110
|
if (!ids.includes(stepId)) {
|
|
108
111
|
throw new FlowRunCheckpointTransitionError(`Frozen extension step ${stepId} is not a definition member.`);
|
|
@@ -42,8 +42,15 @@ export interface FlowRunCheckpointSidecarReceipt {
|
|
|
42
42
|
}
|
|
43
43
|
export interface CheckpointSidecarEncodeConfig {
|
|
44
44
|
readonly definitionStepIds?: readonly string[];
|
|
45
|
+
readonly definitionInvocationOwners?: Readonly<Record<string, string>>;
|
|
45
46
|
readonly previousMaterialRecordDigests?: readonly DefinitionStepDigest[];
|
|
46
47
|
}
|
|
48
|
+
export interface CheckpointSidecarReadConfig {
|
|
49
|
+
readonly ctrlMembership?: (head: FlowRunCheckpointHead, kind: 'WorkCtrlFlow' | 'AICtrlWorkflow') => {
|
|
50
|
+
readonly definitionStepIds: readonly string[];
|
|
51
|
+
readonly invocationOwners: Readonly<Record<string, string>>;
|
|
52
|
+
};
|
|
53
|
+
}
|
|
47
54
|
type EncodedCheckpoint = {
|
|
48
55
|
readonly head: FlowRunCheckpointHead;
|
|
49
56
|
readonly controlRecords: readonly ImmutableStepRecord[];
|
|
@@ -123,12 +130,12 @@ function dataMembership(
|
|
|
123
130
|
const ORCHESTRATION_FIELDS = ['attempts', 'deadlines', 'lanes', 'winners', 'cancellations', 'composites'] as const;
|
|
124
131
|
type OrchestrationField = typeof ORCHESTRATION_FIELDS[number];
|
|
125
132
|
|
|
126
|
-
function runtimeCtrlMembers(orchestrationValue: unknown, definitionStepIds: readonly string[]): readonly string[] {
|
|
133
|
+
function runtimeCtrlMembers(orchestrationValue: unknown, definitionStepIds: readonly string[], invocationOwners: Readonly<Record<string, string>> = {}, waitOwners:ReadonlyMap<string,string>=new Map()): readonly string[] {
|
|
127
134
|
const orchestration = object(orchestrationValue, 'ctrl orchestration');
|
|
128
135
|
const members = new Set<string>();
|
|
129
136
|
for (const field of ORCHESTRATION_FIELDS) {
|
|
130
137
|
for (const [identity] of entries(orchestration[field], `ctrl orchestration.${field}`)) {
|
|
131
|
-
if (!definitionOwner(identity, definitionStepIds)) members.add(identity);
|
|
138
|
+
if (!definitionOwner(identity, definitionStepIds, invocationOwners)&&!waitOwners.has(identity)) members.add(identity);
|
|
132
139
|
}
|
|
133
140
|
}
|
|
134
141
|
return [...members].sort(compare);
|
|
@@ -161,22 +168,55 @@ function createRunStepLeaf(
|
|
|
161
168
|
});
|
|
162
169
|
return { leaf, materialRecords: [coreRecord, ...extensionRecords.map(({ record }) => record)] as readonly ImmutableStepRecord[] };
|
|
163
170
|
}
|
|
164
|
-
function definitionOwner(invocationKey: string, definitionStepIds: readonly string[]): string | undefined {
|
|
171
|
+
function definitionOwner(invocationKey: string, definitionStepIds: readonly string[], invocationOwners: Readonly<Record<string, string>> = {}): string | undefined {
|
|
165
172
|
const visit = invocationKey.lastIndexOf('#');
|
|
166
173
|
const stablePath = visit >= 0 ? invocationKey.slice(0, visit) : invocationKey;
|
|
167
174
|
const id = stablePath.slice(stablePath.lastIndexOf('/') + 1);
|
|
168
|
-
|
|
175
|
+
const owner = invocationOwners[id];
|
|
176
|
+
return definitionStepIds.includes(id) ? id : owner && definitionStepIds.includes(owner) ? owner : undefined;
|
|
169
177
|
}
|
|
170
178
|
|
|
171
|
-
|
|
179
|
+
/** Dynamic waits belong to the exact compiled action that created their native handle, never a prefix wildcard. */
|
|
180
|
+
function delegationWaitOwners(snapshotValue:unknown,definitionStepIds:readonly string[],invocationOwners:Readonly<Record<string,string>>,aiValue?:unknown):ReadonlyMap<string,string>{
|
|
181
|
+
const snapshot=object(snapshotValue,'ctrl snapshot'),owners=new Map<string,string>();
|
|
182
|
+
for(const field of ['closedWaitHandles','openWaitHandles']){
|
|
183
|
+
const handles=snapshot[field];if(!Array.isArray(handles))throw Error('Ctrl wait handles must be an array.');
|
|
184
|
+
for(const value of handles){
|
|
185
|
+
const handle=object(value,'ctrl wait handle');
|
|
186
|
+
if(!handle.metadata||typeof handle.metadata!=='object'||Array.isArray(handle.metadata))continue;
|
|
187
|
+
const metadata=object(handle.metadata,'ctrl wait metadata');
|
|
188
|
+
if(metadata.workflowDelegation===undefined)continue;
|
|
189
|
+
const delegated=object(metadata.workflowDelegation,'ctrl delegation wait');
|
|
190
|
+
const action=delegated.actionInvocationKey,invocation=delegated.invocationKey;
|
|
191
|
+
if(delegated.kind!=='workflow.task-delegation/v1'||typeof action!=='string'||typeof invocation!=='string'||typeof handle.invocationKey!=='string'||typeof handle.nodeKey!=='string'||handle.waitKind!=='external-job'||handle.signalKind!=='workflow.delegation.observe'||handle.signalKey!==action)throw Error('Ctrl delegation wait identity is invalid.');
|
|
192
|
+
const owner=definitionOwner(action,definitionStepIds,invocationOwners);
|
|
193
|
+
const actionPath=action.slice(0,action.lastIndexOf('#')),scope=actionPath.slice(0,actionPath.lastIndexOf('/')+1),node=actionPath.slice(actionPath.lastIndexOf('/')+1);
|
|
194
|
+
const prefix=`${node}:delegation:`;
|
|
195
|
+
if(!owner||definitionOwner(invocation,definitionStepIds,invocationOwners)!==owner||!handle.nodeKey.startsWith(prefix)||!/^(0|[1-9][0-9]*)$/.test(handle.nodeKey.slice(prefix.length))||!handle.invocationKey.startsWith(`${scope}${handle.nodeKey}#`)||!/^(0|[1-9][0-9]*)$/.test(handle.invocationKey.slice(`${scope}${handle.nodeKey}#`.length)))throw Error('Ctrl delegation wait is not owned by its frozen action.');
|
|
196
|
+
if(aiValue!==undefined){
|
|
197
|
+
const ai=object(aiValue,'ctrl AI state'),records=object(ai.delegationsByKey??{},'ctrl delegations'),record=object(records[invocation],'ctrl delegation record');
|
|
198
|
+
if(!Array.isArray(record.attempts)||record.attempts.length===0)throw Error('Ctrl delegation wait has no accepted invocation.');
|
|
199
|
+
const attempt=object(record.attempts.at(-1),'ctrl delegation attempt'),request=object(attempt.request,'ctrl delegation request'),lineage=object(request.lineage,'ctrl delegation lineage');
|
|
200
|
+
if(attempt.dispatchClaimed!==true||lineage.invocationKey!==invocation||lineage.nodeId!==owner)throw Error('Ctrl delegation wait receipt does not match its frozen action.');
|
|
201
|
+
}
|
|
202
|
+
if(owners.has(handle.invocationKey))throw Error('Ctrl delegation wait identity is duplicated.');
|
|
203
|
+
owners.set(handle.invocationKey,owner);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
return owners;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function partitionCtrl(checkpoint: AIWorkflowFlowRunCheckpoint, idsValue?: readonly string[], invocationOwners: Readonly<Record<string, string>> = {}) {
|
|
172
210
|
if (!idsValue?.length || idsValue.some((id) => typeof id !== 'string' || !id) || new Set(idsValue).size !== idsValue.length) {
|
|
173
211
|
throw new Error('Ctrl sidecar requires unique frozen definition step identities.');
|
|
174
212
|
}
|
|
175
213
|
if (checkpoint.profile.kind !== 'WorkCtrlFlow' && checkpoint.profile.kind !== 'AICtrlWorkflow') throw new Error('Ctrl profile expected.');
|
|
176
214
|
const definitionStepIds = [...idsValue];
|
|
215
|
+
if (Object.entries(invocationOwners).some(([key, owner]) => !key || !definitionStepIds.includes(owner))) throw new Error('Ctrl compiled action ownership is invalid.');
|
|
177
216
|
const snapshot = checkpoint.profile.snapshot as FlowClosedObject;
|
|
178
217
|
const { branchDecisions, nodeResults, waitResults, orchestration, ...snapshotControl } = snapshot;
|
|
179
|
-
const
|
|
218
|
+
const waitOwners=delegationWaitOwners(snapshot,definitionStepIds,invocationOwners,checkpoint.profile.kind==='AICtrlWorkflow'?checkpoint.profile.ai:undefined);
|
|
219
|
+
const runtimeMembers = runtimeCtrlMembers(orchestration, definitionStepIds, invocationOwners,waitOwners);
|
|
180
220
|
const memberIds = [...definitionStepIds.map((id) => `definition:${id}`), ...runtimeMembers.map((id) => `runtime:${id}`)].sort(compare);
|
|
181
221
|
const facts = new Map(memberIds.map((id) => [id, {
|
|
182
222
|
branchDecisions: {},
|
|
@@ -186,7 +226,7 @@ function partitionCtrl(checkpoint: AIWorkflowFlowRunCheckpoint, idsValue?: reado
|
|
|
186
226
|
}]));
|
|
187
227
|
const runtimeSet = new Set(runtimeMembers);
|
|
188
228
|
const ownerOf = (identity: string) => {
|
|
189
|
-
const owner = definitionOwner(identity, definitionStepIds);
|
|
229
|
+
const owner = definitionOwner(identity, definitionStepIds, invocationOwners)??waitOwners.get(identity);
|
|
190
230
|
return owner ? `definition:${owner}` : runtimeSet.has(identity) ? `runtime:${identity}` : undefined;
|
|
191
231
|
};
|
|
192
232
|
const place = (field: 'branchDecisions' | 'nodeResults' | 'waitResults', value: unknown) => {
|
|
@@ -223,6 +263,7 @@ function partitionCtrl(checkpoint: AIWorkflowFlowRunCheckpoint, idsValue?: reado
|
|
|
223
263
|
profile: {
|
|
224
264
|
kind: checkpoint.profile.kind,
|
|
225
265
|
definitionStepIds,
|
|
266
|
+
...(Object.keys(invocationOwners).length ? { definitionInvocationOwners: invocationOwners } : {}),
|
|
226
267
|
snapshot: {
|
|
227
268
|
...snapshotControl,
|
|
228
269
|
orchestration: Object.fromEntries(ORCHESTRATION_FIELDS.map((field) => [field, {}])),
|
|
@@ -269,6 +310,7 @@ function reconstruct(
|
|
|
269
310
|
records: readonly ImmutableStepRecord[],
|
|
270
311
|
controlRecords: ReadonlyMap<DefinitionStepDigest, ImmutableStepRecord>,
|
|
271
312
|
readMaterial: (digest: DefinitionStepDigest) => ImmutableStepRecord,
|
|
313
|
+
config: CheckpointSidecarReadConfig,
|
|
272
314
|
): AIWorkflowFlowRunCheckpoint {
|
|
273
315
|
const control = object(head.control, 'checkpoint control');
|
|
274
316
|
if (control.schemaVersion !== 'depa.flow-run-checkpoint-control/v1') throw new Error('Checkpoint control schema is invalid.');
|
|
@@ -339,7 +381,15 @@ function reconstruct(
|
|
|
339
381
|
if (!Array.isArray(profileControl.definitionStepIds) || profileControl.definitionStepIds.some((id) => typeof id !== 'string' || !id)) throw new Error('Ctrl definition membership is invalid.');
|
|
340
382
|
const definitionStepIds = profileControl.definitionStepIds as readonly string[];
|
|
341
383
|
if (new Set(definitionStepIds).size !== definitionStepIds.length) throw new Error('Ctrl definition membership is duplicated.');
|
|
384
|
+
const savedOwners = object(profileControl.definitionInvocationOwners ?? {}, 'ctrl compiled action owners');
|
|
385
|
+
if (Object.values(savedOwners).some(owner => typeof owner !== 'string' || !definitionStepIds.includes(owner))) throw new Error('Ctrl compiled action owner is invalid.');
|
|
386
|
+
const membership = config.ctrlMembership?.(head, profileControl.kind);
|
|
387
|
+
if (membership && (canonicalJson([...definitionStepIds].sort(compare)) !== canonicalJson([...membership.definitionStepIds].sort(compare))
|
|
388
|
+
|| profileControl.definitionInvocationOwners !== undefined && canonicalJson(savedOwners) !== canonicalJson(membership.invocationOwners)))
|
|
389
|
+
throw new Error('Ctrl sidecar ownership differs from the frozen compiled definition.');
|
|
390
|
+
const invocationOwners = membership?.invocationOwners ?? savedOwners as Readonly<Record<string, string>>;
|
|
342
391
|
const snapshotControl = object(profileControl.snapshot, 'ctrl snapshot control');
|
|
392
|
+
const waitOwners=delegationWaitOwners(snapshotControl,definitionStepIds,invocationOwners,profileControl.kind==='AICtrlWorkflow'?profileControl.ai:undefined);
|
|
343
393
|
const orchestrationOutput = Object.fromEntries(
|
|
344
394
|
ORCHESTRATION_FIELDS.map((field) => [field, {}]),
|
|
345
395
|
) as Record<OrchestrationField, Record<string, FlowClosedValue>>;
|
|
@@ -365,7 +415,7 @@ function reconstruct(
|
|
|
365
415
|
actual.push(value.memberId);
|
|
366
416
|
for (const field of ['branchDecisions', 'nodeResults', 'waitResults'] as const) {
|
|
367
417
|
for (const [key, fact] of entries(value[field], `ctrl ${field}`)) {
|
|
368
|
-
const owner = definitionOwner(key, definitionStepIds);
|
|
418
|
+
const owner = definitionOwner(key, definitionStepIds, invocationOwners)??waitOwners.get(key);
|
|
369
419
|
if (projectedDefinition !== null ? owner !== projectedDefinition : value.memberId !== `runtime:${key}`) throw new Error(`Ctrl ${field} member is invalid.`);
|
|
370
420
|
if (Object.prototype.hasOwnProperty.call(output[field], key)) throw new Error(`Ctrl ${field} member is duplicated.`);
|
|
371
421
|
output[field][key] = fact;
|
|
@@ -374,14 +424,14 @@ function reconstruct(
|
|
|
374
424
|
const recordOrchestration = object(value.orchestration, 'ctrl record orchestration');
|
|
375
425
|
for (const field of ORCHESTRATION_FIELDS) {
|
|
376
426
|
for (const [key, fact] of entries(recordOrchestration[field], `ctrl orchestration.${field}`)) {
|
|
377
|
-
const owner = definitionOwner(key, definitionStepIds);
|
|
427
|
+
const owner = definitionOwner(key, definitionStepIds, invocationOwners)??waitOwners.get(key);
|
|
378
428
|
if (projectedDefinition !== null ? owner !== projectedDefinition : value.memberId !== `runtime:${key}`) throw new Error(`Ctrl orchestration.${field} member is invalid.`);
|
|
379
429
|
if (Object.prototype.hasOwnProperty.call(orchestrationOutput[field], key)) throw new Error(`Ctrl orchestration.${field} member is duplicated.`);
|
|
380
430
|
orchestrationOutput[field][key] = fact;
|
|
381
431
|
}
|
|
382
432
|
}
|
|
383
433
|
}
|
|
384
|
-
const runtimeMembers = runtimeCtrlMembers(orchestrationOutput, definitionStepIds);
|
|
434
|
+
const runtimeMembers = runtimeCtrlMembers(orchestrationOutput, definitionStepIds, invocationOwners,waitOwners);
|
|
385
435
|
const expected = [...definitionStepIds.map((id) => `definition:${id}`), ...runtimeMembers.map((id) => `runtime:${id}`)].sort(compare);
|
|
386
436
|
if (canonicalJson(expected) !== canonicalJson(actual.sort(compare))) throw new Error('Ctrl sidecar membership is invalid.');
|
|
387
437
|
const snapshot: FlowClosedObject = { ...snapshotControl, ...output };
|
|
@@ -469,9 +519,20 @@ function runEvent(runtime: CheckpointDurabilityRuntime | undefined, event: Check
|
|
|
469
519
|
runtime?.beforeEvent?.(event); operation(); runtime?.onEvent?.(event);
|
|
470
520
|
}
|
|
471
521
|
function syncDirectory(directory: string, role: CheckpointDurabilityRole, runtime?: CheckpointDurabilityRuntime): void {
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
522
|
+
runEvent(runtime, { operation: 'directory-fsync', role, path: directory }, () => {
|
|
523
|
+
let fd: number | undefined;
|
|
524
|
+
try {
|
|
525
|
+
fd = fs.openSync(directory, fs.constants.O_RDONLY);
|
|
526
|
+
fs.fsyncSync(fd);
|
|
527
|
+
} catch (error) {
|
|
528
|
+
// Windows may reject either opening or syncing a directory. File-data
|
|
529
|
+
// fsync and durability observers retain their original failure behavior.
|
|
530
|
+
const code = (error as NodeJS.ErrnoException).code;
|
|
531
|
+
if (process.platform !== 'win32' || !code || !['EPERM', 'EINVAL', 'ENOTSUP', 'EISDIR'].includes(code)) throw error;
|
|
532
|
+
} finally {
|
|
533
|
+
if (fd !== undefined) fs.closeSync(fd);
|
|
534
|
+
}
|
|
535
|
+
});
|
|
475
536
|
}
|
|
476
537
|
export function createDirectoryDurably(directory: string, role: CheckpointDurabilityRole, runtime?: CheckpointDurabilityRuntime): void {
|
|
477
538
|
runEvent(runtime, { operation: 'directory-create', role, path: directory }, () => fs.mkdirSync(directory));
|
|
@@ -500,7 +561,7 @@ function writeImmutable(file: string, value: unknown, role: 'record' | 'tree' |
|
|
|
500
561
|
|
|
501
562
|
export function encodeCheckpointSidecars(checkpointValue: AIWorkflowFlowRunCheckpoint, previousTree: StepMerkleTree | undefined, config: CheckpointSidecarEncodeConfig = {}): EncodedCheckpoint {
|
|
502
563
|
const checkpoint = normalizeFlowRunCheckpoint(checkpointValue);
|
|
503
|
-
const partitioned = checkpoint.profile.kind === 'AIDataWorkflow' ? partitionData(checkpoint, config.definitionStepIds) : partitionCtrl(checkpoint, config.definitionStepIds);
|
|
564
|
+
const partitioned = checkpoint.profile.kind === 'AIDataWorkflow' ? partitionData(checkpoint, config.definitionStepIds) : partitionCtrl(checkpoint, config.definitionStepIds, config.definitionInvocationOwners);
|
|
504
565
|
const tree = buildStepMerkleTree(partitioned.records); const treeDiff = diffStepMerkleTrees(previousTree, tree);
|
|
505
566
|
const previousRecords = new Set<string>(); Object.values(previousTree?.nodes ?? {}).forEach((node) => { if (node.kind === 'leaf') previousRecords.add(node.recordDigest); });
|
|
506
567
|
const addedRecordDigests = partitioned.records.map((record) => record.digest).filter((value) => !previousRecords.has(value)).sort(compare);
|
|
@@ -525,7 +586,7 @@ function assertReceipt(root: string, head: FlowRunCheckpointHead): Record<string
|
|
|
525
586
|
if (value.schemaVersion !== 'depa.flow-run-checkpoint-sidecar-receipt/v1' || value.version !== head.version || value.rootDigest !== head.runStepStateRootDigest || value.logicalDigest !== head.logicalDigest || (value.previousRootDigest !== null && (typeof value.previousRootDigest !== 'string' || !/^sha256:[0-9a-f]{64}$/.test(value.previousRootDigest)))) throw new Error('Receipt does not bind head.');
|
|
526
587
|
return value;
|
|
527
588
|
}
|
|
528
|
-
export function readCheckpointHeadAndLogical(file: string) {
|
|
589
|
+
export function readCheckpointHeadAndLogical(file: string, config: CheckpointSidecarReadConfig = {}) {
|
|
529
590
|
const head = normalizeHead(readJson(file)); const root = path.join(path.dirname(file), 'step-space'); const receipt = assertReceipt(root, head);
|
|
530
591
|
const controlRecords = new Map<DefinitionStepDigest, ImmutableStepRecord>();
|
|
531
592
|
for (const value of receipt.controlRecordDigests as readonly DefinitionStepDigest[]) {
|
|
@@ -536,7 +597,7 @@ export function readCheckpointHeadAndLogical(file: string) {
|
|
|
536
597
|
materialDigests.add(value);
|
|
537
598
|
return readRecord(path.join(root, 'records'), value);
|
|
538
599
|
};
|
|
539
|
-
const checkpoint = reconstruct(head, collectRecords(root, head.runStepStateRootDigest), controlRecords, readMaterial);
|
|
600
|
+
const checkpoint = reconstruct(head, collectRecords(root, head.runStepStateRootDigest), controlRecords, readMaterial, config);
|
|
540
601
|
if (digest(checkpoint) !== head.logicalDigest || checkpoint.instanceId !== head.instanceId || checkpoint.runId !== head.runId || checkpoint.version !== head.version || canonicalJson(checkpoint.definition) !== canonicalJson(head.definition)) throw new Error('Head and logical checkpoint differ.');
|
|
541
602
|
const nodes: Record<string, StepMerkleNode> = {}; const leafByKey: Record<string, DefinitionStepDigest> = {}; const keyOrder: string[] = [];
|
|
542
603
|
const visit = (value: DefinitionStepDigest): void => { if (nodes[value]) return; const node = readNode(path.join(root, 'trees'), value); nodes[value] = node; if (node.kind === 'leaf') { leafByKey[node.key] = value; keyOrder.push(node.key); } else { visit(node.leftDigest); visit(node.rightDigest); } };
|
package/src/checkpoint-store.ts
CHANGED
|
@@ -34,22 +34,38 @@ export interface WorkCtrlFlowCheckpointStoreOptions {
|
|
|
34
34
|
readonly projectOutput?: (snapshot: WorkCtrlFlowSnapshot) => FlowClosedValue;
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
-
/**
|
|
37
|
+
/** Projects engine-owned faults and optional fields; business values remain subject to closed-data validation. */
|
|
38
38
|
export function projectWorkCtrlSnapshotToCheckpointData(
|
|
39
39
|
snapshot: WorkCtrlFlowSnapshot,
|
|
40
40
|
): WorkCtrlFlowSnapshot {
|
|
41
|
+
const projectState = (state: unknown): unknown => {
|
|
42
|
+
if (!state || typeof state !== 'object') return state;
|
|
43
|
+
const fault: unknown = Object.getOwnPropertyDescriptor(state,'fault')?.value;
|
|
44
|
+
if(!(fault instanceof Error))return state;
|
|
45
|
+
const code: unknown = Object.getOwnPropertyDescriptor(fault, 'code')?.value;
|
|
46
|
+
return { ...state, fault: { name: fault.name, message: fault.message, ...(typeof code === 'string' ? { code } : {}) } };
|
|
47
|
+
};
|
|
41
48
|
const visit = (value: unknown): unknown => {
|
|
42
|
-
if (Array.isArray(value))
|
|
49
|
+
if (Array.isArray(value)) {
|
|
50
|
+
if(Reflect.ownKeys(value).length!==value.length+1)return value;
|
|
51
|
+
const items:unknown[]=[];
|
|
52
|
+
for(let index=0;index<value.length;index++){
|
|
53
|
+
const descriptor=Object.getOwnPropertyDescriptor(value,String(index));
|
|
54
|
+
if(!descriptor||!('value'in descriptor))return value;
|
|
55
|
+
items.push(visit(descriptor.value));
|
|
56
|
+
}
|
|
57
|
+
return items;
|
|
58
|
+
}
|
|
43
59
|
if (value === null || typeof value !== 'object') return value;
|
|
44
60
|
const prototype = Object.getPrototypeOf(value);
|
|
45
61
|
if (prototype !== Object.prototype && prototype !== null) return value;
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
.map(([key, nested]) => [key, visit(nested)]),
|
|
50
|
-
);
|
|
62
|
+
const descriptors=Object.getOwnPropertyDescriptors(value);
|
|
63
|
+
if(Reflect.ownKeys(value).some(key=>typeof key!=='string'||!('value'in descriptors[key]!)))return value;
|
|
64
|
+
return Object.fromEntries(Object.entries(descriptors).filter(([,descriptor])=>descriptor.value!==undefined).map(([key,descriptor])=>[key,visit(descriptor.value)]));
|
|
51
65
|
};
|
|
52
|
-
return visit(snapshot)
|
|
66
|
+
return visit({ ...snapshot, vars: { ...snapshot.vars, __flow_execution__: projectState(snapshot.vars.__flow_execution__) }, ...(snapshot.orchestration ? { orchestration: { ...snapshot.orchestration,
|
|
67
|
+
lanes: Object.fromEntries(Object.entries(snapshot.orchestration.lanes).map(([key, lane]) => [key, { ...lane, ...(lane.flowState === undefined ? {} : { flowState: projectState(lane.flowState) }) }])),
|
|
68
|
+
} } : {}) }) as WorkCtrlFlowSnapshot;
|
|
53
69
|
}
|
|
54
70
|
|
|
55
71
|
function workCtrlControllerSidecars(snapshot: WorkCtrlFlowSnapshot): FlowClosedObject {
|
|
@@ -283,6 +299,9 @@ function validateAIDataProfileTransition(
|
|
|
283
299
|
`AI Data node ${nodeId} generation cannot decrease from ${node.generation} to ${candidate.generation}.`,
|
|
284
300
|
);
|
|
285
301
|
}
|
|
302
|
+
const waitContext=node.result&&typeof node.result==='object'?Object.getOwnPropertyDescriptor(node.result,'waitContext')?.value:undefined;
|
|
303
|
+
const nextContext=candidate.result&&typeof candidate.result==='object'?Object.getOwnPropertyDescriptor(candidate.result,'waitContext')?.value:undefined;
|
|
304
|
+
if(node.generation===candidate.generation&&waitContext!==undefined&&!sameClosedValue(waitContext,nextContext))throw new FlowRunCheckpointTransitionError(`AI Data native wait context ${nodeId}@${node.generation} is immutable.`);
|
|
286
305
|
}
|
|
287
306
|
const appendedPatches = after.patchHistory.slice(before.patchHistory.length);
|
|
288
307
|
const appendedInvalidations = after.invalidations.filter((fact) =>
|
|
@@ -469,6 +488,7 @@ function validateAgentStateTransition(
|
|
|
469
488
|
|| (next.profile.kind !== 'AICtrlWorkflow' && next.profile.kind !== 'AIDataWorkflow')) return;
|
|
470
489
|
const before = current.profile.ai;
|
|
471
490
|
const after = next.profile.ai;
|
|
491
|
+
validateWorkflowDelegationTransition(before.delegationsByKey ?? {}, after.delegationsByKey ?? {});
|
|
472
492
|
for (const [instanceId, instance] of Object.entries(before.instancesById)) {
|
|
473
493
|
if (!sameClosedValue(instance, after.instancesById[instanceId])) {
|
|
474
494
|
throw new FlowRunCheckpointTransitionError(`Accepted Agent instance ${instanceId} is immutable and cannot disappear.`);
|
|
@@ -553,9 +573,38 @@ export function validateFlowRunCheckpointTransition(
|
|
|
553
573
|
);
|
|
554
574
|
}
|
|
555
575
|
validateStepExtensionTransition(current, next);
|
|
576
|
+
validateFrozenCtrlExpansion(current, next);
|
|
556
577
|
validateAgentStateTransition(current, next);
|
|
557
578
|
}
|
|
558
579
|
|
|
580
|
+
function validateFrozenCtrlExpansion(current: AIWorkflowFlowRunCheckpoint, next: AIWorkflowFlowRunCheckpoint): void {
|
|
581
|
+
if (current.profile.kind === 'AIDataWorkflow' || next.profile.kind === 'AIDataWorkflow') return;
|
|
582
|
+
function object(value: FlowClosedValue | undefined): FlowClosedObject {
|
|
583
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) throw new FlowRunCheckpointTransitionError('Ctrl orchestration facts are missing.');
|
|
584
|
+
return value as FlowClosedObject;
|
|
585
|
+
}
|
|
586
|
+
const before = object(current.profile.snapshot.orchestration);
|
|
587
|
+
const after = object(next.profile.snapshot.orchestration);
|
|
588
|
+
const composites = object(before.composites);
|
|
589
|
+
const nextComposites = object(after.composites);
|
|
590
|
+
const lanes = object(before.lanes), nextLanes = object(after.lanes);
|
|
591
|
+
for (const [key, value] of Object.entries(composites)) {
|
|
592
|
+
const fact = object(value);
|
|
593
|
+
if (!fact.expansion) continue;
|
|
594
|
+
const candidate = object(nextComposites[key]);
|
|
595
|
+
if (!sameClosedValue(fact.expansion, candidate.expansion)) throw new FlowRunCheckpointTransitionError(`ForEach ${key} cannot rewrite frozen expansion.`);
|
|
596
|
+
for (const [laneKey, value] of Object.entries(lanes)) {
|
|
597
|
+
const lane = object(value);
|
|
598
|
+
if (lane.invocationKey !== key) continue;
|
|
599
|
+
const candidateLane = object(nextLanes[laneKey]);
|
|
600
|
+
for (const field of ['invocationKey', 'laneId', 'bodyId', 'item']) {
|
|
601
|
+
if (!sameClosedValue(lane[field], candidateLane[field])) throw new FlowRunCheckpointTransitionError(`ForEach ${key} cannot rewrite frozen lane ${laneKey}.${field}.`);
|
|
602
|
+
}
|
|
603
|
+
if (lane.started === true && candidateLane.started !== true) throw new FlowRunCheckpointTransitionError(`ForEach ${key} cannot release an unsettled lane slot by rewriting its claim.`);
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
|
|
559
608
|
function validateStepExtensionTransition(
|
|
560
609
|
current: AIWorkflowFlowRunCheckpoint,
|
|
561
610
|
next: AIWorkflowFlowRunCheckpoint,
|
|
@@ -656,3 +705,4 @@ export async function projectFlowRunCheckpoint(
|
|
|
656
705
|
const checkpoint = await loadFlowRunCheckpoint(runtime, input);
|
|
657
706
|
return checkpoint === undefined ? undefined : normalizeFlowRunCheckpoint(checkpoint);
|
|
658
707
|
}
|
|
708
|
+
import { validateWorkflowDelegationTransition } from './delegation.js';
|
package/src/core.ts
CHANGED
|
@@ -47,6 +47,7 @@ export {
|
|
|
47
47
|
export {
|
|
48
48
|
AIAgentInvocationError,
|
|
49
49
|
bindAIAgentProcessors,
|
|
50
|
+
bindWorkflowNodeTasks,
|
|
50
51
|
normalizeAIAgentEffectConfig,
|
|
51
52
|
normalizeAIAgentInvocation,
|
|
52
53
|
normalizeAIAgentSelector,
|
|
@@ -122,27 +123,7 @@ export function assertAIWorkflowSubstrate(
|
|
|
122
123
|
}
|
|
123
124
|
}
|
|
124
125
|
|
|
125
|
-
export
|
|
126
|
-
definition: AICtrlWorkflowDefinitionBinding['definition'],
|
|
127
|
-
): AICtrlWorkflowDefinitionBinding {
|
|
128
|
-
return Object.freeze({
|
|
129
|
-
kind: 'AICtrlWorkflow',
|
|
130
|
-
substrate: 'WorkCtrlFlow',
|
|
131
|
-
descriptor: AI_CTRL_WORKFLOW_DESCRIPTOR,
|
|
132
|
-
definition,
|
|
133
|
-
});
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
export function defineAIDataWorkflow(
|
|
137
|
-
definition: AIDataWorkflowDefinitionBinding['definition'],
|
|
138
|
-
): AIDataWorkflowDefinitionBinding {
|
|
139
|
-
return Object.freeze({
|
|
140
|
-
kind: 'AIDataWorkflow',
|
|
141
|
-
substrate: 'EagerDataFlow',
|
|
142
|
-
descriptor: AI_DATA_WORKFLOW_DESCRIPTOR,
|
|
143
|
-
definition,
|
|
144
|
-
});
|
|
145
|
-
}
|
|
126
|
+
export { defineAICtrlWorkflow, defineAIDataWorkflow } from './definitions.js';
|
|
146
127
|
|
|
147
128
|
export function projectAIWorkflowAppBundles(
|
|
148
129
|
registry: AIWorkflowResourceRegistryView,
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
AICtrlWorkflowDefinitionBinding,
|
|
3
|
+
AIDataWorkflowDefinitionBinding,
|
|
4
|
+
} from "ai-workflow-contract";
|
|
5
|
+
import {
|
|
6
|
+
AI_CTRL_WORKFLOW_DESCRIPTOR,
|
|
7
|
+
AI_DATA_WORKFLOW_DESCRIPTOR,
|
|
8
|
+
} from "ai-workflow-contract/profiles";
|
|
9
|
+
|
|
10
|
+
export function defineAICtrlWorkflow(
|
|
11
|
+
definition: AICtrlWorkflowDefinitionBinding["definition"],
|
|
12
|
+
): AICtrlWorkflowDefinitionBinding {
|
|
13
|
+
return Object.freeze({
|
|
14
|
+
kind: "AICtrlWorkflow",
|
|
15
|
+
substrate: "WorkCtrlFlow",
|
|
16
|
+
descriptor: AI_CTRL_WORKFLOW_DESCRIPTOR,
|
|
17
|
+
definition,
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function defineAIDataWorkflow(
|
|
22
|
+
definition: AIDataWorkflowDefinitionBinding["definition"],
|
|
23
|
+
): AIDataWorkflowDefinitionBinding {
|
|
24
|
+
return Object.freeze({
|
|
25
|
+
kind: "AIDataWorkflow",
|
|
26
|
+
substrate: "EagerDataFlow",
|
|
27
|
+
descriptor: AI_DATA_WORKFLOW_DESCRIPTOR,
|
|
28
|
+
definition,
|
|
29
|
+
});
|
|
30
|
+
}
|
|
@@ -0,0 +1,387 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
AIAgentProcessorRuntime, AIWorkflowFlowRunCheckpoint, FlowClosedValue,
|
|
3
|
+
WorkflowTaskDelegation, WorkflowDelegationRequest, WorkflowDelegationObservation,
|
|
4
|
+
WorkflowDelegationRecord, WorkflowDelegationEffectPort, WorkflowDelegationOwnerTarget,
|
|
5
|
+
} from 'ai-workflow-contract';
|
|
6
|
+
import { normalizeFlowClosedValue } from './run-checkpoint.js';
|
|
7
|
+
import { assertAIAgentNodeTaskBinding } from './agent-state.js';
|
|
8
|
+
|
|
9
|
+
export class WorkflowDelegationError extends Error {
|
|
10
|
+
constructor(readonly code: string) { super(code); this.name = 'WorkflowDelegationError'; }
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export class WorkflowDelegationSuspendedError extends Error {
|
|
14
|
+
constructor(readonly invocationKey: string, readonly status: string, readonly cause?: unknown) {
|
|
15
|
+
super(`Workflow delegation ${invocationKey} is ${status}; observe the original invocation.`);
|
|
16
|
+
this.name = 'WorkflowDelegationSuspendedError';
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function assert(condition: unknown, code: string): asserts condition {
|
|
21
|
+
if (!condition) throw new WorkflowDelegationError(code);
|
|
22
|
+
}
|
|
23
|
+
function dataRecord(value: unknown): Record<string, FlowClosedValue> {
|
|
24
|
+
// Normalize before accessing properties: getters, symbols and non-data values are rejected.
|
|
25
|
+
const data = normalizeFlowClosedValue(value, 'delegation');
|
|
26
|
+
assert(data !== null && typeof data === 'object' && !Array.isArray(data), 'DELEGATION_OBJECT_REQUIRED');
|
|
27
|
+
return data as Record<string, FlowClosedValue>;
|
|
28
|
+
}
|
|
29
|
+
function object(value: unknown, required: string[], optional: string[] = []): Record<string, FlowClosedValue> {
|
|
30
|
+
const row = dataRecord(value);
|
|
31
|
+
assert(required.every(key => Object.hasOwn(row, key)) && Object.keys(row).every(key => [...required, ...optional].includes(key)), 'DELEGATION_FIELDS_INVALID');
|
|
32
|
+
return row;
|
|
33
|
+
}
|
|
34
|
+
function identity(value: unknown): asserts value is string {
|
|
35
|
+
assert(typeof value === 'string' && value.length > 0 && value.trim() === value && value.normalize('NFC') === value, 'DELEGATION_IDENTITY_INVALID');
|
|
36
|
+
}
|
|
37
|
+
function identityValue(value: unknown): string { identity(value); return value; }
|
|
38
|
+
function integer(value: unknown): asserts value is number {
|
|
39
|
+
assert(Number.isSafeInteger(value) && (value as number) >= 0, 'DELEGATION_INTEGER_INVALID');
|
|
40
|
+
}
|
|
41
|
+
function same(a: unknown, b: unknown): boolean {
|
|
42
|
+
function sorted(value: unknown): unknown {
|
|
43
|
+
if (Array.isArray(value)) return value.map(sorted);
|
|
44
|
+
if (!value || typeof value !== 'object') return value;
|
|
45
|
+
const row = value as Record<string, unknown>;
|
|
46
|
+
return Object.fromEntries(Object.keys(row).sort().map(key => [key, sorted(row[key])]));
|
|
47
|
+
}
|
|
48
|
+
return JSON.stringify(sorted(a)) === JSON.stringify(sorted(b));
|
|
49
|
+
}
|
|
50
|
+
function target(value: unknown): WorkflowDelegationOwnerTarget {
|
|
51
|
+
const row = object(value, ['owner', 'providerScope', 'targetId']);
|
|
52
|
+
return Object.freeze({ owner: identityValue(row.owner), providerScope: identityValue(row.providerScope), targetId: identityValue(row.targetId) });
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function normalizeWorkflowDelegation(value: unknown): WorkflowTaskDelegation {
|
|
56
|
+
const row = object(value, ['kind', 'task', 'context', 'sessionPolicy', 'materials'], ['execution']);
|
|
57
|
+
assert(row.kind === 'workflow.task-delegation/v1', 'DELEGATION_KIND_INVALID');
|
|
58
|
+
const task = object(row.task, ['nodeId', 'agentDefinitionRef', 'input']);
|
|
59
|
+
identity(task.nodeId); identity(task.agentDefinitionRef);
|
|
60
|
+
assert(task.agentDefinitionRef.startsWith('resource://') && task.agentDefinitionRef.length > 11, 'DELEGATION_TASK_REF_INVALID');
|
|
61
|
+
const contextInput = object(row.context, ['mode'], ['owner', 'providerScope', 'targetId', 'nodeId']);
|
|
62
|
+
let context: WorkflowTaskDelegation['context'];
|
|
63
|
+
if (contextInput.mode === 'fresh') {
|
|
64
|
+
object(contextInput, ['mode', 'owner', 'providerScope']);
|
|
65
|
+
context = { mode: 'fresh', owner: identityValue(contextInput.owner), providerScope: identityValue(contextInput.providerScope) };
|
|
66
|
+
} else if (contextInput.mode === 'reuse') {
|
|
67
|
+
object(contextInput, ['mode', 'owner', 'providerScope', 'targetId']);
|
|
68
|
+
context = { mode: 'reuse', ...target({ owner: contextInput.owner, providerScope: contextInput.providerScope, targetId: contextInput.targetId }) };
|
|
69
|
+
} else {
|
|
70
|
+
object(contextInput, ['mode', 'nodeId']); assert(contextInput.mode === 'from-node', 'DELEGATION_CONTEXT_INVALID');
|
|
71
|
+
context = { mode: 'from-node', nodeId: identityValue(contextInput.nodeId) };
|
|
72
|
+
}
|
|
73
|
+
assert(row.sessionPolicy === 'shared' || row.sessionPolicy === 'independent-per-generation', 'DELEGATION_SESSION_POLICY_INVALID');
|
|
74
|
+
assert(row.sessionPolicy !== 'independent-per-generation' || context.mode === 'fresh', 'DELEGATION_INDEPENDENCE_REQUIRED');
|
|
75
|
+
assert(Array.isArray(row.materials) && row.materials.length <= 17, 'DELEGATION_MATERIALS_INVALID');
|
|
76
|
+
let total = 0;
|
|
77
|
+
const ids = new Set<string>();
|
|
78
|
+
let instructions = 0;
|
|
79
|
+
const materials = row.materials.map((item: unknown) => {
|
|
80
|
+
const material = object(item, ['id', 'role', 'ref', 'sha256', 'bytes']);
|
|
81
|
+
identity(material.id); identity(material.ref); identity(material.sha256); integer(material.bytes);
|
|
82
|
+
assert((material.role === 'instruction' || material.role === 'auxiliary') && /^[a-f0-9]{64}$/.test(material.sha256), 'DELEGATION_MATERIAL_INVALID');
|
|
83
|
+
assert(!ids.has(material.id) && material.bytes <= 1048576, 'DELEGATION_MATERIAL_LIMIT');
|
|
84
|
+
ids.add(material.id); total += material.bytes;
|
|
85
|
+
if (material.role === 'instruction') instructions++;
|
|
86
|
+
return Object.freeze({ id: material.id, ref: material.ref, sha256: material.sha256, bytes: material.bytes, role: material.role });
|
|
87
|
+
});
|
|
88
|
+
assert(instructions <= 1 && materials.length - instructions <= 16 && total <= 8388608, 'DELEGATION_MATERIAL_LIMIT');
|
|
89
|
+
const execution=Object.hasOwn(row,'execution')?normalizeFlowClosedValue(row.execution,'delegation.execution'):undefined;
|
|
90
|
+
if(execution!==undefined)assert(new TextEncoder().encode(JSON.stringify(execution)).length<=16384,'DELEGATION_EXECUTION_LIMIT');
|
|
91
|
+
return Object.freeze({ kind: row.kind, task: Object.freeze({ nodeId: task.nodeId, agentDefinitionRef: task.agentDefinitionRef as `resource://${string}`, input: task.input }), context: Object.freeze(context), sessionPolicy: row.sessionPolicy, materials: Object.freeze(materials),...(execution===undefined?{}:{execution}) });
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function normalizeRequest(value: unknown): WorkflowDelegationRequest {
|
|
95
|
+
const row = object(value, ['delegation', 'lineage', 'attempt'], ['target']);
|
|
96
|
+
const lineage = object(row.lineage, ['instanceId', 'runId', 'nodeId', 'invocationKey', 'visit', 'generation'], ['sourceInvocationKey']);
|
|
97
|
+
for (const [key, value] of Object.entries(lineage)) key === 'generation' ? integer(value) : identity(value);
|
|
98
|
+
integer(lineage.generation);
|
|
99
|
+
integer(row.attempt);
|
|
100
|
+
const delegation = normalizeWorkflowDelegation(row.delegation);
|
|
101
|
+
assert(delegation.task.nodeId === lineage.nodeId, 'DELEGATION_LINEAGE_MISMATCH');
|
|
102
|
+
assert((delegation.context.mode === 'from-node') === Object.hasOwn(lineage, 'sourceInvocationKey'), 'DELEGATION_SOURCE_MISMATCH');
|
|
103
|
+
assert((delegation.context.mode !== 'fresh') === Object.hasOwn(row, 'target'), 'DELEGATION_TARGET_MISMATCH');
|
|
104
|
+
return { delegation, lineage: Object.freeze({
|
|
105
|
+
instanceId: identityValue(lineage.instanceId), runId: identityValue(lineage.runId), nodeId: identityValue(lineage.nodeId),
|
|
106
|
+
invocationKey: identityValue(lineage.invocationKey), visit: identityValue(lineage.visit), generation: lineage.generation,
|
|
107
|
+
...(lineage.sourceInvocationKey === undefined ? {} : { sourceInvocationKey: identityValue(lineage.sourceInvocationKey) }),
|
|
108
|
+
}), attempt: row.attempt, ...(row.target === undefined ? {} : { target: target(row.target) }) };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function normalizeWorkflowDelegationObservation(value: unknown, request: WorkflowDelegationRequest): WorkflowDelegationObservation {
|
|
112
|
+
const row = object(value, ['invocationKey', 'attempt', 'receiptId', 'status'], ['target', 'output', 'settled', 'reason']);
|
|
113
|
+
identity(row.receiptId);
|
|
114
|
+
assert(row.invocationKey === request.lineage.invocationKey && row.attempt === request.attempt, 'DELEGATION_RECEIPT_IDENTITY_MISMATCH');
|
|
115
|
+
if (row.status === 'completed') {
|
|
116
|
+
object(row, ['invocationKey', 'attempt', 'receiptId', 'status', 'target', 'output']);
|
|
117
|
+
const actual = target(row.target);
|
|
118
|
+
assertObservationTarget(actual, request);
|
|
119
|
+
} else if (row.status === 'failed') {
|
|
120
|
+
object(row, ['invocationKey', 'attempt', 'receiptId', 'status', 'target', 'settled', 'reason']);
|
|
121
|
+
assert(row.settled === true, 'DELEGATION_FAILURE_UNSETTLED'); identity(row.reason);
|
|
122
|
+
assertObservationTarget(target(row.target), request);
|
|
123
|
+
} else if (row.status === 'rejected' || row.status === 'not-dispatched') {
|
|
124
|
+
object(row, ['invocationKey', 'attempt', 'receiptId', 'status', 'settled', 'reason']);
|
|
125
|
+
assert(row.settled === true, 'DELEGATION_REJECTION_UNSETTLED'); identity(row.reason);
|
|
126
|
+
} else {
|
|
127
|
+
object(row, ['invocationKey', 'attempt', 'receiptId', 'status'], ['target']);
|
|
128
|
+
assert(['pending', 'busy', 'awaiting-user', 'unknown'].includes(identityValue(row.status)), 'DELEGATION_STATUS_INVALID');
|
|
129
|
+
if (row.target !== undefined) assertObservationTarget(target(row.target), request);
|
|
130
|
+
}
|
|
131
|
+
return Object.freeze(row) as WorkflowDelegationObservation;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function assertObservationTarget(actual: WorkflowDelegationOwnerTarget, request: WorkflowDelegationRequest): void {
|
|
135
|
+
if (request.target) assert(same(actual, request.target), 'DELEGATION_OWNER_TARGET_MISMATCH');
|
|
136
|
+
const context = request.delegation.context;
|
|
137
|
+
if (context.mode === 'fresh') assert(actual.owner === context.owner && actual.providerScope === context.providerScope, 'DELEGATION_OWNER_TARGET_MISMATCH');
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export function normalizeWorkflowDelegations(value: unknown): Readonly<Record<string, WorkflowDelegationRecord>> {
|
|
141
|
+
const normalized = normalizeFlowClosedValue(value, 'delegationsByKey');
|
|
142
|
+
assert(normalized && typeof normalized === 'object' && !Array.isArray(normalized), 'DELEGATION_INDEX_INVALID');
|
|
143
|
+
const result: Record<string, WorkflowDelegationRecord> = {};
|
|
144
|
+
for (const key of Object.keys(normalized).sort()) {
|
|
145
|
+
identity(key);
|
|
146
|
+
const row = object(dataRecord(normalized)[key], ['attempts']);
|
|
147
|
+
assert(Array.isArray(row.attempts) && row.attempts.length > 0, 'DELEGATION_ATTEMPTS_REQUIRED');
|
|
148
|
+
const attempts = row.attempts.map((item: unknown, index: number) => {
|
|
149
|
+
const attempt = object(item, ['request', 'dispatchClaimed'], ['observation']);
|
|
150
|
+
assert(typeof attempt.dispatchClaimed === 'boolean' && (attempt.observation === undefined || attempt.dispatchClaimed), 'DELEGATION_DISPATCH_CLAIM_INVALID');
|
|
151
|
+
const request = normalizeRequest(attempt.request);
|
|
152
|
+
assert(request.attempt === index && request.lineage.invocationKey === key, 'DELEGATION_ATTEMPT_IDENTITY_MISMATCH');
|
|
153
|
+
return Object.freeze({ request, dispatchClaimed: attempt.dispatchClaimed, ...(attempt.observation === undefined ? {} : { observation: normalizeWorkflowDelegationObservation(attempt.observation, request) }) });
|
|
154
|
+
});
|
|
155
|
+
for (let index = 1; index < attempts.length; index++) {
|
|
156
|
+
const previous = attempts[index - 1];
|
|
157
|
+
assert(previous.observation?.status === 'rejected' || previous.observation?.status === 'not-dispatched', 'DELEGATION_RETRY_FORBIDDEN');
|
|
158
|
+
assert(same({ ...previous.request, attempt: index }, attempts[index].request), 'DELEGATION_RETRY_INTENT_CHANGED');
|
|
159
|
+
}
|
|
160
|
+
result[key] = Object.freeze({ attempts: Object.freeze(attempts) });
|
|
161
|
+
}
|
|
162
|
+
return Object.freeze(result);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export function validateWorkflowDelegationTransition(before: Readonly<Record<string, WorkflowDelegationRecord>>, after: Readonly<Record<string, WorkflowDelegationRecord>>): void {
|
|
166
|
+
for (const [key, record] of Object.entries(before)) {
|
|
167
|
+
const next = after[key];
|
|
168
|
+
assert(next && next.attempts.length >= record.attempts.length && next.attempts.length <= record.attempts.length + 1, 'DELEGATION_HISTORY_IMMUTABLE');
|
|
169
|
+
record.attempts.forEach((attempt, index) => {
|
|
170
|
+
const candidate = next.attempts[index]!;
|
|
171
|
+
assert(same(attempt.request, candidate.request), 'DELEGATION_RESERVATION_IMMUTABLE');
|
|
172
|
+
assert(!attempt.dispatchClaimed || candidate.dispatchClaimed, 'DELEGATION_DISPATCH_CLAIM_IMMUTABLE');
|
|
173
|
+
if (attempt.observation && ['completed', 'failed', 'rejected', 'not-dispatched'].includes(attempt.observation.status)) {
|
|
174
|
+
assert(same(attempt, candidate), 'DELEGATION_TERMINAL_IMMUTABLE');
|
|
175
|
+
}
|
|
176
|
+
});
|
|
177
|
+
if (next.attempts.length > record.attempts.length) assert(!next.attempts.at(-1)!.observation, 'DELEGATION_RESERVATION_REQUIRED');
|
|
178
|
+
}
|
|
179
|
+
for (const [key, record] of Object.entries(after)) if (!before[key]) {
|
|
180
|
+
assert(record.attempts.length === 1 && !record.attempts[0]!.observation, 'DELEGATION_RESERVATION_REQUIRED');
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
export interface WorkflowDelegationRuntime extends Omit<AIAgentProcessorRuntime, 'effects'> {
|
|
185
|
+
readonly effects: WorkflowDelegationEffectPort;
|
|
186
|
+
readonly visit: string;
|
|
187
|
+
readonly generation: number;
|
|
188
|
+
/** Native scheduler/admission projection, never supplied by authored task input. */
|
|
189
|
+
readonly dependencyInvocations: Readonly<Record<string, string>>;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
async function load(runtime: WorkflowDelegationRuntime): Promise<AIWorkflowFlowRunCheckpoint> {
|
|
193
|
+
const checkpoint = await runtime.checkpointRuntime.checkpointStore.load(runtime.checkpointKey);
|
|
194
|
+
assert(checkpoint && checkpoint.instanceId === runtime.checkpointKey.instanceId && checkpoint.runId === runtime.checkpointKey.runId, 'DELEGATION_CHECKPOINT_MISSING');
|
|
195
|
+
assert(checkpoint.profile.kind === runtime.workflowKind && ['AICtrlWorkflow', 'AIDataWorkflow'].includes(checkpoint.profile.kind), 'DELEGATION_PROFILE_MISMATCH');
|
|
196
|
+
return checkpoint as AIWorkflowFlowRunCheckpoint;
|
|
197
|
+
}
|
|
198
|
+
function records(checkpoint: AIWorkflowFlowRunCheckpoint): Readonly<Record<string, WorkflowDelegationRecord>> {
|
|
199
|
+
assert(checkpoint.profile.kind === 'AICtrlWorkflow' || checkpoint.profile.kind === 'AIDataWorkflow', 'DELEGATION_PROFILE_MISMATCH');
|
|
200
|
+
return checkpoint.profile.ai.delegationsByKey ?? {};
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** Native loop frames and accepted data revisions establish serial order across visits.
|
|
204
|
+
* These identities come from the scheduler/checkpoint, never the authored task input.
|
|
205
|
+
* Same-visit sharing still requires the ordinary explicit predecessor relation.
|
|
206
|
+
*/
|
|
207
|
+
function isNativeSerialPredecessor(runtime: WorkflowDelegationRuntime, checkpoint: AIWorkflowFlowRunCheckpoint, previous: WorkflowDelegationRequest): boolean {
|
|
208
|
+
const before = previous.lineage;
|
|
209
|
+
if (before.instanceId !== checkpoint.instanceId || before.runId !== checkpoint.runId) return false;
|
|
210
|
+
if (checkpoint.profile.kind === 'AICtrlWorkflow') {
|
|
211
|
+
const frames = (visit: string, generation: number): { scope: string; loops: { key: string; iteration: number }[] } | undefined => {
|
|
212
|
+
let value: unknown;
|
|
213
|
+
try { value = JSON.parse(visit); } catch { return; }
|
|
214
|
+
if (!Array.isArray(value) || value.length !== 2 || typeof value[0] !== 'string' || !Array.isArray(value[1]) || !value[1].length) return;
|
|
215
|
+
const loops: { key: string; iteration: number }[] = [];
|
|
216
|
+
for (const item of value[1]) {
|
|
217
|
+
if (item === null || typeof item !== 'object' || Array.isArray(item)) return;
|
|
218
|
+
const row = dataRecord(item);
|
|
219
|
+
if (Object.keys(row).length !== 2 || typeof row.key !== 'string' || !row.key || typeof row.iteration !== 'number' || !Number.isSafeInteger(row.iteration) || row.iteration < 0) return;
|
|
220
|
+
loops.push({ key: row.key, iteration: row.iteration });
|
|
221
|
+
}
|
|
222
|
+
if (loops.at(-1)!.iteration !== generation) return;
|
|
223
|
+
return { scope: value[0], loops };
|
|
224
|
+
};
|
|
225
|
+
const oldVisit = frames(before.visit, before.generation), currentVisit = frames(runtime.visit, runtime.generation);
|
|
226
|
+
if (!oldVisit || !currentVisit || oldVisit.scope !== currentVisit.scope || oldVisit.loops.length !== currentVisit.loops.length) return false;
|
|
227
|
+
if (oldVisit.loops.some((frame, index) => frame.key !== currentVisit.loops[index]!.key)) return false;
|
|
228
|
+
// An outer iteration also orders all of its inner iterations, even after an inner reset.
|
|
229
|
+
const changed = oldVisit.loops.findIndex((frame, index) => frame.iteration !== currentVisit.loops[index]!.iteration);
|
|
230
|
+
return changed >= 0 && oldVisit.loops[changed]!.iteration < currentVisit.loops[changed]!.iteration;
|
|
231
|
+
}
|
|
232
|
+
if (checkpoint.profile.kind === 'AIDataWorkflow') {
|
|
233
|
+
if (before.nodeId !== runtime.nodeId || before.generation >= runtime.generation || before.visit !== `generation:${before.generation}` || runtime.visit !== `generation:${runtime.generation}`) return false;
|
|
234
|
+
const graph = dataRecord(checkpoint.profile.runGraph), nodes = dataRecord(graph.nodes), node = dataRecord(nodes[runtime.nodeId]);
|
|
235
|
+
if (graph.currentGeneration !== runtime.generation || node.generation !== runtime.generation) return false;
|
|
236
|
+
// A number supplied by a caller is insufficient: require the persisted native revision
|
|
237
|
+
// which actually reopened this node, or its still-active reciprocal invalidation fact.
|
|
238
|
+
const invalidations = Array.isArray(graph.invalidations) ? graph.invalidations : [];
|
|
239
|
+
if (invalidations.some(value => { const fact = dataRecord(value); return fact.nodeId === runtime.nodeId && fact.generation === runtime.generation; })) return true;
|
|
240
|
+
const patches = Array.isArray(graph.patchHistory) ? graph.patchHistory : [];
|
|
241
|
+
return patches.some(value => {
|
|
242
|
+
const patch = dataRecord(value);
|
|
243
|
+
return patch.generation === runtime.generation && Array.isArray(patch.operations) && patch.operations.some(value => {
|
|
244
|
+
const operation = dataRecord(value);
|
|
245
|
+
return operation.op === 'update-node' && operation.nodeId === runtime.nodeId;
|
|
246
|
+
});
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
return false;
|
|
250
|
+
}
|
|
251
|
+
async function commit(runtime: WorkflowDelegationRuntime, checkpoint: AIWorkflowFlowRunCheckpoint, key: string, record: WorkflowDelegationRecord): Promise<void> {
|
|
252
|
+
assert(checkpoint.profile.kind === 'AICtrlWorkflow' || checkpoint.profile.kind === 'AIDataWorkflow', 'DELEGATION_PROFILE_MISMATCH');
|
|
253
|
+
await runtime.checkpointRuntime.checkpointStore.commit({ expectedVersion: checkpoint.version, checkpoint: {
|
|
254
|
+
...checkpoint, version: checkpoint.version + 1,
|
|
255
|
+
profile: { ...checkpoint.profile, ai: { ...checkpoint.profile.ai, delegationsByKey: { ...records(checkpoint), [key]: record } } },
|
|
256
|
+
} });
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/** A typed result is returned while pending; native adapters suspend rather than inventing output. */
|
|
260
|
+
export async function delegateWorkflowTask(runtime: WorkflowDelegationRuntime, input: WorkflowTaskDelegation): Promise<WorkflowDelegationObservation> {
|
|
261
|
+
const delegation = normalizeWorkflowDelegation(input);
|
|
262
|
+
assert(delegation.task.nodeId === runtime.nodeId, 'DELEGATION_NODE_MISMATCH');
|
|
263
|
+
assertAIAgentNodeTaskBinding(runtime.taskBinding, { workflowKind: runtime.workflowKind, workflowRef: runtime.workflowRef, nodeId: runtime.nodeId }, { agentDefinitionRef: delegation.task.agentDefinitionRef });
|
|
264
|
+
identity(runtime.visit); integer(runtime.generation);
|
|
265
|
+
let checkpoint = await load(runtime);
|
|
266
|
+
// Target, policy, and materials are admitted with the immutable run config, not task input.
|
|
267
|
+
const binding = { ...delegation, task: { ...delegation.task, input: null } };
|
|
268
|
+
const bindings = dataRecord(dataRecord(checkpoint.config).workflowDelegations);
|
|
269
|
+
assert(same(bindings[runtime.nodeId], binding), 'DELEGATION_FROZEN_BINDING_MISMATCH');
|
|
270
|
+
const key = runtime.invocationKey;
|
|
271
|
+
let record = records(checkpoint)[key];
|
|
272
|
+
let request: WorkflowDelegationRequest;
|
|
273
|
+
let winner = false;
|
|
274
|
+
if (record) {
|
|
275
|
+
request = record.attempts.at(-1)!.request;
|
|
276
|
+
assert(same(request.delegation, delegation) && request.lineage.visit === runtime.visit && request.lineage.generation === runtime.generation && request.lineage.instanceId === checkpoint.instanceId && request.lineage.runId === checkpoint.runId && request.lineage.nodeId === runtime.nodeId, 'DELEGATION_INVOCATION_CONFLICT');
|
|
277
|
+
} else {
|
|
278
|
+
let sourceInvocationKey: string | undefined;
|
|
279
|
+
let selected: WorkflowDelegationOwnerTarget | undefined;
|
|
280
|
+
const context = delegation.context;
|
|
281
|
+
if (context.mode === 'reuse') selected = target({ owner: context.owner, providerScope: context.providerScope, targetId: context.targetId });
|
|
282
|
+
if (context.mode === 'from-node') {
|
|
283
|
+
sourceInvocationKey = runtime.dependencyInvocations[context.nodeId];
|
|
284
|
+
assert(sourceInvocationKey && context.nodeId !== runtime.nodeId, 'DELEGATION_DEPENDENCY_REQUIRED');
|
|
285
|
+
const source = records(checkpoint)[sourceInvocationKey]?.attempts.at(-1);
|
|
286
|
+
assert(source?.observation?.status === 'completed' && source.request.lineage.nodeId === context.nodeId, 'DELEGATION_SOURCE_UNFINISHED');
|
|
287
|
+
assert(source.request.lineage.instanceId === checkpoint.instanceId && source.request.lineage.runId === checkpoint.runId, 'DELEGATION_SOURCE_SCOPE_MISMATCH');
|
|
288
|
+
assert(source.request.lineage.generation === runtime.generation && source.request.lineage.visit === runtime.visit, 'DELEGATION_SOURCE_STALE');
|
|
289
|
+
assert(source.request.delegation.sessionPolicy !== 'independent-per-generation', 'DELEGATION_INDEPENDENCE_REQUIRED');
|
|
290
|
+
selected = source.observation.target;
|
|
291
|
+
}
|
|
292
|
+
for (const existing of Object.values(records(checkpoint))) {
|
|
293
|
+
const last = existing.attempts.at(-1)!;
|
|
294
|
+
const terminal = last.observation && ['completed', 'failed', 'rejected', 'not-dispatched'].includes(last.observation.status);
|
|
295
|
+
const actual = last.observation && 'target' in last.observation ? last.observation.target ?? last.request.target : last.request.target;
|
|
296
|
+
if (selected && actual && same(selected, actual)) {
|
|
297
|
+
assert(terminal && (Object.values(runtime.dependencyInvocations).includes(last.request.lineage.invocationKey) || isNativeSerialPredecessor(runtime, checkpoint, last.request)), 'DELEGATION_SHARED_TARGET_CONCURRENT');
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
request = normalizeRequest({ delegation, lineage: { ...runtime.checkpointKey, nodeId: runtime.nodeId, invocationKey: key, visit: runtime.visit, generation: runtime.generation, ...(sourceInvocationKey ? { sourceInvocationKey } : {}) }, attempt: 0, ...(selected ? { target: selected } : {}) });
|
|
301
|
+
record = { attempts: [{ request, dispatchClaimed: false }] };
|
|
302
|
+
await runtime.effects.verifyMaterials(delegation.materials);
|
|
303
|
+
try { await commit(runtime, checkpoint, key, record); }
|
|
304
|
+
catch (error) {
|
|
305
|
+
const latest = await load(runtime);
|
|
306
|
+
const accepted = records(latest)[key];
|
|
307
|
+
if (!accepted || !same(accepted.attempts.at(-1)!.request, request)) throw error;
|
|
308
|
+
record = accepted;
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
const last = record.attempts.at(-1)!;
|
|
312
|
+
if (last.observation && ['completed', 'failed', 'rejected', 'not-dispatched'].includes(last.observation.status)) return last.observation;
|
|
313
|
+
await runtime.effects.verifyMaterials(delegation.materials);
|
|
314
|
+
if (!last.dispatchClaimed) {
|
|
315
|
+
checkpoint = await load(runtime);
|
|
316
|
+
record = records(checkpoint)[key]!;
|
|
317
|
+
if (!record.attempts.at(-1)!.dispatchClaimed) {
|
|
318
|
+
const claimed = { attempts: [...record.attempts.slice(0, -1), { ...record.attempts.at(-1)!, dispatchClaimed: true }] };
|
|
319
|
+
try { await commit(runtime, checkpoint, key, claimed); winner = true; }
|
|
320
|
+
catch (error) {
|
|
321
|
+
const accepted = records(await load(runtime))[key]?.attempts.at(-1);
|
|
322
|
+
if (!accepted?.dispatchClaimed || !same(accepted.request, request)) throw error;
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
// A lost submit receipt is uncertain. Never call submit again on recovery.
|
|
327
|
+
let response: WorkflowDelegationObservation;
|
|
328
|
+
try { response = await (winner ? runtime.effects.submit(request) : runtime.effects.observe(request)); }
|
|
329
|
+
catch (cause) { throw new WorkflowDelegationSuspendedError(key, 'unknown', cause); }
|
|
330
|
+
const observation = normalizeWorkflowDelegationObservation(response, request);
|
|
331
|
+
checkpoint = await load(runtime);
|
|
332
|
+
record = records(checkpoint)[key]!;
|
|
333
|
+
const latest = record.attempts.at(-1)!;
|
|
334
|
+
if (latest.observation && ['completed', 'failed', 'rejected', 'not-dispatched'].includes(latest.observation.status)) return latest.observation;
|
|
335
|
+
assert(same(latest.request, request), 'DELEGATION_ATTEMPT_CHANGED');
|
|
336
|
+
if (observation.status === 'completed' && delegation.context.mode === 'fresh') {
|
|
337
|
+
for (const [otherKey, other] of Object.entries(records(checkpoint))) {
|
|
338
|
+
if (otherKey === key) continue;
|
|
339
|
+
for (const attempt of other.attempts) {
|
|
340
|
+
assert(attempt.observation?.status !== 'completed' || !same(attempt.observation.target, observation.target), 'DELEGATION_FRESH_CONTEXT_REUSED');
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
const updated = { attempts: [...record.attempts.slice(0, -1), { request, dispatchClaimed: true, observation }] };
|
|
345
|
+
try { await commit(runtime, checkpoint, key, updated); }
|
|
346
|
+
catch (error) {
|
|
347
|
+
const accepted = records(await load(runtime))[key]?.attempts.at(-1)?.observation;
|
|
348
|
+
if (!accepted || !same(accepted, observation)) throw error;
|
|
349
|
+
}
|
|
350
|
+
return observation;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/** Explicit audited retry reservation. It never dispatches and never rewrites the failed attempt. */
|
|
354
|
+
export async function prepareWorkflowDelegationRetry(runtime: WorkflowDelegationRuntime, input: { expectedVersion: number; invocationKey: string; receiptId: string }): Promise<void> {
|
|
355
|
+
const checkpoint = await load(runtime);
|
|
356
|
+
assert(checkpoint.version === input.expectedVersion && input.invocationKey === runtime.invocationKey, 'DELEGATION_RETRY_CAS_MISMATCH');
|
|
357
|
+
const record = records(checkpoint)[input.invocationKey];
|
|
358
|
+
const last = record?.attempts.at(-1);
|
|
359
|
+
assert(last && last.observation && ['rejected', 'not-dispatched'].includes(last.observation.status) && last.observation.receiptId === input.receiptId, 'DELEGATION_RETRY_FORBIDDEN');
|
|
360
|
+
await commit(runtime, checkpoint, input.invocationKey, { attempts: [...record!.attempts, { request: { ...last.request, attempt: last.request.attempt + 1 }, dispatchClaimed: false }] });
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/** Bound by native node adapters. Authored code supplies only this task's business input. */
|
|
364
|
+
export function bindWorkflowTaskDelegation(runtime: Omit<WorkflowDelegationRuntime, 'dependencyInvocations'>): { delegateTask(input: FlowClosedValue): Promise<FlowClosedValue> } {
|
|
365
|
+
return Object.freeze({ async delegateTask(input: FlowClosedValue): Promise<FlowClosedValue> {
|
|
366
|
+
const checkpoint = await load({ ...runtime, dependencyInvocations: {} });
|
|
367
|
+
const config = dataRecord(checkpoint.config);
|
|
368
|
+
const binding = dataRecord(config.workflowDelegations)[runtime.nodeId];
|
|
369
|
+
assert(binding, 'DELEGATION_FROZEN_BINDING_MISSING');
|
|
370
|
+
const dependencies = (config.workflowDelegationDependencies === undefined ? {} : dataRecord(config.workflowDelegationDependencies))[runtime.nodeId] ?? [];
|
|
371
|
+
assert(Array.isArray(dependencies) && dependencies.every((id: unknown) => typeof id === 'string'), 'DELEGATION_DEPENDENCIES_INVALID');
|
|
372
|
+
const dependencyInvocations: Record<string, string> = {};
|
|
373
|
+
for (const nodeId of dependencies.map((id: unknown) => identityValue(id))) {
|
|
374
|
+
const matches = Object.values(records(checkpoint)).map(record => record.attempts.at(-1)!).filter(attempt => attempt.request.lineage.nodeId === nodeId && attempt.request.lineage.generation === runtime.generation && attempt.request.lineage.visit === runtime.visit);
|
|
375
|
+
assert(matches.length <= 1, 'DELEGATION_SOURCE_AMBIGUOUS');
|
|
376
|
+
if (matches.length === 1) dependencyInvocations[nodeId] = matches[0]!.request.lineage.invocationKey;
|
|
377
|
+
}
|
|
378
|
+
const normalizedBinding = normalizeWorkflowDelegation(binding);
|
|
379
|
+
// 已存在的调用沿保存的原lineage观察;新的循环寻址不能改写旧恢复身份。
|
|
380
|
+
const original = records(checkpoint)[runtime.invocationKey]?.attempts.at(-1)?.request;
|
|
381
|
+
const admittedRuntime = original ? { ...runtime, visit: original.lineage.visit, generation: original.lineage.generation } : runtime;
|
|
382
|
+
const observation = await delegateWorkflowTask({ ...admittedRuntime, dependencyInvocations }, { ...normalizedBinding, task: { ...normalizedBinding.task, input } });
|
|
383
|
+
if(observation.status==='failed')throw new WorkflowDelegationError(`DELEGATION_OWNER_FAILED:${observation.reason}`);
|
|
384
|
+
if (observation.status !== 'completed') throw new WorkflowDelegationSuspendedError(runtime.invocationKey, observation.status);
|
|
385
|
+
return observation.output;
|
|
386
|
+
} });
|
|
387
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
export * from './core.js';
|
|
2
|
+
export * from './delegation.js';
|
|
2
3
|
export { resolveAIAgentExecutionResources } from './agent-execution-resources.js';
|
|
3
4
|
export type { AIAgentExecutionResourceObservation, ResolveAIAgentExecutionResourcesRuntime, ResolveAIAgentExecutionResourcesInput } from './agent-execution-resources.js';
|
|
4
5
|
export {
|
package/src/run-checkpoint.ts
CHANGED
|
@@ -92,6 +92,15 @@ export function normalizeFlowRunCheckpoint(value: unknown): AIWorkflowFlowRunChe
|
|
|
92
92
|
? normalizeRunStepExtensions(dataField(record, 'stepExtensions', 'checkpoint.stepExtensions'))
|
|
93
93
|
: undefined;
|
|
94
94
|
const profile = normalizeProfile(dataField(record, 'profile', 'checkpoint.profile'), runId);
|
|
95
|
+
if (profile.kind !== 'WorkCtrlFlow') {
|
|
96
|
+
for (const [key, record] of Object.entries(profile.ai.delegationsByKey ?? {})) {
|
|
97
|
+
for (const attempt of record.attempts) {
|
|
98
|
+
if (attempt.request.lineage.instanceId !== instanceId || attempt.request.lineage.runId !== runId) {
|
|
99
|
+
invalid('DELEGATION_CHECKPOINT_SCOPE_MISMATCH', `checkpoint.profile.ai.delegationsByKey.${key}`, 'Delegation lineage must belong to the canonical instance and run.');
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
95
104
|
validateProfileProjection(profile, { input, state, output, controllerSidecars, nodeSidecars });
|
|
96
105
|
return Object.freeze({
|
|
97
106
|
schemaVersion,
|
|
@@ -471,7 +480,7 @@ function normalizeWorkCtrlNestedFacts(snapshot: Record<string, unknown>, locatio
|
|
|
471
480
|
if (kind === 'Branch' && !hasBranchKey) invalid('WORK_CTRL_BRANCH_KEY_MISSING', `${factLocation}.branchKey`, 'Branch decisions require a branch key.');
|
|
472
481
|
if (hasBranchKey) {
|
|
473
482
|
canonicalIdentity(dataField(fact, 'branchKey', `${factLocation}.branchKey`), `${factLocation}.branchKey`);
|
|
474
|
-
if (kind
|
|
483
|
+
if (kind === 'NoMatch') invalid('WORK_CTRL_BRANCH_KEY_INVALID', `${factLocation}.branchKey`, 'NoMatch decisions cannot retain a branch key.');
|
|
475
484
|
}
|
|
476
485
|
}
|
|
477
486
|
|
|
@@ -501,7 +510,7 @@ function normalizeWorkCtrlNestedFacts(snapshot: Record<string, unknown>, locatio
|
|
|
501
510
|
normalizeWorkCtrlAttempts(dataField(orchestration, 'attempts', `${orchestrationLocation}.attempts`), `${orchestrationLocation}.attempts`);
|
|
502
511
|
normalizeWorkCtrlDeadlines(dataField(orchestration, 'deadlines', `${orchestrationLocation}.deadlines`), `${orchestrationLocation}.deadlines`);
|
|
503
512
|
const lanes = normalizeWorkCtrlLanes(dataField(orchestration, 'lanes', `${orchestrationLocation}.lanes`), `${orchestrationLocation}.lanes`);
|
|
504
|
-
normalizeWorkCtrlComposites(dataField(orchestration, 'composites', `${orchestrationLocation}.composites`), `${orchestrationLocation}.composites
|
|
513
|
+
normalizeWorkCtrlComposites(dataField(orchestration, 'composites', `${orchestrationLocation}.composites`), `${orchestrationLocation}.composites`, lanes);
|
|
505
514
|
normalizeWorkCtrlWinners(dataField(orchestration, 'winners', `${orchestrationLocation}.winners`), lanes, `${orchestrationLocation}.winners`);
|
|
506
515
|
normalizeWorkCtrlCancellations(dataField(orchestration, 'cancellations', `${orchestrationLocation}.cancellations`), `${orchestrationLocation}.cancellations`);
|
|
507
516
|
}
|
|
@@ -542,7 +551,8 @@ function normalizeWorkCtrlDeadlines(value: unknown, location: string): void {
|
|
|
542
551
|
function normalizeWorkCtrlLanes(value: unknown, location: string): Map<string, { invocationKey: string; laneId: string; status: string }> {
|
|
543
552
|
const output = new Map<string, { invocationKey: string; laneId: string; status: string }>();
|
|
544
553
|
for (const [key, item, itemLocation] of closedRecordEntries(value, location)) {
|
|
545
|
-
const fact = exactRecord(item, ['invocationKey', 'laneId', 'status', 'bodyId'], ['item', 'flowState', 'output'], itemLocation);
|
|
554
|
+
const fact = exactRecord(item, ['invocationKey', 'laneId', 'status', 'bodyId'], ['item', 'flowState', 'output', 'started'], itemLocation);
|
|
555
|
+
if (Object.hasOwn(fact, 'started') && typeof dataField(fact, 'started', `${itemLocation}.started`) !== 'boolean') invalid('WORK_CTRL_LANE_STARTED_INVALID', `${itemLocation}.started`, 'Lane slot claim must be boolean.');
|
|
546
556
|
const invocationKey = canonicalIdentity(dataField(fact, 'invocationKey', `${itemLocation}.invocationKey`), `${itemLocation}.invocationKey`);
|
|
547
557
|
const laneId = canonicalIdentity(dataField(fact, 'laneId', `${itemLocation}.laneId`), `${itemLocation}.laneId`);
|
|
548
558
|
if (key !== `${invocationKey}/${laneId}`) invalid('WORK_CTRL_LANE_IDENTITY_MISMATCH', itemLocation, 'Lane map key must equal invocationKey/laneId.');
|
|
@@ -583,12 +593,22 @@ function normalizeWorkCtrlCancellations(value: unknown, location: string): void
|
|
|
583
593
|
}
|
|
584
594
|
}
|
|
585
595
|
|
|
586
|
-
function normalizeWorkCtrlComposites(value: unknown, location: string): void {
|
|
596
|
+
function normalizeWorkCtrlComposites(value: unknown, location: string, lanes: Map<string, { invocationKey: string; laneId: string; status: string }>): void {
|
|
587
597
|
for (const [key, item, itemLocation] of closedRecordEntries(value, location)) {
|
|
588
|
-
const fact = exactRecord(item, ['invocationKey', 'primitive', 'bodyId', 'status', 'updatedAtMs'], ['input', 'cursor', 'childTreeId'], itemLocation);
|
|
598
|
+
const fact = exactRecord(item, ['invocationKey', 'primitive', 'bodyId', 'status', 'updatedAtMs'], ['input', 'cursor', 'childTreeId', 'expansion'], itemLocation);
|
|
589
599
|
assertFactKey(fact, key, itemLocation);
|
|
590
600
|
const primitive = dataField(fact, 'primitive', `${itemLocation}.primitive`);
|
|
591
601
|
if (!['Retry', 'Timeout', 'ForEach', 'Parallel', 'Race', 'CallFlow'].includes(String(primitive))) invalid('WORK_CTRL_COMPOSITE_PRIMITIVE_INVALID', `${itemLocation}.primitive`, 'Composite primitive is invalid.');
|
|
602
|
+
if (Object.hasOwn(fact, 'expansion')) {
|
|
603
|
+
const location = `${itemLocation}.expansion`;
|
|
604
|
+
const expansion = exactRecord(dataField(fact, 'expansion', location), ['itemCount', 'concurrency'], ['maxItems'], location);
|
|
605
|
+
const itemCount = nonNegativeSafeInteger(dataField(expansion, 'itemCount', `${location}.itemCount`), `${location}.itemCount`);
|
|
606
|
+
const concurrency = nonNegativeSafeInteger(dataField(expansion, 'concurrency', `${location}.concurrency`), `${location}.concurrency`);
|
|
607
|
+
const maxItems = Object.hasOwn(expansion, 'maxItems') ? nonNegativeSafeInteger(dataField(expansion, 'maxItems', `${location}.maxItems`), `${location}.maxItems`) : undefined;
|
|
608
|
+
if (primitive !== 'ForEach' || concurrency < 1 || concurrency > Math.min(32, maxItems ?? 1000) || (maxItems !== undefined && (maxItems < 1 || maxItems > 1000 || itemCount > maxItems))) invalid('WORK_CTRL_EXPANSION_INVALID', location, 'Frozen ForEach expansion exceeds its budget.');
|
|
609
|
+
const expanded = [...lanes.values()].filter(lane => lane.invocationKey === key);
|
|
610
|
+
if (expanded.length !== itemCount || Array.from({ length: itemCount }, (_, index) => `${key}/${index}`).some(id => !lanes.has(id))) invalid('WORK_CTRL_EXPANSION_INCOMPLETE', location, 'Frozen ForEach lanes must cover the exact input order.');
|
|
611
|
+
}
|
|
592
612
|
canonicalIdentity(dataField(fact, 'bodyId', `${itemLocation}.bodyId`), `${itemLocation}.bodyId`);
|
|
593
613
|
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.');
|
|
594
614
|
nonNegativeSafeInteger(dataField(fact, 'updatedAtMs', `${itemLocation}.updatedAtMs`), `${itemLocation}.updatedAtMs`);
|
|
@@ -1412,7 +1432,7 @@ function normalizeAIDataNodeResult(
|
|
|
1412
1432
|
const result = exactRecord(
|
|
1413
1433
|
value,
|
|
1414
1434
|
['nodeId', 'generation', 'status'],
|
|
1415
|
-
['output', 'semanticFingerprint', 'reusedFrom'],
|
|
1435
|
+
['output', 'semanticFingerprint', 'reusedFrom', 'waitContext'],
|
|
1416
1436
|
location,
|
|
1417
1437
|
);
|
|
1418
1438
|
if (canonicalIdentity(dataField(result, 'nodeId', `${location}.nodeId`), `${location}.nodeId`) !== nodeId) {
|
|
@@ -1422,6 +1442,10 @@ function normalizeAIDataNodeResult(
|
|
|
1422
1442
|
invalid('AI_DATA_RESULT_GENERATION_INVALID', `${location}.generation`, 'Result generation must equal its run node generation.');
|
|
1423
1443
|
}
|
|
1424
1444
|
const resultStatus = String(dataField(result, 'status', `${location}.status`));
|
|
1445
|
+
if(Object.hasOwn(result,'waitContext')){
|
|
1446
|
+
const context=normalizeFlowClosedObject(dataField(result,'waitContext',`${location}.waitContext`),`${location}.waitContext`);
|
|
1447
|
+
if(new TextEncoder().encode(JSON.stringify(context)).length>16384)invalid('AI_DATA_WAIT_CONTEXT_BUDGET',`${location}.waitContext`,'Native wait context exceeds 16KiB.');
|
|
1448
|
+
}
|
|
1425
1449
|
if (!['Pending', 'Running', 'Waiting', 'Succeeded', 'Failed', 'Cancelled', 'Invalidated', 'Reused'].includes(resultStatus)) {
|
|
1426
1450
|
invalid('AI_DATA_RESULT_STATUS_INVALID', `${location}.status`, 'Result status is not part of the public contract.');
|
|
1427
1451
|
}
|