work-ctrl-flow-logic 0.1.0
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 +26 -0
- package/src/browser.ts +1 -0
- package/src/engine.ts +1255 -0
- package/src/filesystem.ts +34 -0
- package/src/index.ts +4 -0
- package/src/instance.ts +104 -0
- package/src/stores.ts +44 -0
package/src/engine.ts
ADDED
|
@@ -0,0 +1,1255 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WorkCtrlFlow 引擎(WF-1~WF-6)。
|
|
3
|
+
*
|
|
4
|
+
* 定义 / 实例分离:
|
|
5
|
+
* start 时把定义 bundle 物化为 instancesDir/<treeId>/ 的冻结拷贝,实例终身只从拷贝编译执行;
|
|
6
|
+
* 定义后续修改不影响在途实例(重放的 invocationKey 依赖树结构,这是正确性要求)。
|
|
7
|
+
*
|
|
8
|
+
* 执行模型:每个 tick 从快照出发**重放整棵树**——
|
|
9
|
+
* - 已完成动作按 invocationKey 命中 nodeResults → 直接返回记录结果(副作用不重复);
|
|
10
|
+
* - 已收敛等待命中 waitResults → 写回 payload 并返回记录结果;
|
|
11
|
+
* - 未收敛的可中断节点 → 登记 WaitHandle、通知 TickController 挂起,tick 落快照返回 WAITING。
|
|
12
|
+
* 恢复唯一入口是 ResumeSignal(resumeToken + appliedResumeSignals 双重幂等,WF-4)。
|
|
13
|
+
* 重放确定性假设(v1,见 mission 决策):控制流分支只依赖 vars/config/input。
|
|
14
|
+
*
|
|
15
|
+
* status 标注:每 tick 收尾把节点状态视图写回实例 manifest.xnl 的 { } 属性块
|
|
16
|
+
* (行为树枚举;Until 附 iterations;Tree 根附 runStatus/tickNo)。快照是真源,标注是单向视图。
|
|
17
|
+
*/
|
|
18
|
+
import {
|
|
19
|
+
CtrlFlowDsl,
|
|
20
|
+
BehaviorTreeEngine,
|
|
21
|
+
ActionHandlerRegistry,
|
|
22
|
+
BehaviorResult,
|
|
23
|
+
BehaviorTreeNodeKind,
|
|
24
|
+
BehaviorTreeNodeStatus,
|
|
25
|
+
evaluateExpression,
|
|
26
|
+
LogActionHandler,
|
|
27
|
+
AssertActionHandler,
|
|
28
|
+
SleepActionHandler,
|
|
29
|
+
type ActionHandler,
|
|
30
|
+
type BehaviorTreeData,
|
|
31
|
+
type BehaviorTreeNode,
|
|
32
|
+
type ExecutionContext,
|
|
33
|
+
type INodeConfig,
|
|
34
|
+
type CtrlFlowNodeType,
|
|
35
|
+
} from 'depa-behavior-tree';
|
|
36
|
+
import type { CtrlFlowResolver, FlowBundleSpec, FlowCodeResolver } from 'instant-ctrl-flow-contract';
|
|
37
|
+
import {
|
|
38
|
+
compileBundle,
|
|
39
|
+
FlowCompileError,
|
|
40
|
+
createFlowExecutionState,
|
|
41
|
+
createFlowHandlers,
|
|
42
|
+
flowStateOf,
|
|
43
|
+
FLOW_STATE_KEY,
|
|
44
|
+
runInstantCtrlFlowSpec,
|
|
45
|
+
type FlowBranchDecision,
|
|
46
|
+
type FlowDecisionHooks,
|
|
47
|
+
type CompileExtension,
|
|
48
|
+
} from 'instant-ctrl-flow-logic/browser';
|
|
49
|
+
import type {
|
|
50
|
+
CompositeInvocationFact,
|
|
51
|
+
CompositePrimitive,
|
|
52
|
+
LaneFact,
|
|
53
|
+
NodeAnnotation,
|
|
54
|
+
NodeViewStatus,
|
|
55
|
+
ResumeSignal,
|
|
56
|
+
TerminalResult,
|
|
57
|
+
TickOutcome,
|
|
58
|
+
TreeViewAnnotation,
|
|
59
|
+
WaitHandle,
|
|
60
|
+
WaitKind,
|
|
61
|
+
WorkCtrlFlowRuntime,
|
|
62
|
+
WorkCtrlFlowClock,
|
|
63
|
+
WorkCtrlFlowSnapshot,
|
|
64
|
+
WorkCtrlFlowStartOptions,
|
|
65
|
+
WorkCtrlFlowStore,
|
|
66
|
+
WorkCtrlFlowOrchestrationFacts,
|
|
67
|
+
DurableChildFlowResolver,
|
|
68
|
+
} from 'work-ctrl-flow-contract';
|
|
69
|
+
import { WORKFLOW_SNAPSHOT_SCHEMA } from 'work-ctrl-flow-contract';
|
|
70
|
+
|
|
71
|
+
type Ctx = ExecutionContext<CtrlFlowNodeType, INodeConfig>;
|
|
72
|
+
type CtrlNode = BehaviorTreeNode<CtrlFlowNodeType, INodeConfig>;
|
|
73
|
+
|
|
74
|
+
const WAIT_TAGS: Record<string, WaitKind> = {
|
|
75
|
+
ExternalJob: 'external-job',
|
|
76
|
+
Timer: 'timer',
|
|
77
|
+
TaskStep: 'task-step',
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
function emptyOrchestrationFacts(): WorkCtrlFlowOrchestrationFacts {
|
|
81
|
+
return {
|
|
82
|
+
attempts: {},
|
|
83
|
+
deadlines: {},
|
|
84
|
+
lanes: {},
|
|
85
|
+
winners: {},
|
|
86
|
+
cancellations: {},
|
|
87
|
+
composites: {},
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function ensureOrchestrationFacts(snapshot: WorkCtrlFlowSnapshot): WorkCtrlFlowOrchestrationFacts {
|
|
92
|
+
const facts = (snapshot.orchestration ??= emptyOrchestrationFacts());
|
|
93
|
+
facts.composites ??= {};
|
|
94
|
+
return facts;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** WorkCtrlFlow 层的编译扩展:可中断节点 → wf.* Type(BPCtrlFlow 复用并在其上扩展 TaskStep) */
|
|
98
|
+
export const workFlowCompileExtension: CompileExtension = (_spec, node, { meta, attrs, compileStatements }) => {
|
|
99
|
+
const kind = WAIT_TAGS[node.tag];
|
|
100
|
+
if (!kind) {
|
|
101
|
+
if (!['Retry', 'Timeout', 'ForEach', 'Parallel', 'Race', 'CallFlow'].includes(node.tag)) return null;
|
|
102
|
+
const section = node.tag === 'Parallel' ? node.sections.Lanes : node.tag === 'Race' ? node.sections.Candidates : undefined;
|
|
103
|
+
const items = section?.children.map((item) => ({
|
|
104
|
+
id: item.id ?? item.tag,
|
|
105
|
+
bodyPlan: compileStatements(item.children, `${meta.key}.${item.id ?? item.tag}`),
|
|
106
|
+
}));
|
|
107
|
+
return CtrlFlowDsl.node(
|
|
108
|
+
'wf.Composite' as CtrlFlowNodeType,
|
|
109
|
+
meta,
|
|
110
|
+
{
|
|
111
|
+
primitive: node.tag,
|
|
112
|
+
bodyId: node.tag === 'Retry' ? 'attempt:1' : 'body',
|
|
113
|
+
...attrs,
|
|
114
|
+
items,
|
|
115
|
+
bodyPlan: node.tag === 'CallFlow' ? undefined : compileStatements(node.children, `${meta.key}.__body`),
|
|
116
|
+
} as INodeConfig,
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
if (node.tag === 'TaskStep') {
|
|
120
|
+
// WorkCtrlFlow 不含 TaskStep(BP-5:由 bp-ctrl-flow 的扩展接管)
|
|
121
|
+
throw new FlowCompileError('interruptible-in-work-ctrl-flow', `<TaskStep #${node.id}> illegal in WorkCtrlFlow`);
|
|
122
|
+
}
|
|
123
|
+
return CtrlFlowDsl.node(`wf.${node.tag}` as CtrlFlowNodeType, meta, { ...attrs, __waitKind: kind } as INodeConfig);
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
/** tick 内挂起控制器 */
|
|
127
|
+
export class TickController {
|
|
128
|
+
suspended: WaitHandle[] = [];
|
|
129
|
+
/** 挂起后惰性执行过的节点(视图中应显示 INIT,见 computeView) */
|
|
130
|
+
inertKeys = new Set<string>();
|
|
131
|
+
constructor(
|
|
132
|
+
public snapshot: WorkCtrlFlowSnapshot,
|
|
133
|
+
public runtime: WorkCtrlFlowRuntime,
|
|
134
|
+
) {}
|
|
135
|
+
now(): number {
|
|
136
|
+
return this.runtime.now?.() ?? Date.now();
|
|
137
|
+
}
|
|
138
|
+
/** invocationKey 计数(每 tick 重置,重放确定性来源) */
|
|
139
|
+
private visitCounts = new Map<string, number>();
|
|
140
|
+
private invocationScopes: string[] = [];
|
|
141
|
+
invocationKeyFor(nodeKey: string): string {
|
|
142
|
+
const scopedNodeKey = [...this.invocationScopes, nodeKey].join('/');
|
|
143
|
+
const n = this.visitCounts.get(scopedNodeKey) ?? 0;
|
|
144
|
+
this.visitCounts.set(scopedNodeKey, n + 1);
|
|
145
|
+
return `${scopedNodeKey}#${n}`;
|
|
146
|
+
}
|
|
147
|
+
async withinInvocationScope<T>(scope: string, run: () => Promise<T>): Promise<T> {
|
|
148
|
+
this.invocationScopes.push(scope);
|
|
149
|
+
try {
|
|
150
|
+
return await run();
|
|
151
|
+
} finally {
|
|
152
|
+
this.invocationScopes.pop();
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
type Registry = ActionHandlerRegistry<CtrlFlowNodeType, INodeConfig>;
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Snapshot-driven actor for durable composite bodies. Product primitive policy
|
|
161
|
+
* is deliberately kept outside this scheduler foundation.
|
|
162
|
+
*/
|
|
163
|
+
export class DurableCompositeScheduler implements ActionHandler<CtrlFlowNodeType, INodeConfig> {
|
|
164
|
+
constructor(
|
|
165
|
+
private controller: () => TickController,
|
|
166
|
+
private registryFor: (
|
|
167
|
+
controller: () => TickController,
|
|
168
|
+
persist?: (snapshot: WorkCtrlFlowSnapshot) => Promise<void>,
|
|
169
|
+
) => Registry,
|
|
170
|
+
private persist: (snapshot: WorkCtrlFlowSnapshot) => Promise<void>,
|
|
171
|
+
private definition: WorkCtrlFlowDefinition,
|
|
172
|
+
private durableChildren?: DurableChildFlowResolver,
|
|
173
|
+
) {}
|
|
174
|
+
|
|
175
|
+
async execute(ctx: Ctx): Promise<BehaviorResult> {
|
|
176
|
+
const controller = this.controller();
|
|
177
|
+
if (controller.suspended.length > 0) {
|
|
178
|
+
controller.inertKeys.add(ctx.node.Key);
|
|
179
|
+
return BehaviorResult.Failure;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const config = ctx.node.Config as {
|
|
183
|
+
primitive: CompositePrimitive;
|
|
184
|
+
bodyId: string;
|
|
185
|
+
bodyPlan?: CtrlNode;
|
|
186
|
+
maxAttempts?: number;
|
|
187
|
+
backoffMs?: number;
|
|
188
|
+
timeoutMs?: number;
|
|
189
|
+
src?: string;
|
|
190
|
+
when?: string;
|
|
191
|
+
concurrency?: number;
|
|
192
|
+
config?: Record<string, unknown>;
|
|
193
|
+
mode?: 'first-success' | 'first-completed';
|
|
194
|
+
flow?: string;
|
|
195
|
+
items?: Array<{ id: string; bodyPlan: CtrlNode }>;
|
|
196
|
+
};
|
|
197
|
+
const invocationKey = controller.invocationKeyFor(ctx.node.Key);
|
|
198
|
+
let fact = controller.snapshot.orchestration.composites[invocationKey];
|
|
199
|
+
if (!fact) {
|
|
200
|
+
fact = {
|
|
201
|
+
invocationKey,
|
|
202
|
+
primitive: config.primitive,
|
|
203
|
+
bodyId: config.bodyId,
|
|
204
|
+
status: 'Open',
|
|
205
|
+
input: flowStateOf(ctx).current,
|
|
206
|
+
cursor: 0,
|
|
207
|
+
updatedAtMs: controller.now(),
|
|
208
|
+
};
|
|
209
|
+
controller.snapshot.orchestration.composites[invocationKey] = fact;
|
|
210
|
+
await this.persist(controller.snapshot);
|
|
211
|
+
}
|
|
212
|
+
if (fact.status === 'Completed') return BehaviorResult.Success;
|
|
213
|
+
if (fact.status === 'Failed' || fact.status === 'Cancelled') return BehaviorResult.Failure;
|
|
214
|
+
|
|
215
|
+
if (config.primitive === 'CallFlow') {
|
|
216
|
+
return this.runCallFlow(ctx, invocationKey, fact, config);
|
|
217
|
+
}
|
|
218
|
+
if (config.primitive === 'ForEach' || config.primitive === 'Parallel' || config.primitive === 'Race') {
|
|
219
|
+
return this.runLanes(ctx, invocationKey, fact, config);
|
|
220
|
+
}
|
|
221
|
+
if (config.primitive === 'Timeout') {
|
|
222
|
+
const deadline = controller.snapshot.orchestration.deadlines[invocationKey] ??= {
|
|
223
|
+
invocationKey,
|
|
224
|
+
deadlineAtMs: controller.now() + Math.max(0, Number(config.timeoutMs ?? 0)),
|
|
225
|
+
status: 'Open',
|
|
226
|
+
};
|
|
227
|
+
this.ensureCompositeTimer(controller, invocationKey, deadline.deadlineAtMs, 'timeout');
|
|
228
|
+
if (deadline.deadlineAtMs <= controller.now()) {
|
|
229
|
+
deadline.status = 'Expired';
|
|
230
|
+
fact.status = 'Cancelled';
|
|
231
|
+
controller.snapshot.orchestration.cancellations[invocationKey] = {
|
|
232
|
+
invocationKey,
|
|
233
|
+
reason: 'DeadlineExceeded',
|
|
234
|
+
atMs: controller.now(),
|
|
235
|
+
};
|
|
236
|
+
this.cancelScopedWaits(controller, invocationKey, 'DeadlineExceeded');
|
|
237
|
+
await this.persist(controller.snapshot);
|
|
238
|
+
return BehaviorResult.Failure;
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const body = config.bodyPlan;
|
|
243
|
+
if (!body) {
|
|
244
|
+
fact.status = 'Completed';
|
|
245
|
+
fact.updatedAtMs = controller.now();
|
|
246
|
+
await this.persist(controller.snapshot);
|
|
247
|
+
return BehaviorResult.Success;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
const attempt = controller.snapshot.orchestration.attempts[invocationKey] ??= {
|
|
251
|
+
invocationKey,
|
|
252
|
+
attempt: 1,
|
|
253
|
+
maxAttempts: Math.max(1, Number(config.maxAttempts ?? 1)),
|
|
254
|
+
status: 'Open',
|
|
255
|
+
};
|
|
256
|
+
if (config.primitive === 'Retry' && attempt.nextAttemptAtMs && attempt.nextAttemptAtMs > controller.now()) {
|
|
257
|
+
return this.openSchedulerTimer(controller, invocationKey, attempt.nextAttemptAtMs);
|
|
258
|
+
}
|
|
259
|
+
if (config.primitive === 'Retry' && attempt.nextAttemptAtMs) {
|
|
260
|
+
flowStateOf(ctx).fault = undefined;
|
|
261
|
+
flowStateOf(ctx).current = fact.input;
|
|
262
|
+
attempt.nextAttemptAtMs = undefined;
|
|
263
|
+
}
|
|
264
|
+
const bodyId = config.primitive === 'Retry' ? `attempt:${attempt.attempt}` : fact.bodyId;
|
|
265
|
+
const result = await controller.withinInvocationScope(
|
|
266
|
+
`${invocationKey}/${bodyId}`,
|
|
267
|
+
() => runScheduledBody(
|
|
268
|
+
structuredCloneTree(body),
|
|
269
|
+
this.registryFor(() => controller),
|
|
270
|
+
controller,
|
|
271
|
+
ctx.runtime,
|
|
272
|
+
flowStateOf(ctx),
|
|
273
|
+
),
|
|
274
|
+
);
|
|
275
|
+
if (config.primitive === 'Retry' && controller.suspended.length === 0 && result !== BehaviorResult.Success
|
|
276
|
+
&& attempt.attempt < attempt.maxAttempts) {
|
|
277
|
+
attempt.status = 'Failed';
|
|
278
|
+
attempt.attempt += 1;
|
|
279
|
+
attempt.status = 'Open';
|
|
280
|
+
attempt.nextAttemptAtMs = controller.now() + Math.max(0, Number(config.backoffMs ?? 0));
|
|
281
|
+
fact.bodyId = `attempt:${attempt.attempt}`;
|
|
282
|
+
fact.status = 'Waiting';
|
|
283
|
+
fact.updatedAtMs = controller.now();
|
|
284
|
+
await this.persist(controller.snapshot);
|
|
285
|
+
return attempt.nextAttemptAtMs > controller.now()
|
|
286
|
+
? this.openSchedulerTimer(controller, invocationKey, attempt.nextAttemptAtMs)
|
|
287
|
+
: BehaviorResult.Failure;
|
|
288
|
+
}
|
|
289
|
+
fact.status = controller.suspended.length > 0
|
|
290
|
+
? 'Waiting'
|
|
291
|
+
: result === BehaviorResult.Success
|
|
292
|
+
? 'Completed'
|
|
293
|
+
: 'Failed';
|
|
294
|
+
attempt.status = fact.status === 'Waiting'
|
|
295
|
+
? 'Open'
|
|
296
|
+
: fact.status === 'Completed'
|
|
297
|
+
? 'Completed'
|
|
298
|
+
: 'Failed';
|
|
299
|
+
if (config.primitive === 'Timeout' && fact.status !== 'Waiting') {
|
|
300
|
+
this.closeCompositeTimer(controller, invocationKey, 'timeout');
|
|
301
|
+
const deadline = controller.snapshot.orchestration.deadlines[invocationKey];
|
|
302
|
+
if (deadline && fact.status === 'Completed') deadline.status = 'Completed';
|
|
303
|
+
}
|
|
304
|
+
fact.updatedAtMs = controller.now();
|
|
305
|
+
await this.persist(controller.snapshot);
|
|
306
|
+
return controller.suspended.length > 0 ? BehaviorResult.Failure : result;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
private async runLanes(
|
|
310
|
+
ctx: Ctx,
|
|
311
|
+
invocationKey: string,
|
|
312
|
+
fact: CompositeInvocationFact,
|
|
313
|
+
config: {
|
|
314
|
+
primitive: CompositePrimitive;
|
|
315
|
+
src?: string;
|
|
316
|
+
when?: string;
|
|
317
|
+
mode?: 'first-success' | 'first-completed';
|
|
318
|
+
concurrency?: number;
|
|
319
|
+
config?: Record<string, unknown>;
|
|
320
|
+
bodyPlan?: CtrlNode;
|
|
321
|
+
items?: Array<{ id: string; bodyPlan: CtrlNode }>;
|
|
322
|
+
},
|
|
323
|
+
): Promise<BehaviorResult> {
|
|
324
|
+
const c = this.controller();
|
|
325
|
+
let items = config.items ?? [];
|
|
326
|
+
if (config.primitive === 'ForEach') {
|
|
327
|
+
const existing = Object.values(c.snapshot.orchestration.lanes)
|
|
328
|
+
.filter((lane) => lane.invocationKey === invocationKey)
|
|
329
|
+
.sort((a, b) => Number(a.laneId) - Number(b.laneId));
|
|
330
|
+
let selected: unknown[];
|
|
331
|
+
if (existing.length > 0) {
|
|
332
|
+
selected = existing.map((lane) => lane.item);
|
|
333
|
+
} else {
|
|
334
|
+
const selector = await this.definition.resolveCode({
|
|
335
|
+
reference: String(config.src ?? config.when ?? ''),
|
|
336
|
+
flowId: this.definition.spec.fqn,
|
|
337
|
+
nodeId: ctx.node.Key,
|
|
338
|
+
baseUri: this.definition.spec.baseUri,
|
|
339
|
+
});
|
|
340
|
+
const value = await selector(c.runtime, fact.input, config.config ?? {});
|
|
341
|
+
if (!Array.isArray(value)) throw new TypeError('ForEach selector must return an array');
|
|
342
|
+
selected = value;
|
|
343
|
+
}
|
|
344
|
+
items = selected.map((_item, index) => ({ id: String(index), bodyPlan: config.bodyPlan! }));
|
|
345
|
+
for (let i = 0; i < selected.length; i++) {
|
|
346
|
+
c.snapshot.orchestration.lanes[`${invocationKey}/${i}`] ??= {
|
|
347
|
+
invocationKey,
|
|
348
|
+
laneId: String(i),
|
|
349
|
+
bodyId: `item:${i}`,
|
|
350
|
+
item: selected[i],
|
|
351
|
+
flowState: createFlowExecutionState(selected[i]),
|
|
352
|
+
status: 'Open',
|
|
353
|
+
};
|
|
354
|
+
}
|
|
355
|
+
} else {
|
|
356
|
+
for (const item of items) c.snapshot.orchestration.lanes[`${invocationKey}/${item.id}`] ??= {
|
|
357
|
+
invocationKey,
|
|
358
|
+
laneId: item.id,
|
|
359
|
+
bodyId: `lane:${item.id}`,
|
|
360
|
+
flowState: createFlowExecutionState(fact.input),
|
|
361
|
+
status: 'Open',
|
|
362
|
+
};
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
const executeLane = async (item: { id: string; bodyPlan: CtrlNode }, signal?: AbortSignal) => {
|
|
366
|
+
// A lane can only publish through commitLaneOutcome. Late race losers keep
|
|
367
|
+
// running against this detached copy and cannot mutate persisted facts.
|
|
368
|
+
const workingSnapshot = structuredClone(c.snapshot);
|
|
369
|
+
const lane = workingSnapshot.orchestration.lanes[`${invocationKey}/${item.id}`];
|
|
370
|
+
const laneState = (lane.flowState ??= createFlowExecutionState(
|
|
371
|
+
config.primitive === 'ForEach' ? lane.item : fact.input,
|
|
372
|
+
)) as ReturnType<typeof createFlowExecutionState>;
|
|
373
|
+
laneState.fault = undefined;
|
|
374
|
+
const laneController = new TickController(workingSnapshot, c.runtime);
|
|
375
|
+
const laneRuntime = signal
|
|
376
|
+
? Object.assign(Object.create((ctx.runtime ?? {}) as object), { abortSignal: signal })
|
|
377
|
+
: ctx.runtime;
|
|
378
|
+
let result = BehaviorResult.Failure;
|
|
379
|
+
try {
|
|
380
|
+
result = await laneController.withinInvocationScope(
|
|
381
|
+
`${invocationKey}/${lane.bodyId}`,
|
|
382
|
+
() => runScheduledBody(
|
|
383
|
+
structuredCloneTree(item.bodyPlan),
|
|
384
|
+
this.registryFor(() => laneController, async () => {}),
|
|
385
|
+
laneController,
|
|
386
|
+
laneRuntime,
|
|
387
|
+
laneState,
|
|
388
|
+
),
|
|
389
|
+
);
|
|
390
|
+
} catch {
|
|
391
|
+
result = BehaviorResult.Failure;
|
|
392
|
+
}
|
|
393
|
+
if (laneController.suspended.length > 0) {
|
|
394
|
+
return { lane, snapshot: workingSnapshot, suspensions: laneController.suspended };
|
|
395
|
+
}
|
|
396
|
+
lane.status = result === BehaviorResult.Success ? 'Completed' : 'Failed';
|
|
397
|
+
lane.output = laneState.current;
|
|
398
|
+
return { lane, snapshot: workingSnapshot, suspensions: laneController.suspended };
|
|
399
|
+
};
|
|
400
|
+
|
|
401
|
+
const ready = items.filter((item) =>
|
|
402
|
+
c.snapshot.orchestration.lanes[`${invocationKey}/${item.id}`].status === 'Open');
|
|
403
|
+
const concurrency = config.primitive === 'ForEach'
|
|
404
|
+
? Math.max(1, Math.floor(Number(config.concurrency ?? 1)))
|
|
405
|
+
: Math.max(1, ready.length);
|
|
406
|
+
const completed: Awaited<ReturnType<typeof executeLane>>[] = [];
|
|
407
|
+
const isRaceWinner = (lane: LaneFact) =>
|
|
408
|
+
(config.mode ?? 'first-success') === 'first-completed'
|
|
409
|
+
? lane.status === 'Completed' || lane.status === 'Failed'
|
|
410
|
+
: lane.status === 'Completed';
|
|
411
|
+
for (let offset = 0; offset < ready.length; offset += concurrency) {
|
|
412
|
+
const batchItems = ready.slice(offset, offset + concurrency);
|
|
413
|
+
const controllers = batchItems.map(() => new AbortController());
|
|
414
|
+
const batch = batchItems.map((item, index) =>
|
|
415
|
+
executeLane(item, controllers[index].signal).catch(() => {
|
|
416
|
+
const failedSnapshot = structuredClone(c.snapshot);
|
|
417
|
+
const lane = failedSnapshot.orchestration.lanes[`${invocationKey}/${item.id}`];
|
|
418
|
+
lane.status = 'Failed';
|
|
419
|
+
return { lane, snapshot: failedSnapshot, suspensions: [] as WaitHandle[] };
|
|
420
|
+
}));
|
|
421
|
+
if (config.primitive === 'Race') {
|
|
422
|
+
const tagged = batch.map((promise, index) => promise.then((value) => ({ index, value })));
|
|
423
|
+
const pending = new Map(tagged.map((promise, index) => [index, promise]));
|
|
424
|
+
while (pending.size > 0) {
|
|
425
|
+
const settled = await Promise.race(pending.values());
|
|
426
|
+
pending.delete(settled.index);
|
|
427
|
+
completed.push(settled.value);
|
|
428
|
+
this.commitLaneOutcome(c.snapshot, invocationKey, settled.value);
|
|
429
|
+
if (isRaceWinner(settled.value.lane)) {
|
|
430
|
+
controllers.forEach((controller, index) => {
|
|
431
|
+
if (index !== settled.index) controller.abort();
|
|
432
|
+
});
|
|
433
|
+
break;
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
} else {
|
|
437
|
+
const settled = await Promise.all(batch);
|
|
438
|
+
completed.push(...settled);
|
|
439
|
+
for (const outcome of settled) this.commitLaneOutcome(c.snapshot, invocationKey, outcome);
|
|
440
|
+
}
|
|
441
|
+
if (config.primitive === 'Race' && completed.some(({ lane }) => isRaceWinner(lane))) break;
|
|
442
|
+
}
|
|
443
|
+
c.suspended.push(...completed.flatMap(({ suspensions }) => suspensions));
|
|
444
|
+
|
|
445
|
+
if (config.primitive === 'Race') {
|
|
446
|
+
const settled = completed.find(({ lane }) => isRaceWinner(lane));
|
|
447
|
+
if (settled) {
|
|
448
|
+
const lane = settled.lane;
|
|
449
|
+
c.snapshot.orchestration.winners[invocationKey] = {
|
|
450
|
+
invocationKey,
|
|
451
|
+
winnerId: lane.laneId,
|
|
452
|
+
mode: config.mode ?? 'first-success',
|
|
453
|
+
};
|
|
454
|
+
for (const loser of Object.values(c.snapshot.orchestration.lanes)) {
|
|
455
|
+
if (loser.invocationKey === invocationKey && loser.laneId !== lane.laneId) {
|
|
456
|
+
loser.status = 'Cancelled';
|
|
457
|
+
c.snapshot.orchestration.cancellations[`${invocationKey}/${loser.laneId}`] = {
|
|
458
|
+
invocationKey: `${invocationKey}/${loser.laneId}`,
|
|
459
|
+
reason: 'RaceLost',
|
|
460
|
+
atMs: c.now(),
|
|
461
|
+
};
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
this.cancelScopedWaits(c, invocationKey, 'RaceLost', lane.laneId);
|
|
465
|
+
flowStateOf(ctx).current = { winner: lane.laneId, output: lane.output };
|
|
466
|
+
const wonSuccessfully = lane.status === 'Completed';
|
|
467
|
+
fact.status = wonSuccessfully ? 'Completed' : 'Failed';
|
|
468
|
+
await this.persist(c.snapshot);
|
|
469
|
+
return wonSuccessfully ? BehaviorResult.Success : BehaviorResult.Failure;
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
const lanes = Object.values(c.snapshot.orchestration.lanes)
|
|
473
|
+
.filter((lane) => lane.invocationKey === invocationKey)
|
|
474
|
+
.sort((a, b) => a.laneId.localeCompare(b.laneId, undefined, { numeric: true }));
|
|
475
|
+
if (c.suspended.length > 0 || lanes.some((lane) => lane.status === 'Open')) {
|
|
476
|
+
fact.status = 'Waiting';
|
|
477
|
+
await this.persist(c.snapshot);
|
|
478
|
+
return BehaviorResult.Failure;
|
|
479
|
+
}
|
|
480
|
+
const success = config.primitive === 'Race'
|
|
481
|
+
? false
|
|
482
|
+
: lanes.every((lane) => lane.status === 'Completed');
|
|
483
|
+
if (success) {
|
|
484
|
+
flowStateOf(ctx).current = config.primitive === 'ForEach'
|
|
485
|
+
? lanes.map((lane) => lane.output)
|
|
486
|
+
: Object.fromEntries(lanes.map((lane) => [lane.laneId, lane.output]));
|
|
487
|
+
}
|
|
488
|
+
fact.status = success ? 'Completed' : 'Failed';
|
|
489
|
+
await this.persist(c.snapshot);
|
|
490
|
+
return success ? BehaviorResult.Success : BehaviorResult.Failure;
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
private commitLaneOutcome(
|
|
494
|
+
snapshot: WorkCtrlFlowSnapshot,
|
|
495
|
+
invocationKey: string,
|
|
496
|
+
outcome: {
|
|
497
|
+
lane: LaneFact;
|
|
498
|
+
snapshot: WorkCtrlFlowSnapshot;
|
|
499
|
+
suspensions: WaitHandle[];
|
|
500
|
+
},
|
|
501
|
+
): void {
|
|
502
|
+
const laneKey = `${invocationKey}/${outcome.lane.laneId}`;
|
|
503
|
+
const current = snapshot.orchestration.lanes[laneKey];
|
|
504
|
+
if (current.status === 'Cancelled') return;
|
|
505
|
+
snapshot.orchestration.lanes[laneKey] = structuredClone(outcome.lane);
|
|
506
|
+
|
|
507
|
+
const scope = `${invocationKey}/${outcome.lane.bodyId}/`;
|
|
508
|
+
this.mergeScopedRecord(snapshot.nodeResults, outcome.snapshot.nodeResults, scope);
|
|
509
|
+
this.mergeScopedRecord(snapshot.waitResults, outcome.snapshot.waitResults, scope);
|
|
510
|
+
this.mergeScopedRecord(snapshot.branchDecisions, outcome.snapshot.branchDecisions, scope);
|
|
511
|
+
this.mergeScopedRecord(snapshot.orchestration.attempts, outcome.snapshot.orchestration.attempts, scope);
|
|
512
|
+
this.mergeScopedRecord(snapshot.orchestration.deadlines, outcome.snapshot.orchestration.deadlines, scope);
|
|
513
|
+
this.mergeScopedRecord(snapshot.orchestration.lanes, outcome.snapshot.orchestration.lanes, scope);
|
|
514
|
+
this.mergeScopedRecord(snapshot.orchestration.winners, outcome.snapshot.orchestration.winners, scope);
|
|
515
|
+
this.mergeScopedRecord(snapshot.orchestration.cancellations, outcome.snapshot.orchestration.cancellations, scope);
|
|
516
|
+
this.mergeScopedRecord(snapshot.orchestration.composites, outcome.snapshot.orchestration.composites, scope);
|
|
517
|
+
|
|
518
|
+
snapshot.openWaitHandles = this.mergeScopedHandles(
|
|
519
|
+
snapshot.openWaitHandles,
|
|
520
|
+
outcome.snapshot.openWaitHandles,
|
|
521
|
+
scope,
|
|
522
|
+
);
|
|
523
|
+
snapshot.closedWaitHandles = this.mergeScopedHandles(
|
|
524
|
+
snapshot.closedWaitHandles,
|
|
525
|
+
outcome.snapshot.closedWaitHandles,
|
|
526
|
+
scope,
|
|
527
|
+
);
|
|
528
|
+
snapshot.appliedResumeSignals = [...new Set([
|
|
529
|
+
...snapshot.appliedResumeSignals,
|
|
530
|
+
...outcome.snapshot.appliedResumeSignals,
|
|
531
|
+
])];
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
private mergeScopedRecord<T>(
|
|
535
|
+
target: Record<string, T>,
|
|
536
|
+
source: Record<string, T>,
|
|
537
|
+
scope: string,
|
|
538
|
+
): void {
|
|
539
|
+
for (const key of Object.keys(target)) {
|
|
540
|
+
if (key.startsWith(scope)) delete target[key];
|
|
541
|
+
}
|
|
542
|
+
for (const [key, value] of Object.entries(source)) {
|
|
543
|
+
if (key.startsWith(scope)) target[key] = structuredClone(value);
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
private mergeScopedHandles(
|
|
548
|
+
target: WaitHandle[],
|
|
549
|
+
source: WaitHandle[],
|
|
550
|
+
scope: string,
|
|
551
|
+
): WaitHandle[] {
|
|
552
|
+
return [
|
|
553
|
+
...target.filter((handle) => !handle.invocationKey.startsWith(scope)),
|
|
554
|
+
...source.filter((handle) => handle.invocationKey.startsWith(scope)).map((handle) => structuredClone(handle)),
|
|
555
|
+
];
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
private async runCallFlow(
|
|
559
|
+
ctx: Ctx,
|
|
560
|
+
invocationKey: string,
|
|
561
|
+
fact: CompositeInvocationFact,
|
|
562
|
+
config: { flow?: string },
|
|
563
|
+
): Promise<BehaviorResult> {
|
|
564
|
+
const c = this.controller();
|
|
565
|
+
const reference = String(config.flow ?? '');
|
|
566
|
+
const parsed = /^(instant-ctrl-flow|work-ctrl-flow|bp-ctrl-flow):\/\/([\w.-]+)$/.exec(reference);
|
|
567
|
+
if (!parsed) throw new WorkCtrlFlowLoadError(['invalid-flow-ref'], `CallFlow reference is invalid: ${reference}`);
|
|
568
|
+
const [, scheme, expectedFqn] = parsed;
|
|
569
|
+
if (this.definition.resolveFlow) {
|
|
570
|
+
const target = await this.definition.resolveFlow(reference, this.definition.spec);
|
|
571
|
+
const expectedScheme = target.spec.form === 'InstantCtrlFlow'
|
|
572
|
+
? 'instant-ctrl-flow'
|
|
573
|
+
: target.spec.form === 'WorkCtrlFlow'
|
|
574
|
+
? 'work-ctrl-flow'
|
|
575
|
+
: 'bp-ctrl-flow';
|
|
576
|
+
if (scheme !== expectedScheme || target.spec.fqn !== expectedFqn) {
|
|
577
|
+
throw new WorkCtrlFlowLoadError(
|
|
578
|
+
['flow-resolution-mismatch'],
|
|
579
|
+
`CallFlow ${reference} resolved to ${target.spec.form} #${target.spec.fqn}`,
|
|
580
|
+
);
|
|
581
|
+
}
|
|
582
|
+
if (target.spec.form === 'InstantCtrlFlow') {
|
|
583
|
+
flowStateOf(ctx).current = await runInstantCtrlFlowSpec(target.spec, {
|
|
584
|
+
input: fact.input,
|
|
585
|
+
runtime: c.runtime,
|
|
586
|
+
resolveCode: target.resolveCode,
|
|
587
|
+
resolveFlow: this.definition.resolveFlow,
|
|
588
|
+
});
|
|
589
|
+
fact.status = 'Completed';
|
|
590
|
+
await this.persist(c.snapshot);
|
|
591
|
+
return BehaviorResult.Success;
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
if (!this.durableChildren) throw new WorkCtrlFlowLoadError(
|
|
595
|
+
['durable-child-resolver-required'],
|
|
596
|
+
`CallFlow ${reference} targets a durable flow and requires durableChildren`,
|
|
597
|
+
);
|
|
598
|
+
fact.childTreeId ??= `${c.snapshot.treeId}:${invocationKey}:child`;
|
|
599
|
+
const child = fact.status === 'Open'
|
|
600
|
+
? await this.durableChildren.start(reference, fact.childTreeId, fact.input)
|
|
601
|
+
: await this.durableChildren.inspect(reference, fact.childTreeId);
|
|
602
|
+
if (child.treeId !== fact.childTreeId) {
|
|
603
|
+
throw new WorkCtrlFlowLoadError(
|
|
604
|
+
['durable-child-tree-id-mismatch'],
|
|
605
|
+
`CallFlow ${reference} expected child ${fact.childTreeId}, got ${child.treeId}`,
|
|
606
|
+
);
|
|
607
|
+
}
|
|
608
|
+
if (child.status === 'Waiting') {
|
|
609
|
+
fact.status = 'Waiting';
|
|
610
|
+
const handle = this.ensureCompositeTimer(c, invocationKey, 0, 'child');
|
|
611
|
+
c.suspended.push(handle);
|
|
612
|
+
await this.persist(c.snapshot);
|
|
613
|
+
return BehaviorResult.Failure;
|
|
614
|
+
}
|
|
615
|
+
fact.status = child.status === 'Completed' ? 'Completed' : child.status === 'Cancelled' ? 'Cancelled' : 'Failed';
|
|
616
|
+
if (child.status === 'Completed') flowStateOf(ctx).current = child.output;
|
|
617
|
+
await this.persist(c.snapshot);
|
|
618
|
+
return child.status === 'Completed' ? BehaviorResult.Success : BehaviorResult.Failure;
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
private openSchedulerTimer(c: TickController, invocationKey: string, deadlineAtMs: number): BehaviorResult {
|
|
622
|
+
const handle = this.ensureCompositeTimer(c, invocationKey, deadlineAtMs, 'backoff');
|
|
623
|
+
c.suspended.push(handle);
|
|
624
|
+
return BehaviorResult.Failure;
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
private ensureCompositeTimer(
|
|
628
|
+
c: TickController,
|
|
629
|
+
invocationKey: string,
|
|
630
|
+
deadlineAtMs: number,
|
|
631
|
+
role: 'backoff' | 'timeout' | 'child',
|
|
632
|
+
): WaitHandle {
|
|
633
|
+
const key = `${invocationKey}/${role}`;
|
|
634
|
+
let handle = c.snapshot.openWaitHandles.find((item) => item.invocationKey === key);
|
|
635
|
+
if (!handle) {
|
|
636
|
+
handle = {
|
|
637
|
+
waitHandleId: `wh_${c.snapshot.treeId}_${key.replace(/[^\w]/g, '_')}`,
|
|
638
|
+
treeId: c.snapshot.treeId,
|
|
639
|
+
nodeKey: invocationKey.slice(0, invocationKey.lastIndexOf('#')),
|
|
640
|
+
invocationKey: key,
|
|
641
|
+
waitKind: role === 'child' ? 'external-job' : 'timer',
|
|
642
|
+
status: 'Open',
|
|
643
|
+
signalKind: role === 'child' ? 'flow.child.updated' : 'timer.due',
|
|
644
|
+
signalKey: key,
|
|
645
|
+
resumeToken: `rt_${Math.random().toString(36).slice(2, 10)}`,
|
|
646
|
+
deadlineAtMs,
|
|
647
|
+
metadata: { compositeRole: role },
|
|
648
|
+
};
|
|
649
|
+
c.snapshot.openWaitHandles.push(handle);
|
|
650
|
+
}
|
|
651
|
+
return handle;
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
private closeCompositeTimer(c: TickController, invocationKey: string, role: 'backoff' | 'timeout' | 'child'): void {
|
|
655
|
+
const key = `${invocationKey}/${role}`;
|
|
656
|
+
const handle = c.snapshot.openWaitHandles.find((item) => item.invocationKey === key);
|
|
657
|
+
if (!handle) return;
|
|
658
|
+
handle.status = 'Cancelled';
|
|
659
|
+
handle.closedAtMs = c.now();
|
|
660
|
+
c.snapshot.openWaitHandles = c.snapshot.openWaitHandles.filter((item) => item !== handle);
|
|
661
|
+
c.snapshot.closedWaitHandles.push(handle);
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
private cancelScopedWaits(c: TickController, invocationKey: string, reason: 'DeadlineExceeded' | 'RaceLost', winnerId?: string): void {
|
|
665
|
+
const atMs = c.now();
|
|
666
|
+
for (const handle of [...c.snapshot.openWaitHandles]) {
|
|
667
|
+
if (!handle.invocationKey.startsWith(`${invocationKey}/`)) continue;
|
|
668
|
+
if (winnerId && handle.invocationKey.includes(`/lane:${winnerId}/`)) continue;
|
|
669
|
+
handle.status = 'Cancelled';
|
|
670
|
+
handle.closedAtMs = atMs;
|
|
671
|
+
c.snapshot.openWaitHandles = c.snapshot.openWaitHandles.filter((item) => item !== handle);
|
|
672
|
+
c.snapshot.closedWaitHandles.push(handle);
|
|
673
|
+
c.snapshot.waitResults[handle.invocationKey] = { result: 'Cancelled' };
|
|
674
|
+
c.snapshot.orchestration.cancellations[handle.invocationKey] = {
|
|
675
|
+
invocationKey: handle.invocationKey,
|
|
676
|
+
reason,
|
|
677
|
+
atMs,
|
|
678
|
+
};
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
async function runScheduledBody(
|
|
684
|
+
tree: CtrlNode,
|
|
685
|
+
registry: Registry,
|
|
686
|
+
controller: TickController,
|
|
687
|
+
runtime: unknown,
|
|
688
|
+
state: ReturnType<typeof createFlowExecutionState>,
|
|
689
|
+
): Promise<BehaviorResult> {
|
|
690
|
+
const engine = new BehaviorTreeEngine<CtrlFlowNodeType, INodeConfig>(registry, evaluateExpression);
|
|
691
|
+
const data: BehaviorTreeData<CtrlFlowNodeType, INodeConfig> = {
|
|
692
|
+
NodeTree: tree,
|
|
693
|
+
Vars: new Map([[FLOW_STATE_KEY, state]]),
|
|
694
|
+
CmdStack: [],
|
|
695
|
+
CmdHistory: [],
|
|
696
|
+
NodeMap: new Map(),
|
|
697
|
+
};
|
|
698
|
+
await engine.start(data, runtime);
|
|
699
|
+
while (!engine.isComplete(data) && controller.suspended.length === 0) {
|
|
700
|
+
await new Promise((resolve) => setTimeout(resolve, 5));
|
|
701
|
+
await engine.runPendingCommands(data, runtime);
|
|
702
|
+
}
|
|
703
|
+
return data.NodeTree.Status === BehaviorTreeNodeStatus.Success
|
|
704
|
+
? BehaviorResult.Success
|
|
705
|
+
: BehaviorResult.Failure;
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
/**
|
|
709
|
+
* 挂起协议:wait 节点登记挂起后返回 Failure 让树快速塌陷(内核 adapter 会 await handler,
|
|
710
|
+
* 不能用不结算的 Promise);其后所有 handler 进入惰性模式——不执行、不记录。
|
|
711
|
+
* 本 tick 的树状态整体丢弃,恢复时从快照全新重放,因此塌陷路径不产生任何可观察效果。
|
|
712
|
+
*/
|
|
713
|
+
|
|
714
|
+
/** 包装动作 handler:重放命中即跳过执行(副作用不重复);挂起后惰性 */
|
|
715
|
+
class ReplayingHandler implements ActionHandler<CtrlFlowNodeType, INodeConfig> {
|
|
716
|
+
constructor(
|
|
717
|
+
private inner: ActionHandler<CtrlFlowNodeType, INodeConfig>,
|
|
718
|
+
private controller: () => TickController,
|
|
719
|
+
private replayResults = true,
|
|
720
|
+
) {}
|
|
721
|
+
async execute(ctx: Ctx): Promise<BehaviorResult> {
|
|
722
|
+
const c = this.controller();
|
|
723
|
+
if (c.suspended.length > 0) {
|
|
724
|
+
c.inertKeys.add(ctx.node.Key);
|
|
725
|
+
return BehaviorResult.Failure; // 惰性中止
|
|
726
|
+
}
|
|
727
|
+
if (!this.replayResults) return this.inner.execute(ctx);
|
|
728
|
+
const key = c.invocationKeyFor(ctx.node.Key);
|
|
729
|
+
const recorded = c.snapshot.nodeResults[key];
|
|
730
|
+
if (recorded !== undefined) return recorded === 'Success' ? BehaviorResult.Success : BehaviorResult.Failure;
|
|
731
|
+
const result = await this.inner.execute(ctx);
|
|
732
|
+
if (result !== BehaviorResult.Running) {
|
|
733
|
+
c.snapshot.nodeResults[key] = result === BehaviorResult.Success ? 'Success' : 'Failure';
|
|
734
|
+
}
|
|
735
|
+
return result;
|
|
736
|
+
}
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
/** 可中断节点 handler:命中 waitResults 即收敛,否则登记 WaitHandle 并让本 tick 挂起 */
|
|
740
|
+
export class WaitNodeHandler implements ActionHandler<CtrlFlowNodeType, INodeConfig> {
|
|
741
|
+
constructor(
|
|
742
|
+
private waitKind: WaitKind,
|
|
743
|
+
private controller: () => TickController,
|
|
744
|
+
/** TaskStep 由 bp-ctrl-flow 注入的进入动作(置任务 ACTIVE、before-hooks 等) */
|
|
745
|
+
private onOpen?: (ctx: Ctx, handle: WaitHandle) => Promise<void>,
|
|
746
|
+
/** 收敛回调(首次命中 waitResults 时执行一次,after-hooks 等);重放幂等由 nodeResults 标记保证 */
|
|
747
|
+
private onConverge?: (ctx: Ctx, invocationKey: string, result: TerminalResult) => Promise<void>,
|
|
748
|
+
) {}
|
|
749
|
+
async execute(ctx: Ctx): Promise<BehaviorResult> {
|
|
750
|
+
const c = this.controller();
|
|
751
|
+
const flow = flowStateOf(ctx);
|
|
752
|
+
if (flow.returned) return BehaviorResult.Success;
|
|
753
|
+
if (c.suspended.length > 0) {
|
|
754
|
+
c.inertKeys.add(ctx.node.Key);
|
|
755
|
+
return BehaviorResult.Failure; // 惰性中止
|
|
756
|
+
}
|
|
757
|
+
const key = c.invocationKeyFor(ctx.node.Key);
|
|
758
|
+
const done = c.snapshot.waitResults[key];
|
|
759
|
+
const cfg = ctx.node.Config as Record<string, unknown>;
|
|
760
|
+
if (done) {
|
|
761
|
+
const closed = c.snapshot.closedWaitHandles.find((handle) => handle.invocationKey === key);
|
|
762
|
+
if (done.result === 'Success') {
|
|
763
|
+
flow.fault = undefined;
|
|
764
|
+
flow.current = this.waitKind === 'timer' ? closed?.metadata?.input : done.payload;
|
|
765
|
+
} else {
|
|
766
|
+
flow.fault = new Error(`${this.waitKind} ${ctx.node.Key} finished with ${done.result}`);
|
|
767
|
+
}
|
|
768
|
+
const convergeMark = `${key}:converged`;
|
|
769
|
+
if (this.onConverge && c.snapshot.nodeResults[convergeMark] === undefined) {
|
|
770
|
+
c.snapshot.nodeResults[convergeMark] = done.result;
|
|
771
|
+
await this.onConverge(ctx, key, done.result);
|
|
772
|
+
}
|
|
773
|
+
return done.result === 'Success' ? BehaviorResult.Success : BehaviorResult.Failure;
|
|
774
|
+
}
|
|
775
|
+
// 未收敛:若已有同 invocationKey 的 open handle(上个 tick 登记过)则复用,否则新建
|
|
776
|
+
let handle = c.snapshot.openWaitHandles.find((h) => h.invocationKey === key);
|
|
777
|
+
if (!handle) {
|
|
778
|
+
const now = c.now();
|
|
779
|
+
handle = {
|
|
780
|
+
waitHandleId: `wh_${c.snapshot.treeId}_${key.replace(/[^\w]/g, '_')}`,
|
|
781
|
+
treeId: c.snapshot.treeId,
|
|
782
|
+
nodeKey: ctx.node.Key,
|
|
783
|
+
invocationKey: key,
|
|
784
|
+
waitKind: this.waitKind,
|
|
785
|
+
status: 'Open',
|
|
786
|
+
signalKind: String(cfg.signalKind ?? (this.waitKind === 'timer' ? 'timer.due' : this.waitKind === 'task-step' ? 'task.operated' : 'job.completed')),
|
|
787
|
+
signalKey: String(cfg.signalKey ?? ctx.node.Key),
|
|
788
|
+
resumeToken: `rt_${Math.random().toString(36).slice(2, 10)}`,
|
|
789
|
+
deadlineAtMs:
|
|
790
|
+
this.waitKind === 'timer'
|
|
791
|
+
? now + Number(cfg.durationMs ?? 0)
|
|
792
|
+
: cfg.timeoutMs !== undefined
|
|
793
|
+
? now + Number(cfg.timeoutMs)
|
|
794
|
+
: 0,
|
|
795
|
+
assignTo: cfg.assignTo as string | undefined,
|
|
796
|
+
metadata: this.waitKind === 'timer' ? { input: flow.current } : undefined,
|
|
797
|
+
};
|
|
798
|
+
c.snapshot.openWaitHandles.push(handle);
|
|
799
|
+
if (handle.deadlineAtMs > 0) {
|
|
800
|
+
c.snapshot.orchestration.deadlines[key] = {
|
|
801
|
+
invocationKey: key,
|
|
802
|
+
deadlineAtMs: handle.deadlineAtMs,
|
|
803
|
+
status: 'Open',
|
|
804
|
+
};
|
|
805
|
+
}
|
|
806
|
+
if (this.onOpen) await this.onOpen(ctx, handle);
|
|
807
|
+
}
|
|
808
|
+
c.suspended.push(handle);
|
|
809
|
+
return BehaviorResult.Failure; // 触发树塌陷结束本 tick;恢复经 ResumeSignal 后整树重放
|
|
810
|
+
}
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
export interface WorkCtrlFlowEngineOptions {
|
|
814
|
+
definition: WorkCtrlFlowDefinition;
|
|
815
|
+
store: WorkCtrlFlowStore;
|
|
816
|
+
runtime?: WorkCtrlFlowRuntime;
|
|
817
|
+
clock?: WorkCtrlFlowClock;
|
|
818
|
+
view?: WorkCtrlFlowViewAdapter;
|
|
819
|
+
/** 产品形态扩展(BPCtrlFlow 注入 TaskStep 编译与 handler) */
|
|
820
|
+
compileExtension?: CompileExtension;
|
|
821
|
+
extraHandlers?: (controller: () => TickController) => Record<string, ActionHandler<CtrlFlowNodeType, INodeConfig>>;
|
|
822
|
+
expectedForm?: 'WorkCtrlFlow' | 'BPCtrlFlow';
|
|
823
|
+
durableChildren?: DurableChildFlowResolver;
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
export interface WorkCtrlFlowDefinition {
|
|
827
|
+
spec: FlowBundleSpec;
|
|
828
|
+
resolveCode: FlowCodeResolver<WorkCtrlFlowRuntime>;
|
|
829
|
+
resolveFlow?: CtrlFlowResolver;
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
export interface WorkCtrlFlowViewAdapter {
|
|
833
|
+
annotateTree(treeId: string, view: TreeViewAnnotation): void | Promise<void>;
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
export class WorkCtrlFlowLoadError extends Error {
|
|
837
|
+
constructor(public codes: string[], message: string) {
|
|
838
|
+
super(message);
|
|
839
|
+
}
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
export class WorkCtrlFlowEngine {
|
|
843
|
+
readonly spec: FlowBundleSpec;
|
|
844
|
+
private runtime: WorkCtrlFlowRuntime;
|
|
845
|
+
private controllerRef: { current?: TickController } = {};
|
|
846
|
+
|
|
847
|
+
constructor(private options: WorkCtrlFlowEngineOptions) {
|
|
848
|
+
this.spec = options.definition.spec;
|
|
849
|
+
this.runtime = options.runtime ?? {};
|
|
850
|
+
const expected = this.options.expectedForm ?? 'WorkCtrlFlow';
|
|
851
|
+
const hard = this.spec.diagnostics.filter((diagnostic) => diagnostic.code !== 'parser-warning');
|
|
852
|
+
if (hard.length) {
|
|
853
|
+
throw new WorkCtrlFlowLoadError(hard.map((diagnostic) => diagnostic.code), hard.map((diagnostic) => diagnostic.message).join('; '));
|
|
854
|
+
}
|
|
855
|
+
if (this.spec.form !== expected) {
|
|
856
|
+
throw new WorkCtrlFlowLoadError(['form-mismatch'], `bundle ${this.spec.fqn} is a ${this.spec.form}, expected ${expected}`);
|
|
857
|
+
}
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
private now(): number {
|
|
861
|
+
return this.options.clock?.now() ?? this.runtime.now?.() ?? Date.now();
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
async start(treeId: string, opts: WorkCtrlFlowStartOptions = {}): Promise<TickOutcome> {
|
|
865
|
+
const existing = await this.options.store.load(treeId);
|
|
866
|
+
if (existing) throw new WorkCtrlFlowLoadError(['tree-exists'], `tree ${treeId} already exists`);
|
|
867
|
+
const spec = this.spec;
|
|
868
|
+
const snapshot: WorkCtrlFlowSnapshot = {
|
|
869
|
+
schemaVersion: WORKFLOW_SNAPSHOT_SCHEMA,
|
|
870
|
+
treeId,
|
|
871
|
+
fqn: spec.fqn,
|
|
872
|
+
status: 'Waiting',
|
|
873
|
+
vars: { ...(spec.stateSeed ?? {}) },
|
|
874
|
+
input: { ...(opts.input ?? {}) },
|
|
875
|
+
branchDecisions: {},
|
|
876
|
+
nodeResults: {},
|
|
877
|
+
waitResults: {},
|
|
878
|
+
openWaitHandles: [],
|
|
879
|
+
closedWaitHandles: [],
|
|
880
|
+
appliedResumeSignals: [],
|
|
881
|
+
orchestration: emptyOrchestrationFacts(),
|
|
882
|
+
lastTickNo: 0,
|
|
883
|
+
updatedAtMs: this.now(),
|
|
884
|
+
};
|
|
885
|
+
snapshot.vars[FLOW_STATE_KEY] = createFlowExecutionState(opts.input ?? {});
|
|
886
|
+
return this.tick(snapshot, spec);
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
/** 投递恢复信号(WF-4:幂等;未匹配 open handle 拒绝) */
|
|
890
|
+
async resume(treeId: string, signal: ResumeSignal): Promise<TickOutcome> {
|
|
891
|
+
const snapshot = await this.options.store.load(treeId);
|
|
892
|
+
if (!snapshot) throw new WorkCtrlFlowLoadError(['tree-not-found'], `tree ${treeId} not found`);
|
|
893
|
+
ensureOrchestrationFacts(snapshot);
|
|
894
|
+
|
|
895
|
+
const handle = snapshot.openWaitHandles.find(
|
|
896
|
+
(h) => h.signalKind === signal.signalKind && h.signalKey === signal.signalKey,
|
|
897
|
+
);
|
|
898
|
+
if (!handle) {
|
|
899
|
+
const appliedKey = `${signal.signalKind}:${signal.signalKey}:${signal.resumeToken}`;
|
|
900
|
+
if (snapshot.appliedResumeSignals.some((a) => a.endsWith(`:${signal.resumeToken}`))) {
|
|
901
|
+
// A crash can happen after durable node facts are saved but before the
|
|
902
|
+
// enclosing tick persists its terminal status. Reconcile from facts
|
|
903
|
+
// when there is no remaining wait instead of returning stale Waiting.
|
|
904
|
+
if (snapshot.status === 'Waiting' && snapshot.openWaitHandles.length === 0) {
|
|
905
|
+
return this.tick(snapshot, this.spec);
|
|
906
|
+
}
|
|
907
|
+
return this.outcomeFromSnapshot(snapshot);
|
|
908
|
+
}
|
|
909
|
+
throw new WorkCtrlFlowLoadError(['no-open-wait-handle'], `no open wait handle for ${appliedKey}`);
|
|
910
|
+
}
|
|
911
|
+
if (handle.resumeToken !== signal.resumeToken) {
|
|
912
|
+
throw new WorkCtrlFlowLoadError(['resume-token-mismatch'], `resume token mismatch for ${handle.waitHandleId}`);
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
snapshot.appliedResumeSignals.push(`${handle.waitHandleId}:${signal.resumeToken}`);
|
|
916
|
+
const result: TerminalResult = signal.outcome ?? 'Success';
|
|
917
|
+
snapshot.waitResults[handle.invocationKey] = { result, payload: signal.payload };
|
|
918
|
+
handle.status = result === 'Cancelled' ? 'Cancelled' : 'Consumed';
|
|
919
|
+
handle.closedAtMs = this.now();
|
|
920
|
+
snapshot.openWaitHandles = snapshot.openWaitHandles.filter((h) => h !== handle);
|
|
921
|
+
snapshot.closedWaitHandles.push(handle);
|
|
922
|
+
const deadline = snapshot.orchestration.deadlines[handle.invocationKey];
|
|
923
|
+
if (deadline) deadline.status = result === 'Cancelled' ? 'Cancelled' : result === 'Failure' ? 'Failed' : 'Completed';
|
|
924
|
+
if (result === 'Cancelled') {
|
|
925
|
+
snapshot.orchestration.cancellations[handle.invocationKey] = {
|
|
926
|
+
invocationKey: handle.invocationKey,
|
|
927
|
+
reason: 'SignalCancelled',
|
|
928
|
+
atMs: handle.closedAtMs!,
|
|
929
|
+
};
|
|
930
|
+
}
|
|
931
|
+
return this.tick(snapshot, this.spec);
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
/**
|
|
935
|
+
* 到期等待统一收敛:Timer 到期 → Success(等价于 timer.due 信号);
|
|
936
|
+
* 其余(如 ExternalJob timeoutMs)到期 → Failure,句柄标 Expired。deadlineAtMs=0 表示无限不收敛。
|
|
937
|
+
*/
|
|
938
|
+
async fireDueDeadlines(treeId: string): Promise<TickOutcome> {
|
|
939
|
+
const snapshot = await this.options.store.load(treeId);
|
|
940
|
+
if (!snapshot) throw new WorkCtrlFlowLoadError(['tree-not-found'], `tree ${treeId} not found`);
|
|
941
|
+
ensureOrchestrationFacts(snapshot);
|
|
942
|
+
const now = this.now();
|
|
943
|
+
let fired = 0;
|
|
944
|
+
for (const h of [...snapshot.openWaitHandles]) {
|
|
945
|
+
if (h.deadlineAtMs > 0 && h.deadlineAtMs <= now) {
|
|
946
|
+
snapshot.appliedResumeSignals.push(`${h.waitHandleId}:${h.resumeToken}`);
|
|
947
|
+
if (h.waitKind === 'timer') {
|
|
948
|
+
snapshot.waitResults[h.invocationKey] = { result: 'Success' };
|
|
949
|
+
h.status = 'Consumed';
|
|
950
|
+
} else {
|
|
951
|
+
snapshot.waitResults[h.invocationKey] = { result: 'Failure' };
|
|
952
|
+
h.status = 'Expired';
|
|
953
|
+
}
|
|
954
|
+
h.closedAtMs = now;
|
|
955
|
+
snapshot.openWaitHandles = snapshot.openWaitHandles.filter((x) => x !== h);
|
|
956
|
+
snapshot.closedWaitHandles.push(h);
|
|
957
|
+
const deadline = snapshot.orchestration.deadlines[h.invocationKey];
|
|
958
|
+
if (deadline) deadline.status = h.waitKind === 'timer' ? 'Completed' : 'Expired';
|
|
959
|
+
if (h.waitKind !== 'timer') {
|
|
960
|
+
snapshot.orchestration.cancellations[h.invocationKey] = {
|
|
961
|
+
invocationKey: h.invocationKey,
|
|
962
|
+
reason: 'DeadlineExceeded',
|
|
963
|
+
atMs: now,
|
|
964
|
+
};
|
|
965
|
+
}
|
|
966
|
+
fired++;
|
|
967
|
+
}
|
|
968
|
+
}
|
|
969
|
+
if (!fired) return this.outcomeFromSnapshot(snapshot);
|
|
970
|
+
return this.tick(snapshot, this.spec);
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
/** @deprecated 用 fireDueDeadlines(保留别名以兼容) */
|
|
974
|
+
async fireDueTimers(treeId: string): Promise<TickOutcome> {
|
|
975
|
+
return this.fireDueDeadlines(treeId);
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
async getOutcome(treeId: string): Promise<TickOutcome | undefined> {
|
|
979
|
+
const snapshot = await this.options.store.load(treeId);
|
|
980
|
+
return snapshot && this.outcomeFromSnapshot(snapshot);
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
/** Explicit scheduler tick used after an external durable child changes state. */
|
|
984
|
+
async refresh(treeId: string): Promise<TickOutcome> {
|
|
985
|
+
const snapshot = await this.options.store.load(treeId);
|
|
986
|
+
if (!snapshot) throw new WorkCtrlFlowLoadError(['tree-not-found'], `tree ${treeId} not found`);
|
|
987
|
+
for (const handle of [...snapshot.openWaitHandles]) {
|
|
988
|
+
if (handle.metadata?.compositeRole !== 'child') continue;
|
|
989
|
+
handle.status = 'Consumed';
|
|
990
|
+
handle.closedAtMs = this.now();
|
|
991
|
+
snapshot.openWaitHandles = snapshot.openWaitHandles.filter((item) => item !== handle);
|
|
992
|
+
snapshot.closedWaitHandles.push(handle);
|
|
993
|
+
}
|
|
994
|
+
return this.tick(snapshot, this.spec);
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
private outcomeFromSnapshot(snapshot: WorkCtrlFlowSnapshot): TickOutcome {
|
|
998
|
+
return {
|
|
999
|
+
status: snapshot.status,
|
|
1000
|
+
treeId: snapshot.treeId,
|
|
1001
|
+
vars: snapshot.vars,
|
|
1002
|
+
openWaitHandles: snapshot.openWaitHandles,
|
|
1003
|
+
logs: [],
|
|
1004
|
+
errors: [],
|
|
1005
|
+
tickNo: snapshot.lastTickNo,
|
|
1006
|
+
};
|
|
1007
|
+
}
|
|
1008
|
+
|
|
1009
|
+
private buildRegistry(
|
|
1010
|
+
spec: FlowBundleSpec,
|
|
1011
|
+
logs: string[],
|
|
1012
|
+
controller: () => TickController = () => this.controllerRef.current!,
|
|
1013
|
+
persist: (snapshot: WorkCtrlFlowSnapshot) => Promise<void> = (snapshot) => this.options.store.save(snapshot),
|
|
1014
|
+
): ActionHandlerRegistry<CtrlFlowNodeType, INodeConfig> {
|
|
1015
|
+
const registry = new ActionHandlerRegistry<CtrlFlowNodeType, INodeConfig>();
|
|
1016
|
+
const wrap = (h: ActionHandler<CtrlFlowNodeType, INodeConfig>, replayResults = true) =>
|
|
1017
|
+
new ReplayingHandler(h, controller, replayResults);
|
|
1018
|
+
|
|
1019
|
+
const decisionHooks: FlowDecisionHooks = {
|
|
1020
|
+
begin: (ifPath) => {
|
|
1021
|
+
const c = controller();
|
|
1022
|
+
const invocationKey = c.invocationKeyFor(ifPath);
|
|
1023
|
+
return { ifPath, invocationKey, decision: c.snapshot.branchDecisions[invocationKey] };
|
|
1024
|
+
},
|
|
1025
|
+
record: async (decision: FlowBranchDecision) => {
|
|
1026
|
+
const c = controller();
|
|
1027
|
+
const existing = c.snapshot.branchDecisions[decision.invocationKey];
|
|
1028
|
+
if (existing && JSON.stringify(existing) !== JSON.stringify(decision)) {
|
|
1029
|
+
throw new Error(`conflicting branch decision for ${decision.invocationKey}`);
|
|
1030
|
+
}
|
|
1031
|
+
c.snapshot.branchDecisions[decision.invocationKey] = decision;
|
|
1032
|
+
c.snapshot.updatedAtMs = c.now();
|
|
1033
|
+
await persist(c.snapshot);
|
|
1034
|
+
},
|
|
1035
|
+
};
|
|
1036
|
+
|
|
1037
|
+
registry.register('Log', wrap(new LogActionHandler(logs)));
|
|
1038
|
+
registry.register('Assert', wrap(new AssertActionHandler()));
|
|
1039
|
+
registry.register('Sleep', wrap(new SleepActionHandler()));
|
|
1040
|
+
for (const [type, handler] of Object.entries(createFlowHandlers(spec, this.options.definition.resolveCode, decisionHooks))) {
|
|
1041
|
+
const decisionAware = type === 'flow.IfStart' || type === 'flow.Predicate' || type === 'flow.Otherwise';
|
|
1042
|
+
registry.register(type as CtrlFlowNodeType, wrap(handler, !decisionAware));
|
|
1043
|
+
}
|
|
1044
|
+
registry.register('wf.ExternalJob', new WaitNodeHandler('external-job', controller));
|
|
1045
|
+
registry.register('wf.Timer', new WaitNodeHandler('timer', controller));
|
|
1046
|
+
registry.register(
|
|
1047
|
+
'wf.Composite',
|
|
1048
|
+
new DurableCompositeScheduler(
|
|
1049
|
+
controller,
|
|
1050
|
+
(nestedController, nestedPersist = persist) => this.buildRegistry(spec, logs, nestedController, nestedPersist),
|
|
1051
|
+
persist,
|
|
1052
|
+
this.options.definition,
|
|
1053
|
+
this.options.durableChildren,
|
|
1054
|
+
),
|
|
1055
|
+
);
|
|
1056
|
+
// seed: restore the persistent statement data holder before replaying the plan.
|
|
1057
|
+
registry.register('instant.Seed', {
|
|
1058
|
+
execute: async (ctx: Ctx) => {
|
|
1059
|
+
const c = controller();
|
|
1060
|
+
const state = c.snapshot.vars[FLOW_STATE_KEY] ?? createFlowExecutionState(c.snapshot.input);
|
|
1061
|
+
c.snapshot.vars[FLOW_STATE_KEY] = state;
|
|
1062
|
+
ctx.setVar(FLOW_STATE_KEY, state);
|
|
1063
|
+
return BehaviorResult.Success;
|
|
1064
|
+
},
|
|
1065
|
+
});
|
|
1066
|
+
for (const [type, handler] of Object.entries(this.options.extraHandlers?.(controller) ?? {})) {
|
|
1067
|
+
registry.register(type, handler);
|
|
1068
|
+
}
|
|
1069
|
+
return registry;
|
|
1070
|
+
}
|
|
1071
|
+
|
|
1072
|
+
private compileTree(spec: FlowBundleSpec): CtrlNode {
|
|
1073
|
+
const ext: CompileExtension = (s, node, helpers) =>
|
|
1074
|
+
this.options.compileExtension?.(s, node, helpers) ?? workFlowCompileExtension(s, node, helpers);
|
|
1075
|
+
const userRoot = compileBundle(spec, ext);
|
|
1076
|
+
const seed = CtrlFlowDsl.node('instant.Seed' as CtrlFlowNodeType, { key: '__seed__' }, {});
|
|
1077
|
+
return CtrlFlowDsl.node('Sequence' as CtrlFlowNodeType, { key: '__root__' }, {}, [seed, userRoot]);
|
|
1078
|
+
}
|
|
1079
|
+
|
|
1080
|
+
private async tick(snapshot: WorkCtrlFlowSnapshot, spec: FlowBundleSpec): Promise<TickOutcome> {
|
|
1081
|
+
snapshot.branchDecisions ??= {};
|
|
1082
|
+
ensureOrchestrationFacts(snapshot);
|
|
1083
|
+
snapshot.lastTickNo += 1;
|
|
1084
|
+
const logs: string[] = [];
|
|
1085
|
+
const errors: string[] = [];
|
|
1086
|
+
const controller = new TickController(snapshot, this.runtime);
|
|
1087
|
+
this.controllerRef.current = controller;
|
|
1088
|
+
|
|
1089
|
+
const registry = this.buildRegistry(spec, logs);
|
|
1090
|
+
const engine = new BehaviorTreeEngine<CtrlFlowNodeType, INodeConfig>(registry, evaluateExpression);
|
|
1091
|
+
// 重放要求全新树实例(节点 Status 不能跨 tick 残留)
|
|
1092
|
+
const tree = structuredCloneTree(this.compileTree(spec));
|
|
1093
|
+
const btData: BehaviorTreeData<CtrlFlowNodeType, INodeConfig> = {
|
|
1094
|
+
NodeTree: tree,
|
|
1095
|
+
Vars: new Map(),
|
|
1096
|
+
CmdStack: [],
|
|
1097
|
+
CmdHistory: [],
|
|
1098
|
+
NodeMap: new Map(),
|
|
1099
|
+
};
|
|
1100
|
+
|
|
1101
|
+
await engine.start(btData, this.runtime);
|
|
1102
|
+
while (!engine.isComplete(btData) && controller.suspended.length === 0) {
|
|
1103
|
+
await new Promise((r) => setTimeout(r, 5));
|
|
1104
|
+
await engine.runPendingCommands(btData, this.runtime);
|
|
1105
|
+
}
|
|
1106
|
+
|
|
1107
|
+
snapshot.updatedAtMs = this.now();
|
|
1108
|
+
let status: TickOutcome['status'];
|
|
1109
|
+
if (controller.suspended.length > 0) {
|
|
1110
|
+
status = 'Waiting';
|
|
1111
|
+
} else {
|
|
1112
|
+
status = btData.NodeTree.Status === BehaviorTreeNodeStatus.Success ? 'Completed' : 'Failed';
|
|
1113
|
+
if (status === 'Failed') collectErrors(btData.NodeTree, errors);
|
|
1114
|
+
}
|
|
1115
|
+
snapshot.status = status;
|
|
1116
|
+
await this.options.store.save(snapshot);
|
|
1117
|
+
|
|
1118
|
+
// Optional views are adapters; snapshots remain the engine truth source.
|
|
1119
|
+
try {
|
|
1120
|
+
await this.options.view?.annotateTree(snapshot.treeId, computeView(btData, controller, status, snapshot.lastTickNo));
|
|
1121
|
+
} catch (e) {
|
|
1122
|
+
console.error(`[workflow view] annotate failed for ${snapshot.treeId}:`, e);
|
|
1123
|
+
}
|
|
1124
|
+
|
|
1125
|
+
return {
|
|
1126
|
+
status,
|
|
1127
|
+
treeId: snapshot.treeId,
|
|
1128
|
+
vars: snapshot.vars,
|
|
1129
|
+
openWaitHandles: snapshot.openWaitHandles,
|
|
1130
|
+
logs,
|
|
1131
|
+
errors,
|
|
1132
|
+
tickNo: snapshot.lastTickNo,
|
|
1133
|
+
};
|
|
1134
|
+
}
|
|
1135
|
+
}
|
|
1136
|
+
|
|
1137
|
+
/** 编译结果是纯数据树(Config/Children/Attr),深拷贝以避免 tick 间状态残留 */
|
|
1138
|
+
function structuredCloneTree(node: CtrlNode): CtrlNode {
|
|
1139
|
+
return {
|
|
1140
|
+
...node,
|
|
1141
|
+
Config: { ...(node.Config as Record<string, unknown>) } as INodeConfig,
|
|
1142
|
+
Children: node.Children.map((c) => structuredCloneTree(c as CtrlNode)),
|
|
1143
|
+
};
|
|
1144
|
+
}
|
|
1145
|
+
|
|
1146
|
+
function collectErrors(node: CtrlNode, errors: string[]): void {
|
|
1147
|
+
if (node.Status === BehaviorTreeNodeStatus.Failure && node.Children.length === 0) {
|
|
1148
|
+
errors.push(`node ${node.Key} failed`);
|
|
1149
|
+
}
|
|
1150
|
+
for (const c of node.Children) collectErrors(c as CtrlNode, errors);
|
|
1151
|
+
}
|
|
1152
|
+
|
|
1153
|
+
function flatConfig(spec: FlowBundleSpec): Record<string, unknown> {
|
|
1154
|
+
const flat: Record<string, unknown> = {};
|
|
1155
|
+
for (const entry of Object.values(spec.config)) Object.assign(flat, entry);
|
|
1156
|
+
return Object.freeze(flat);
|
|
1157
|
+
}
|
|
1158
|
+
|
|
1159
|
+
const STATUS_VIEW: Record<string, NodeViewStatus> = {
|
|
1160
|
+
[BehaviorTreeNodeStatus.Init]: 'Init',
|
|
1161
|
+
[BehaviorTreeNodeStatus.Started]: 'Active',
|
|
1162
|
+
[BehaviorTreeNodeStatus.Success]: 'Success',
|
|
1163
|
+
[BehaviorTreeNodeStatus.Failure]: 'Failure',
|
|
1164
|
+
[BehaviorTreeNodeStatus.Omitted]: 'Omitted',
|
|
1165
|
+
};
|
|
1166
|
+
|
|
1167
|
+
const SYNTHETIC_KEYS = new Set(['__root__', '__seed__']);
|
|
1168
|
+
|
|
1169
|
+
/**
|
|
1170
|
+
* 从 tick 末的树状态计算标注视图。挂起 tick 的塌陷路径修正:
|
|
1171
|
+
* 惰性执行节点 → INIT;全 INIT 子树的控制节点 → INIT;挂起节点 → WAITING;其祖先链 → ACTIVE。
|
|
1172
|
+
*/
|
|
1173
|
+
export function computeView(
|
|
1174
|
+
btData: BehaviorTreeData<CtrlFlowNodeType, INodeConfig>,
|
|
1175
|
+
controller: TickController,
|
|
1176
|
+
runStatus: TickOutcome['status'],
|
|
1177
|
+
tickNo: number,
|
|
1178
|
+
): TreeViewAnnotation {
|
|
1179
|
+
const nodes: Record<string, NodeAnnotation> = {};
|
|
1180
|
+
const parentOf = new Map<string, string>();
|
|
1181
|
+
|
|
1182
|
+
const walk = (n: CtrlNode, parent?: CtrlNode): void => {
|
|
1183
|
+
if (parent) parentOf.set(n.Key, parent.Key);
|
|
1184
|
+
nodes[n.Key] = { status: STATUS_VIEW[n.Status] ?? 'Init' };
|
|
1185
|
+
for (const c of n.Children) walk(c as CtrlNode, n);
|
|
1186
|
+
};
|
|
1187
|
+
walk(btData.NodeTree as CtrlNode);
|
|
1188
|
+
|
|
1189
|
+
if (controller.suspended.length > 0) {
|
|
1190
|
+
for (const k of controller.inertKeys) if (nodes[k]) nodes[k].status = 'Init';
|
|
1191
|
+
// 全 INIT 子树的控制节点归 INIT(塌陷路径上被误标 FAILURE 的分支容器)
|
|
1192
|
+
const settle = (n: CtrlNode): NodeViewStatus => {
|
|
1193
|
+
const children = n.Children as CtrlNode[];
|
|
1194
|
+
if (children.length) {
|
|
1195
|
+
const childStatuses = children.map(settle);
|
|
1196
|
+
if (childStatuses.every((s) => s === 'Init')) nodes[n.Key].status = 'Init';
|
|
1197
|
+
}
|
|
1198
|
+
return nodes[n.Key].status;
|
|
1199
|
+
};
|
|
1200
|
+
settle(btData.NodeTree as CtrlNode);
|
|
1201
|
+
const byKey = new Map<string, CtrlNode>();
|
|
1202
|
+
const index = (n: CtrlNode) => {
|
|
1203
|
+
byKey.set(n.Key, n);
|
|
1204
|
+
for (const c of n.Children) index(c as CtrlNode);
|
|
1205
|
+
};
|
|
1206
|
+
index(btData.NodeTree as CtrlNode);
|
|
1207
|
+
const resetOmitted = (n: CtrlNode) => {
|
|
1208
|
+
if (nodes[n.Key]?.status === 'Omitted') nodes[n.Key].status = 'Init';
|
|
1209
|
+
for (const c of n.Children) resetOmitted(c as CtrlNode);
|
|
1210
|
+
};
|
|
1211
|
+
for (const h of controller.suspended) {
|
|
1212
|
+
if (nodes[h.nodeKey]) nodes[h.nodeKey].status = 'Waiting';
|
|
1213
|
+
// 祖先链 → ACTIVE;同时清掉塌陷伪影:链上每个父节点中、链子节点之后的兄弟子树里的
|
|
1214
|
+
// OMITTED(Sequence 塌陷标记)→ INIT。真实的 Selector 分支跳过(Omitted)在链子节点
|
|
1215
|
+
// 之前的已完成分支里,不受影响。
|
|
1216
|
+
let child = h.nodeKey;
|
|
1217
|
+
let p = parentOf.get(child);
|
|
1218
|
+
while (p) {
|
|
1219
|
+
if (nodes[p]) nodes[p].status = 'Active';
|
|
1220
|
+
const parentNode = byKey.get(p);
|
|
1221
|
+
if (parentNode) {
|
|
1222
|
+
const idx = parentNode.Children.findIndex((c) => (c as CtrlNode).Key === child);
|
|
1223
|
+
for (let i = idx + 1; i >= 0 && i < parentNode.Children.length; i++) {
|
|
1224
|
+
resetOmitted(parentNode.Children[i] as CtrlNode);
|
|
1225
|
+
}
|
|
1226
|
+
}
|
|
1227
|
+
child = p;
|
|
1228
|
+
p = parentOf.get(p);
|
|
1229
|
+
}
|
|
1230
|
+
}
|
|
1231
|
+
}
|
|
1232
|
+
|
|
1233
|
+
// Cancelled 可区分(WF:树语义按 Failure 收敛,视图还原 Cancelled)
|
|
1234
|
+
for (const [invKey, r] of Object.entries(controller.snapshot.waitResults)) {
|
|
1235
|
+
if (r.result === 'Cancelled') {
|
|
1236
|
+
const nodeKey = invKey.slice(0, invKey.lastIndexOf('#'));
|
|
1237
|
+
if (nodes[nodeKey]?.status === 'Failure') nodes[nodeKey].status = 'Cancelled';
|
|
1238
|
+
}
|
|
1239
|
+
}
|
|
1240
|
+
|
|
1241
|
+
// Until iterations(内核在 Vars 里维护循环计数;首轮为 0 → iterations = 计数 + 1)
|
|
1242
|
+
const untilIterations = (n: CtrlNode): void => {
|
|
1243
|
+
if (n.Kind === BehaviorTreeNodeKind.Until && nodes[n.Key].status !== 'Init') {
|
|
1244
|
+
const counter = (btData.Vars.get(`__until_iterations_${n.Key}`) as number | undefined) ?? 0;
|
|
1245
|
+
nodes[n.Key].iterations = counter + 1;
|
|
1246
|
+
}
|
|
1247
|
+
for (const c of n.Children) untilIterations(c as CtrlNode);
|
|
1248
|
+
};
|
|
1249
|
+
untilIterations(btData.NodeTree as CtrlNode);
|
|
1250
|
+
|
|
1251
|
+
for (const k of SYNTHETIC_KEYS) delete nodes[k];
|
|
1252
|
+
return { runStatus, tickNo, nodes };
|
|
1253
|
+
}
|
|
1254
|
+
|
|
1255
|
+
export { FlowCompileError };
|