@ai-agent-forge/plugin-workflow 0.85.20

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/dist/engine.js ADDED
@@ -0,0 +1,925 @@
1
+ /**
2
+ * Workflow advance engine: pure state-machine derivation over WorkflowState
3
+ * (§5 of docs/design/工作流编排插件设计.md).
4
+ *
5
+ * Everything here is deterministic and derived from the persisted snapshot
6
+ * plus a plan-lookup callback: no clocks, no CapabilityAPI access — index.ts
7
+ * owns persistence (§5 总则 1 snapshot-driven reevaluation; events are only
8
+ * trigger signals, the truth always comes from the current branch entries).
9
+ * Every conversion appends exactly one transition; held/rejected activation
10
+ * attempts are audited too, deduplicated when the previous transition for the
11
+ * stage already carries the same held code and unchanged wait condition
12
+ * (§5 总则 5).
13
+ *
14
+ * Budget note (§5 总则 4): the per-workflow cascade limit
15
+ * (max(32, 2 x stageCount), recomputed per run from the workflow's current
16
+ * stage count — see cascadeLimitFor) covers every transition of one run —
17
+ * the explicit action's own conversions plus the derived reevaluation chain.
18
+ * Logical operations reserve their maximum
19
+ * bump count up front (canBump) so a mid-chain stop never leaves a mutated
20
+ * status without its audit entry; hitting the cap is not terminal — any
21
+ * workflow_advance action resumes the chain.
22
+ */
23
+ export const DEFAULT_RETRY_LIMIT = 2;
24
+ export const DEFAULT_MAX_RETREATS = 3;
25
+ export const DEFAULT_MAX_ACTIVE_STAGES = 4;
26
+ const DEFAULT_CASCADE_LIMIT = 32;
27
+ const DIAGNOSTICS_CAP = 50;
28
+ export function cascadeLimitFor(workflow) {
29
+ return Math.max(DEFAULT_CASCADE_LIMIT, 2 * workflow.stages.length);
30
+ }
31
+ // ---------------------------------------------------------------------------
32
+ // Small lookups
33
+ // ---------------------------------------------------------------------------
34
+ function runtimeOf(workflow, stageId) {
35
+ return workflow.runtime.find((item) => item.stageId === stageId);
36
+ }
37
+ function specOf(workflow, stageId) {
38
+ return workflow.stages.find((item) => item.id === stageId);
39
+ }
40
+ function retryLimitOf(spec) {
41
+ return spec.retryLimit ?? DEFAULT_RETRY_LIMIT;
42
+ }
43
+ export function maxRetreatsOf(workflow) {
44
+ return workflow.maxRetreats ?? DEFAULT_MAX_RETREATS;
45
+ }
46
+ export function maxActiveOf(workflow) {
47
+ return workflow.maxActiveStages ?? DEFAULT_MAX_ACTIVE_STAGES;
48
+ }
49
+ function activeCount(workflow) {
50
+ return workflow.runtime.filter((item) => item.status === "active").length;
51
+ }
52
+ function uncoveredAcceptanceIds(spec, runtime) {
53
+ const covered = new Set(runtime.evidence.map((item) => item.acceptanceId));
54
+ return spec.objective.acceptance.map((item) => item.id).filter((id) => !covered.has(id));
55
+ }
56
+ function pushDiagnostic(workflow, text) {
57
+ if (workflow.diagnostics.length >= DIAGNOSTICS_CAP)
58
+ workflow.diagnostics.shift();
59
+ workflow.diagnostics.push(text);
60
+ }
61
+ const stageKey = (workflowId, stageId) => `${workflowId}::${stageId}`;
62
+ function newWorkflowRun(workflow, deps, closures) {
63
+ return { workflow, deps, limit: cascadeLimitFor(workflow), used: 0, transitions: [], closures, hitLimit: false };
64
+ }
65
+ /** Reserves `needed` transition slots so an operation never mutates without its audit entry. */
66
+ function canBump(run, needed) {
67
+ if (run.used + needed <= run.limit)
68
+ return true;
69
+ run.hitLimit = true;
70
+ return false;
71
+ }
72
+ function appendTransition(run, stageId, from, to, trigger, reason) {
73
+ if (run.used >= run.limit) {
74
+ run.hitLimit = true;
75
+ return false;
76
+ }
77
+ run.workflow.revision += 1;
78
+ run.transitions.push({
79
+ workflowId: run.workflow.id,
80
+ ...(stageId !== undefined ? { stageId } : {}),
81
+ from,
82
+ to,
83
+ trigger,
84
+ reason,
85
+ revision: run.workflow.revision,
86
+ });
87
+ run.used += 1;
88
+ if (stageId !== undefined) {
89
+ run.deps.lastStageTransition.set(stageKey(run.workflow.id, stageId), {
90
+ code: reason.code,
91
+ detail: reason.detail,
92
+ });
93
+ }
94
+ return true;
95
+ }
96
+ /**
97
+ * A held (not activated) pending stage: appends the held transition unless
98
+ * the stage's previous transition already carries the same held code and
99
+ * unchanged wait condition (§5 总则 5 dedup — reevaluation becomes a no-op).
100
+ * plan-dangling and write-scope-conflict additionally raise a diagnostic
101
+ * (§5 总则 2 conditions b/d); deps-incomplete and active-limit are normal
102
+ * graph waiting and stay transition-only.
103
+ */
104
+ function holdStage(run, stageId, trigger, code, message, detail) {
105
+ const key = stageKey(run.workflow.id, stageId);
106
+ const last = run.deps.lastStageTransition.get(key);
107
+ if (last !== undefined && last.code === code && last.detail === detail)
108
+ return false;
109
+ const runtime = runtimeOf(run.workflow, stageId);
110
+ const from = runtime?.status ?? "pending";
111
+ if (!appendTransition(run, stageId, from, from, trigger, { code, message, detail }))
112
+ return false;
113
+ if (code === "stage.activated.held-plan-dangling" || code === "stage.held.write-scope-conflict") {
114
+ pushDiagnostic(run.workflow, message);
115
+ }
116
+ return true;
117
+ }
118
+ // ---------------------------------------------------------------------------
119
+ // Scope guards (§5 总则 2 condition d)
120
+ // ---------------------------------------------------------------------------
121
+ /**
122
+ * An undeclared (or empty) scope on a write stage is global: it intersects
123
+ * every other write stage. Read stages are never scope-checked.
124
+ */
125
+ function scopesIntersect(a, b) {
126
+ if (a === undefined || a.length === 0 || b === undefined || b.length === 0)
127
+ return true;
128
+ return a.some((tag) => b.includes(tag));
129
+ }
130
+ function findWriteConflict(workflow, spec) {
131
+ return workflow.stages.find((other) => {
132
+ if (other.id === spec.id || other.kind !== "write")
133
+ return false;
134
+ return runtimeOf(workflow, other.id)?.status === "active" && scopesIntersect(spec.scope, other.scope);
135
+ });
136
+ }
137
+ /** Every stage that transitively depends on `stageId` (reverse dependsOn reachability). */
138
+ function transitiveDependents(workflow, stageId) {
139
+ const dependents = new Set();
140
+ let grew = true;
141
+ while (grew) {
142
+ grew = false;
143
+ for (const spec of workflow.stages) {
144
+ if (dependents.has(spec.id))
145
+ continue;
146
+ if (spec.dependsOn.some((dep) => dep === stageId || dependents.has(dep))) {
147
+ dependents.add(spec.id);
148
+ grew = true;
149
+ }
150
+ }
151
+ }
152
+ return [...dependents];
153
+ }
154
+ // ---------------------------------------------------------------------------
155
+ // Evaluation (§5 rule 3) and failure disposal (§5 rule 5)
156
+ // ---------------------------------------------------------------------------
157
+ /** Settlement of a passed evaluation: auto -> completed, review -> awaiting_review. */
158
+ function settlePassedStage(run, spec, runtime, trigger) {
159
+ const from = runtime.status;
160
+ if (spec.advance === "review") {
161
+ runtime.status = "awaiting_review";
162
+ // §5 rule 3: awaiting_review does not count as completed — dependents stay locked.
163
+ return appendTransition(run, spec.id, from, "awaiting_review", trigger, {
164
+ code: "stage.awaiting-review",
165
+ message: `stage ${spec.id} passed evaluation and awaits review`,
166
+ })
167
+ ? 1
168
+ : 0;
169
+ }
170
+ runtime.status = "completed";
171
+ const covered = spec.objective.acceptance.length > 0;
172
+ return appendTransition(run, spec.id, from, "completed", trigger, {
173
+ code: covered ? "stage.completed.acceptance-covered" : "stage.completed",
174
+ message: `stage ${spec.id} completed (${covered ? "acceptance covered" : "plan lower bound"})`,
175
+ })
176
+ ? 1
177
+ : 0;
178
+ }
179
+ /**
180
+ * §5 rule 3 objective evaluation of one ACTIVE stage against its plan: the
181
+ * lower bound is the completed plan's own evidence; declared acceptance adds
182
+ * per-item evidence coverage. Cancelled plan or unmet evaluation -> rule 5
183
+ * failure disposal.
184
+ */
185
+ function evaluateActiveStage(run, spec, runtime, trigger) {
186
+ if (spec.planId === undefined)
187
+ return 0; // checkpoint stages settle via the explicit complete action
188
+ const plan = run.deps.getPlan(spec.planId);
189
+ if (plan === undefined)
190
+ return 0; // unresolvable after activation (plans are append-only); nothing to evaluate
191
+ if (plan.status === "cancelled") {
192
+ return failStage(run, spec, runtime, trigger, "stage.failed.plan-cancelled", `plan ${spec.planId} is cancelled`);
193
+ }
194
+ if (plan.status !== "completed")
195
+ return 0; // waiting: plan-incomplete
196
+ const missing = uncoveredAcceptanceIds(spec, runtime);
197
+ const lowerBoundUnmet = spec.objective.acceptance.length === 0 && plan.evidence.length === 0;
198
+ if (missing.length > 0 || lowerBoundUnmet) {
199
+ return failStage(run, spec, runtime, trigger, "stage.failed.acceptance-missing", `missing acceptance: [${missing.join(", ")}]${lowerBoundUnmet ? " (plan evidence empty)" : ""}`);
200
+ }
201
+ return settlePassedStage(run, spec, runtime, trigger);
202
+ }
203
+ /**
204
+ * §5 rule 5 failure disposal: the stage transition to failed is audited, then
205
+ * either in-place retry (evidence retained, attempts + 1), cross-stage
206
+ * retreat via the stage's onFail edge (when retries are exhausted and onFail
207
+ * is declared), or workflow failure. A failed workflow keeps every stage in
208
+ * its current state — no forced settlement (rule 5, contrast with cancel).
209
+ */
210
+ function failStage(run, spec, runtime, trigger, code, detail) {
211
+ if (!canBump(run, 2))
212
+ return 0; // failure transition + one disposal transition
213
+ const from = runtime.status;
214
+ runtime.status = "failed";
215
+ const appended = appendTransition(run, spec.id, from, "failed", trigger, {
216
+ code,
217
+ message: `stage ${spec.id} failed`,
218
+ detail,
219
+ })
220
+ ? 1
221
+ : 0;
222
+ pushDiagnostic(run.workflow, `stage ${spec.id} failed: ${detail}`);
223
+ runtime.attempts += 1;
224
+ if (runtime.attempts > retryLimitOf(spec)) {
225
+ if (spec.onFail !== undefined) {
226
+ const target = specOf(run.workflow, spec.onFail);
227
+ if (target !== undefined)
228
+ return appended + crossStageRetreat(run, target, trigger, `stage ${spec.id} exhausted retries`, spec);
229
+ }
230
+ const message = `workflow failed: stage ${spec.id} exhausted retry limit ${retryLimitOf(spec)}`;
231
+ pushDiagnostic(run.workflow, message);
232
+ run.workflow.status = "failed";
233
+ return appendTransition(run, undefined, "running", "failed", trigger, {
234
+ code: "workflow.failed.stage-retries",
235
+ message,
236
+ detail: `stage:${spec.id}`,
237
+ })
238
+ ? appended + 1
239
+ : appended;
240
+ }
241
+ runtime.status = "pending";
242
+ return appendTransition(run, spec.id, "failed", "pending", trigger, {
243
+ code: "stage.retry.queued",
244
+ message: `stage ${spec.id} queued for in-place retry (attempt ${runtime.attempts}/${retryLimitOf(spec)}); evidence retained`,
245
+ })
246
+ ? appended + 1
247
+ : appended;
248
+ }
249
+ /**
250
+ * §5 rule 5 cross-stage retreat (explicit onFail, retreat, or reject): the
251
+ * retreat budget is checked first — over budget the workflow fails and the
252
+ * scene is preserved as-is; otherwise the target resets to pending
253
+ * unconditionally (a retry-waiting target loses its evidence too) and every
254
+ * non-pending transitive dependent resets to pending with evidence cleared
255
+ * (their inputs are invalidated — DAG propagation). The failing/rejected
256
+ * `origin` stage itself always resets as well (rule 5 is unconditional): when
257
+ * the onFail target sits on a parallel branch the origin is outside the
258
+ * dependent closure and joins the reset set explicitly. attempts are
259
+ * retained (§4); `skipped` dependents never reset (explicit scope ruling).
260
+ */
261
+ function crossStageRetreat(run, target, trigger, note, origin) {
262
+ const dependents = transitiveDependents(run.workflow, target.id);
263
+ const originOutside = origin !== undefined && origin.id !== target.id && !dependents.includes(origin.id);
264
+ // Retreat audit transition (+ at most one more disposal transition when the
265
+ // origin sits outside the dependent closure and needs its own audit slot).
266
+ if (!canBump(run, originOutside ? 2 : 1))
267
+ return 0;
268
+ run.workflow.retreatCount += 1;
269
+ if (run.workflow.retreatCount > maxRetreatsOf(run.workflow)) {
270
+ const message = `workflow failed: retreat budget exhausted (${run.workflow.retreatCount}/${maxRetreatsOf(run.workflow)})`;
271
+ pushDiagnostic(run.workflow, `${message} — ${note}`);
272
+ run.workflow.status = "failed";
273
+ return appendTransition(run, undefined, "running", "failed", trigger, {
274
+ code: "workflow.failed.retreat-budget",
275
+ message,
276
+ detail: note,
277
+ })
278
+ ? 1
279
+ : 0;
280
+ }
281
+ const targetFrom = runtimeOf(run.workflow, target.id)?.status ?? "pending";
282
+ const resetIds = [];
283
+ for (const stageId of [target.id, ...(originOutside ? [origin.id] : []), ...dependents]) {
284
+ const runtime = runtimeOf(run.workflow, stageId);
285
+ if (runtime === undefined)
286
+ continue;
287
+ if (stageId === target.id ||
288
+ stageId === origin?.id ||
289
+ runtime.status === "completed" ||
290
+ runtime.status === "active" ||
291
+ runtime.status === "awaiting_review" ||
292
+ runtime.status === "failed") {
293
+ runtime.status = "pending";
294
+ runtime.evidence = [];
295
+ resetIds.push(stageId);
296
+ }
297
+ }
298
+ const detail = `target:${target.id} reset:[${resetIds.join(", ")}]${note !== "" ? ` — ${note}` : ""}`;
299
+ return appendTransition(run, target.id, targetFrom, "pending", trigger, {
300
+ code: "stage.retreated",
301
+ message: `retreat to stage ${target.id}: reset [${resetIds.join(", ")}]`,
302
+ detail,
303
+ })
304
+ ? 1
305
+ : 0;
306
+ }
307
+ // ---------------------------------------------------------------------------
308
+ // Activation scan (§5 总则 2) and the reevaluation loop (§5 总则 1)
309
+ // ---------------------------------------------------------------------------
310
+ /**
311
+ * One activation pass over pending stages in declaration order. A pending
312
+ * stage activates only when (a) dependsOn are all completed/skipped (skip
313
+ * counts as completed for unlocking — §5 rule 6), (b) its planId resolves in
314
+ * the current branch entries, (c) the active count is below the cap, (d) a
315
+ * write stage's scope does not intersect an active write stage, and (e)
316
+ * attempts === 0 — retry-waiting stages never auto-activate (anti
317
+ * burnout-spiral, §5 总则 2 e). Each freshly activated stage is immediately
318
+ * re-evaluated once (§5 rule 2), which covers "plan already completed at
319
+ * activation" and pure-checkpoint restore flows.
320
+ */
321
+ function activationPass(run, trigger) {
322
+ let appended = 0;
323
+ for (const spec of run.workflow.stages) {
324
+ const runtime = runtimeOf(run.workflow, spec.id);
325
+ if (runtime === undefined || runtime.status !== "pending" || runtime.attempts !== 0)
326
+ continue; // (e)
327
+ const incomplete = spec.dependsOn.filter((dep) => {
328
+ const dependency = runtimeOf(run.workflow, dep);
329
+ return !(dependency !== undefined && (dependency.status === "completed" || dependency.status === "skipped"));
330
+ });
331
+ if (incomplete.length > 0) {
332
+ if (holdStage(run, spec.id, trigger, "stage.held.deps-incomplete", `stage ${spec.id} waiting: dependencies incomplete [${incomplete.join(", ")}]`, `missing:[${incomplete.join(", ")}]`)) {
333
+ appended += 1;
334
+ }
335
+ continue;
336
+ }
337
+ if (spec.planId !== undefined && run.deps.getPlan(spec.planId) === undefined) {
338
+ if (holdStage(run, spec.id, trigger, "stage.activated.held-plan-dangling", `stage ${spec.id} held: plan ${spec.planId} not found in the session branch`, `plan:${spec.planId}`)) {
339
+ appended += 1;
340
+ }
341
+ continue;
342
+ }
343
+ if (activeCount(run.workflow) >= maxActiveOf(run.workflow)) {
344
+ if (holdStage(run, spec.id, trigger, "stage.held.active-limit", `stage ${spec.id} waiting: active stage limit (${maxActiveOf(run.workflow)}) reached`, `limit:${maxActiveOf(run.workflow)}`)) {
345
+ appended += 1;
346
+ }
347
+ continue;
348
+ }
349
+ if (spec.kind === "write") {
350
+ const conflict = findWriteConflict(run.workflow, spec);
351
+ if (conflict !== undefined) {
352
+ const own = spec.scope === undefined || spec.scope.length === 0 ? "global" : spec.scope.join(",");
353
+ const other = conflict.scope === undefined || conflict.scope.length === 0 ? "global" : conflict.scope.join(",");
354
+ if (holdStage(run, spec.id, trigger, "stage.held.write-scope-conflict", `stage ${spec.id} blocked: write scope [${own}] intersects active stage ${conflict.id} scope [${other}]`, `conflict:${conflict.id} scope:[${own}] vs [${other}]`)) {
355
+ appended += 1;
356
+ }
357
+ continue;
358
+ }
359
+ }
360
+ if (!canBump(run, 3))
361
+ break; // activation + immediate evaluation chain (worst case 2 more)
362
+ runtime.status = "active";
363
+ if (appendTransition(run, spec.id, "pending", "active", trigger, {
364
+ code: "stage.activated",
365
+ message: `stage ${spec.id} activated`,
366
+ })) {
367
+ appended += 1;
368
+ }
369
+ // §5 rule 2: immediate single-stage snapshot reevaluation of the activated stage.
370
+ appended += evaluateActiveStage(run, spec, runtime, "activation-reeval");
371
+ }
372
+ return appended;
373
+ }
374
+ /** §5 rule 7 closure: all stages completed/skipped -> workflow completed, closure evidence recorded. */
375
+ function checkClosure(run, trigger) {
376
+ if (run.workflow.status !== "running")
377
+ return 0;
378
+ if (!run.workflow.runtime.every((item) => item.status === "completed" || item.status === "skipped"))
379
+ return 0;
380
+ if (!canBump(run, 1))
381
+ return 0;
382
+ run.workflow.status = "completed";
383
+ const evidence = run.workflow.runtime
384
+ .filter((item) => item.status === "completed")
385
+ .flatMap((item) => item.evidence.map((entry) => ({ stageId: item.stageId, acceptanceId: entry.acceptanceId, ref: entry.ref })));
386
+ run.closures.push({
387
+ workflowId: run.workflow.id,
388
+ ...(run.workflow.goalId !== undefined ? { goalId: run.workflow.goalId } : {}),
389
+ evidence,
390
+ });
391
+ return appendTransition(run, undefined, "running", "completed", trigger, {
392
+ code: "workflow.completed",
393
+ message: `workflow completed: all ${run.workflow.stages.length} stage(s) settled`,
394
+ detail: `evidence items: ${evidence.length}`,
395
+ })
396
+ ? 1
397
+ : 0;
398
+ }
399
+ /** §5 总则 4: the cascade marker is appended beyond the budget by design (it is the stop record, not a chain step). */
400
+ function markCascadeLimit(run, trigger) {
401
+ const message = `cascade limit ${run.limit} reached; transitions stopped — resumable via any workflow_advance action`;
402
+ pushDiagnostic(run.workflow, message);
403
+ run.workflow.revision += 1;
404
+ run.transitions.push({
405
+ workflowId: run.workflow.id,
406
+ from: run.workflow.status,
407
+ to: run.workflow.status,
408
+ trigger,
409
+ reason: { code: "cascade.limit-reached", message },
410
+ revision: run.workflow.revision,
411
+ });
412
+ }
413
+ /**
414
+ * The reevaluation loop for one running workflow: evaluate active stages ->
415
+ * scan activation -> check closure, repeating to a fixed point (no new
416
+ * persisted transitions). Condition-not-met passes are idempotent no-ops;
417
+ * the held dedup keeps repeat passes audit-silent (§5 总则 1 / 总则 5).
418
+ */
419
+ function reevaluateWorkflow(run, trigger) {
420
+ while (run.workflow.status === "running") {
421
+ if (run.used >= run.limit) {
422
+ run.hitLimit = true;
423
+ break;
424
+ }
425
+ let appended = 0;
426
+ for (const spec of run.workflow.stages) {
427
+ const runtime = runtimeOf(run.workflow, spec.id);
428
+ if (runtime === undefined || runtime.status !== "active")
429
+ continue;
430
+ if (!canBump(run, 2))
431
+ break;
432
+ appended += evaluateActiveStage(run, spec, runtime, trigger);
433
+ }
434
+ if (run.hitLimit)
435
+ break;
436
+ appended += activationPass(run, trigger);
437
+ if (run.hitLimit)
438
+ break;
439
+ appended += checkClosure(run, trigger);
440
+ if (run.hitLimit)
441
+ break;
442
+ if (appended === 0)
443
+ return; // fixed point
444
+ }
445
+ if (run.hitLimit)
446
+ markCascadeLimit(run, trigger);
447
+ }
448
+ /** Snapshot reevaluation over every workflow in the state (one budget per workflow per run). */
449
+ export function reevaluateAll(state, deps, trigger) {
450
+ const workflows = state.workflows.map((workflow) => structuredClone(workflow));
451
+ const run = { state: { workflows }, transitions: [], cascadeLimitReached: false, closures: [] };
452
+ for (const workflow of workflows) {
453
+ if (workflow.status !== "running")
454
+ continue;
455
+ const workflowRun = newWorkflowRun(workflow, deps, run.closures);
456
+ reevaluateWorkflow(workflowRun, trigger);
457
+ run.transitions.push(...workflowRun.transitions);
458
+ if (workflowRun.hitLimit)
459
+ run.cascadeLimitReached = true;
460
+ }
461
+ return run;
462
+ }
463
+ // ---------------------------------------------------------------------------
464
+ // workflow_set application (§6)
465
+ // ---------------------------------------------------------------------------
466
+ /**
467
+ * Applies a graded full-submit update: workflow-level fields, the new stage
468
+ * sequence (runtime aligned by stageId — existing runtimes are preserved,
469
+ * appended stages get fresh pending runtimes), evidence linkage pruning for
470
+ * removed acceptance ids, one stage.spec-amended transition per amended
471
+ * active/awaiting_review stage, then the post-change reevaluation chain
472
+ * (§5 总则 1: conversions settle -> same-call reevaluation).
473
+ */
474
+ export function applyWorkflowSet(state, next, diff, deps) {
475
+ const workflows = state.workflows.map((workflow) => structuredClone(workflow));
476
+ const run = { state: { workflows }, transitions: [], cascadeLimitReached: false, closures: [] };
477
+ if (diff.created) {
478
+ workflows.push({
479
+ id: next.id,
480
+ title: next.title,
481
+ ...(next.goalId !== undefined ? { goalId: next.goalId } : {}),
482
+ status: "draft",
483
+ revision: next.revision,
484
+ stages: next.stages.map((stage) => structuredClone(stage)),
485
+ runtime: next.stages.map((stage) => ({ stageId: stage.id, status: "pending", attempts: 0, evidence: [] })),
486
+ retreatCount: 0,
487
+ ...(next.maxRetreats !== undefined ? { maxRetreats: next.maxRetreats } : {}),
488
+ ...(next.maxActiveStages !== undefined ? { maxActiveStages: next.maxActiveStages } : {}),
489
+ diagnostics: [],
490
+ });
491
+ return run;
492
+ }
493
+ const record = workflows.find((workflow) => workflow.id === next.id);
494
+ if (record === undefined)
495
+ throw new Error(`unknown workflow: ${next.id}`);
496
+ // Workflow-level fields are modifiable while running (§6), revision is the
497
+ // caller's monotonic value; engine conversions below increment past it.
498
+ record.title = next.title;
499
+ if (next.goalId === undefined)
500
+ delete record.goalId;
501
+ else
502
+ record.goalId = next.goalId;
503
+ if (next.maxRetreats === undefined)
504
+ delete record.maxRetreats;
505
+ else
506
+ record.maxRetreats = next.maxRetreats;
507
+ if (next.maxActiveStages === undefined)
508
+ delete record.maxActiveStages;
509
+ else
510
+ record.maxActiveStages = next.maxActiveStages;
511
+ record.revision = next.revision;
512
+ record.stages = next.stages.map((stage) => structuredClone(stage));
513
+ const previousRuntime = new Map(record.runtime.map((item) => [item.stageId, item]));
514
+ record.runtime = record.stages.map((spec) => previousRuntime.get(spec.id) ?? { stageId: spec.id, status: "pending", attempts: 0, evidence: [] });
515
+ // Evidence linkage (§6): an evidence item is dropped when its acceptanceId
516
+ // is non-empty and no longer references the amended acceptance set — this
517
+ // holds even when the amend emptied acceptance entirely, so no dead linkage
518
+ // survives a switch to lower-bound mode. Ref-only items (empty
519
+ // acceptanceId, checkpoint completion records) survive regardless of the
520
+ // spec shape.
521
+ for (const spec of record.stages) {
522
+ const runtime = runtimeOf(record, spec.id);
523
+ if (runtime === undefined || runtime.evidence.length === 0)
524
+ continue;
525
+ const ids = new Set(spec.objective.acceptance.map((item) => item.id));
526
+ runtime.evidence = runtime.evidence.filter((entry) => entry.acceptanceId === "" || ids.has(entry.acceptanceId));
527
+ }
528
+ const workflowRun = newWorkflowRun(record, deps, run.closures);
529
+ for (const change of diff.amendChanges) {
530
+ const runtime = runtimeOf(record, change.stageId);
531
+ const from = runtime?.status ?? "pending";
532
+ const detail = `fields:[${change.fields.join(", ")}] note:${change.amendNote}` +
533
+ (change.prunedEvidence.length > 0 ? ` pruned evidence for [${change.prunedEvidence.join(", ")}]` : "");
534
+ if (canBump(workflowRun, 1)) {
535
+ appendTransition(workflowRun, change.stageId, from, from, "tool", {
536
+ code: "stage.spec-amended",
537
+ message: `stage ${change.stageId} spec amended`,
538
+ detail,
539
+ });
540
+ }
541
+ }
542
+ if (record.status === "running")
543
+ reevaluateWorkflow(workflowRun, "tool");
544
+ run.transitions.push(...workflowRun.transitions);
545
+ if (workflowRun.hitLimit)
546
+ run.cascadeLimitReached = true;
547
+ return run;
548
+ }
549
+ // ---------------------------------------------------------------------------
550
+ // workflow_advance application (§5 rule 6)
551
+ // ---------------------------------------------------------------------------
552
+ function requireStage(workflow, stageId) {
553
+ if (stageId === undefined)
554
+ throw new Error("stageId is required for this action");
555
+ const spec = specOf(workflow, stageId);
556
+ if (spec === undefined)
557
+ throw new Error(`unknown stage: ${stageId} (workflow ${workflow.id})`);
558
+ return spec;
559
+ }
560
+ function normalizeEvidence(items) {
561
+ return items.map((item) => ({ acceptanceId: item.acceptanceId ?? "", ref: item.ref }));
562
+ }
563
+ /**
564
+ * Applies one explicit workflow_advance action, then runs the post-action
565
+ * reevaluation chain (§5 总则 1). Illegal conversions throw structured
566
+ * errors (§5 rule 6) — index.ts surfaces them as tool errors unchanged.
567
+ */
568
+ export function applyAdvance(state, input, deps) {
569
+ const workflows = state.workflows.map((workflow) => structuredClone(workflow));
570
+ const run = { state: { workflows }, transitions: [], cascadeLimitReached: false, closures: [] };
571
+ if (input.action === "inspect") {
572
+ // Read-only and allowed on every workflow status (§5 rule 6), but still
573
+ // scoped to one workflow: an unknown id is a structured error.
574
+ if (!workflows.some((workflow) => workflow.id === input.workflowId)) {
575
+ throw new Error(`unknown workflow: ${input.workflowId}`);
576
+ }
577
+ return run; // response decorations are added by the caller
578
+ }
579
+ const record = workflows.find((workflow) => workflow.id === input.workflowId);
580
+ if (record === undefined)
581
+ throw new Error(`unknown workflow: ${input.workflowId}`);
582
+ if (record.status === "completed" || record.status === "failed" || record.status === "cancelled") {
583
+ throw new Error(`workflow is ${record.status} and accepts no further actions: ${input.workflowId}`);
584
+ }
585
+ const workflowRun = newWorkflowRun(record, deps, run.closures);
586
+ const requireRunning = () => {
587
+ if (record.status !== "running") {
588
+ throw new Error(`workflow is ${record.status}; this action requires a running workflow`);
589
+ }
590
+ };
591
+ const noteSuffix = input.note !== undefined && input.note !== "" ? `: ${input.note}` : "";
592
+ switch (input.action) {
593
+ case "start": {
594
+ if (record.status !== "draft")
595
+ throw new Error(`workflow is already ${record.status}; start requires draft`);
596
+ if (!canBump(workflowRun, 1))
597
+ break;
598
+ const roots = record.stages.filter((spec) => spec.dependsOn.length === 0).length;
599
+ record.status = "running";
600
+ appendTransition(workflowRun, undefined, "draft", "running", "tool", {
601
+ code: "workflow.started",
602
+ message: `workflow started with ${roots} root stage(s)`,
603
+ detail: `roots:${roots}`,
604
+ });
605
+ break;
606
+ }
607
+ case "record-evidence": {
608
+ requireRunning();
609
+ const spec = requireStage(record, input.stageId);
610
+ const runtime = runtimeOf(record, spec.id);
611
+ if (runtime === undefined)
612
+ throw new Error(`unknown stage: ${spec.id}`);
613
+ if (runtime.status === "completed" || runtime.status === "skipped" || runtime.status === "failed") {
614
+ throw new Error(`stage ${spec.id} is ${runtime.status}; record-evidence accepts pending (retry wait), active, and awaiting_review stages`);
615
+ }
616
+ if (spec.objective.acceptance.length === 0) {
617
+ throw new Error(`stage ${spec.id} declares no acceptance (lower-bound mode); record-evidence does not apply — the plan's completion evidence is the lower bound`);
618
+ }
619
+ const items = input.evidence ?? [];
620
+ if (items.length === 0)
621
+ throw new Error("record-evidence requires at least one evidence item");
622
+ for (const item of items) {
623
+ if (item.acceptanceId === undefined || item.acceptanceId.trim() === "") {
624
+ throw new Error(`stage ${spec.id}: evidence items must carry acceptanceId in acceptance mode`);
625
+ }
626
+ if (!spec.objective.acceptance.some((entry) => entry.id === item.acceptanceId)) {
627
+ throw new Error(`stage ${spec.id}: acceptanceId ${item.acceptanceId} does not reference the current spec's acceptance`);
628
+ }
629
+ }
630
+ if (!canBump(workflowRun, 2))
631
+ break; // evidence.recorded + possible single-stage wake settlement
632
+ const from = runtime.status;
633
+ runtime.evidence = [...runtime.evidence, ...normalizeEvidence(items)];
634
+ appendTransition(workflowRun, spec.id, from, from, "tool", {
635
+ code: "evidence.recorded",
636
+ message: `stage ${spec.id}: recorded ${items.length} evidence item(s)`,
637
+ detail: items.map((item) => `${item.acceptanceId} <- ${item.ref}`).join("; "),
638
+ });
639
+ if (runtime.status === "pending" && runtime.attempts > 0) {
640
+ // §5 rule 4 exception — the only automatic wake channel for
641
+ // retry-waiting stages: a single-stage evaluation by the current
642
+ // spec and latest evidence. Pass -> settle (completion propagates
643
+ // through the reevaluation below); still missing -> keep waiting,
644
+ // no attempts consumed.
645
+ const plan = spec.planId !== undefined ? workflowRun.deps.getPlan(spec.planId) : undefined;
646
+ const passed = plan !== undefined &&
647
+ plan.status === "completed" &&
648
+ plan.evidence.length > 0 &&
649
+ uncoveredAcceptanceIds(spec, runtime).length === 0;
650
+ if (passed)
651
+ settlePassedStage(workflowRun, spec, runtime, "tool");
652
+ }
653
+ break;
654
+ }
655
+ case "approve": {
656
+ requireRunning();
657
+ const spec = requireStage(record, input.stageId);
658
+ const runtime = runtimeOf(record, spec.id);
659
+ if (runtime === undefined)
660
+ throw new Error(`unknown stage: ${spec.id}`);
661
+ if (runtime.status !== "awaiting_review") {
662
+ throw new Error(`stage ${spec.id} is ${runtime.status}; approve only settles awaiting_review stages`);
663
+ }
664
+ const items = input.evidence ?? [];
665
+ if (spec.objective.acceptance.length > 0) {
666
+ // Same strong validation as record-evidence/complete: in acceptance
667
+ // mode every carried evidence item must reference the current spec.
668
+ for (const item of items) {
669
+ if (item.acceptanceId === undefined || item.acceptanceId.trim() === "") {
670
+ throw new Error(`stage ${spec.id}: evidence items must carry acceptanceId in acceptance mode`);
671
+ }
672
+ if (!spec.objective.acceptance.some((entry) => entry.id === item.acceptanceId)) {
673
+ throw new Error(`stage ${spec.id}: acceptanceId ${item.acceptanceId} does not reference the current spec's acceptance`);
674
+ }
675
+ }
676
+ }
677
+ if (!canBump(workflowRun, 1))
678
+ break;
679
+ if (items.length > 0)
680
+ runtime.evidence = [...runtime.evidence, ...normalizeEvidence(items)];
681
+ runtime.status = "completed";
682
+ appendTransition(workflowRun, spec.id, "awaiting_review", "completed", "tool", {
683
+ code: spec.objective.acceptance.length > 0 ? "stage.completed.acceptance-covered" : "stage.completed",
684
+ message: `stage ${spec.id} approved in review`,
685
+ detail: input.note,
686
+ });
687
+ break;
688
+ }
689
+ case "reject": {
690
+ requireRunning();
691
+ const spec = requireStage(record, input.stageId);
692
+ const runtime = runtimeOf(record, spec.id);
693
+ if (runtime === undefined)
694
+ throw new Error(`unknown stage: ${spec.id}`);
695
+ if (runtime.status !== "awaiting_review") {
696
+ throw new Error(`stage ${spec.id} is ${runtime.status}; reject only applies to awaiting_review stages`);
697
+ }
698
+ if (spec.onFail !== undefined) {
699
+ // §5 rule 6: reject walks the stage's onFail edge; the rejected
700
+ // stage itself is the retreat origin and always resets (rule 5).
701
+ const target = requireStage(record, spec.onFail);
702
+ crossStageRetreat(workflowRun, target, "tool", `review rejection of stage ${spec.id}${noteSuffix}`, spec);
703
+ }
704
+ else {
705
+ // No onFail edge: in-place retry semantics (rule 6 / rule 5).
706
+ runtime.attempts += 1;
707
+ if (runtime.attempts > retryLimitOf(spec)) {
708
+ const message = `workflow failed: stage ${spec.id} exhausted retry limit ${retryLimitOf(spec)} (rejected in review)`;
709
+ if (canBump(workflowRun, 1)) {
710
+ pushDiagnostic(record, message);
711
+ record.status = "failed";
712
+ appendTransition(workflowRun, undefined, "running", "failed", "tool", {
713
+ code: "workflow.failed.stage-retries",
714
+ message,
715
+ detail: `stage:${spec.id}${noteSuffix}`,
716
+ });
717
+ }
718
+ }
719
+ else if (canBump(workflowRun, 1)) {
720
+ runtime.status = "pending";
721
+ appendTransition(workflowRun, spec.id, "awaiting_review", "pending", "tool", {
722
+ code: "stage.retry.queued",
723
+ message: `stage ${spec.id} rejected in review; queued for in-place retry (attempt ${runtime.attempts}/${retryLimitOf(spec)})`,
724
+ detail: input.note,
725
+ });
726
+ }
727
+ }
728
+ break;
729
+ }
730
+ case "retreat": {
731
+ requireRunning();
732
+ const target = requireStage(record, input.stageId);
733
+ const targetRuntime = runtimeOf(record, target.id);
734
+ if (targetRuntime === undefined)
735
+ throw new Error(`unknown stage: ${target.id}`);
736
+ const wasActivated = targetRuntime.status === "active" ||
737
+ targetRuntime.status === "awaiting_review" ||
738
+ targetRuntime.status === "completed" ||
739
+ targetRuntime.attempts > 0;
740
+ if (!wasActivated) {
741
+ throw new Error(`retreat target ${target.id} has never been activated; retreat targets a previously activated earlier stage`);
742
+ }
743
+ crossStageRetreat(workflowRun, target, "tool", input.note ?? "explicit retreat");
744
+ break;
745
+ }
746
+ case "complete": {
747
+ requireRunning();
748
+ const spec = requireStage(record, input.stageId);
749
+ const runtime = runtimeOf(record, spec.id);
750
+ if (runtime === undefined)
751
+ throw new Error(`unknown stage: ${spec.id}`);
752
+ if (spec.planId !== undefined) {
753
+ throw new Error(`stage ${spec.id} references plan ${spec.planId}; complete only applies to checkpoint stages without planId — plan-backed stages settle via plan completion and evaluation`);
754
+ }
755
+ if (runtime.status !== "active") {
756
+ throw new Error(`stage ${spec.id} is ${runtime.status}; complete requires an active checkpoint stage`);
757
+ }
758
+ const items = input.evidence ?? [];
759
+ if (items.length === 0)
760
+ throw new Error(`stage ${spec.id}: complete requires evidence`);
761
+ const covered = new Set();
762
+ for (const item of items) {
763
+ if (spec.objective.acceptance.length > 0) {
764
+ if (item.acceptanceId === undefined ||
765
+ !spec.objective.acceptance.some((entry) => entry.id === item.acceptanceId)) {
766
+ throw new Error(`stage ${spec.id}: evidence acceptanceId must reference the declared acceptance items`);
767
+ }
768
+ covered.add(item.acceptanceId);
769
+ }
770
+ }
771
+ const missing = spec.objective.acceptance.filter((item) => !covered.has(item.id)).map((item) => item.id);
772
+ if (missing.length > 0) {
773
+ throw new Error(`stage ${spec.id}: evidence does not cover acceptance [${missing.join(", ")}]`);
774
+ }
775
+ if (!canBump(workflowRun, 1))
776
+ break;
777
+ runtime.evidence = [...runtime.evidence, ...normalizeEvidence(items)];
778
+ runtime.status = "completed";
779
+ appendTransition(workflowRun, spec.id, "active", "completed", "tool", {
780
+ code: spec.objective.acceptance.length > 0 ? "stage.completed.acceptance-covered" : "stage.completed",
781
+ message: `stage ${spec.id} completed at checkpoint`,
782
+ detail: items.map((item) => item.ref).join("; "),
783
+ });
784
+ break;
785
+ }
786
+ case "skip": {
787
+ const spec = requireStage(record, input.stageId);
788
+ const runtime = runtimeOf(record, spec.id);
789
+ if (runtime === undefined)
790
+ throw new Error(`unknown stage: ${spec.id}`);
791
+ if (runtime.status !== "pending") {
792
+ throw new Error(`stage ${spec.id} is ${runtime.status}; only pending stages can be skipped`);
793
+ }
794
+ if (!canBump(workflowRun, 1))
795
+ break;
796
+ // §5 rule 6: skipped counts as completed for dependency unlocking —
797
+ // an explicit scope ruling, applied uniformly in closure checks.
798
+ runtime.status = "skipped";
799
+ appendTransition(workflowRun, spec.id, "pending", "skipped", "tool", {
800
+ code: "stage.skipped",
801
+ message: `stage ${spec.id} skipped (explicit scope ruling)`,
802
+ detail: input.note,
803
+ });
804
+ break;
805
+ }
806
+ case "cancel": {
807
+ const from = record.status;
808
+ // §5 rule 6: every non-terminal stage becomes skipped; referenced
809
+ // active plans are left untouched (soft reference — the plan domain
810
+ // keeps its own sovereignty).
811
+ for (const spec of record.stages) {
812
+ const runtime = runtimeOf(record, spec.id);
813
+ if (runtime === undefined || runtime.status === "completed" || runtime.status === "skipped")
814
+ continue;
815
+ if (!canBump(workflowRun, 1))
816
+ break;
817
+ const stageFrom = runtime.status;
818
+ runtime.status = "skipped";
819
+ appendTransition(workflowRun, spec.id, stageFrom, "skipped", "tool", {
820
+ code: "stage.skipped",
821
+ message: `stage ${spec.id} skipped by workflow cancellation`,
822
+ });
823
+ }
824
+ if (!canBump(workflowRun, 1))
825
+ break;
826
+ record.status = "cancelled";
827
+ appendTransition(workflowRun, undefined, from, "cancelled", "tool", {
828
+ code: "workflow.cancelled",
829
+ message: `workflow cancelled (non-terminal stages skipped)`,
830
+ detail: input.note,
831
+ });
832
+ break;
833
+ }
834
+ }
835
+ if (record.status === "running")
836
+ reevaluateWorkflow(workflowRun, "tool");
837
+ run.transitions.push(...workflowRun.transitions);
838
+ if (workflowRun.hitLimit)
839
+ run.cascadeLimitReached = true;
840
+ return run;
841
+ }
842
+ // ---------------------------------------------------------------------------
843
+ // Inspection (§8.1 waiting conditions + acceptance coverage)
844
+ // ---------------------------------------------------------------------------
845
+ /**
846
+ * The §8.1 waiting-condition label for one stage, or null when nothing is
847
+ * blocking it. Label ownership: `acceptance-missing` is exclusively the
848
+ * acceptance-mode retry wait (pending with attempts > 0); `retry-wait` is the
849
+ * lower-bound retry wait (pending with attempts > 0 and no acceptance
850
+ * declared — the plan completed but its evidence is empty); an active stage
851
+ * whose plan is not completed yet is `plan-incomplete`. The check order
852
+ * mirrors activationPass (§8.1: active-limit precedes write-scope conflict),
853
+ * so the inspect label always names the same condition the audit recorded.
854
+ */
855
+ export function computeWaitingOn(workflow, spec, runtime, deps) {
856
+ if (runtime.status === "awaiting_review")
857
+ return "review";
858
+ if (runtime.status === "pending") {
859
+ if (workflow.status === "draft")
860
+ return "not-yet-active";
861
+ if (runtime.attempts > 0) {
862
+ if (spec.objective.acceptance.length === 0)
863
+ return "retry-wait"; // lower-bound retry wait
864
+ return `acceptance-missing:[${uncoveredAcceptanceIds(spec, runtime).join(", ")}]`;
865
+ }
866
+ const incomplete = spec.dependsOn.filter((dep) => {
867
+ const dependency = runtimeOf(workflow, dep);
868
+ return !(dependency !== undefined && (dependency.status === "completed" || dependency.status === "skipped"));
869
+ });
870
+ if (incomplete.length > 0)
871
+ return `deps-incomplete:[${incomplete.join(", ")}]`;
872
+ if (spec.planId !== undefined && deps.getPlan(spec.planId) === undefined)
873
+ return "plan-dangling";
874
+ if (activeCount(workflow) >= maxActiveOf(workflow))
875
+ return "active-limit";
876
+ if (spec.kind === "write") {
877
+ const conflict = findWriteConflict(workflow, spec);
878
+ if (conflict !== undefined)
879
+ return `write-scope-conflict:[${conflict.id}]`;
880
+ }
881
+ return "not-yet-active";
882
+ }
883
+ if (runtime.status === "active") {
884
+ if (spec.planId === undefined)
885
+ return null; // checkpoint waiting for the explicit complete action
886
+ const plan = deps.getPlan(spec.planId);
887
+ if (plan === undefined)
888
+ return "plan-dangling";
889
+ if (plan.status !== "completed")
890
+ return "plan-incomplete";
891
+ return null; // completed plan, evaluation pending the next reevaluation trigger
892
+ }
893
+ return null;
894
+ }
895
+ /** §5 rule 6 inspect payload: full snapshot plus per-stage waiting conditions and acceptance coverage (§8.1). */
896
+ export function inspectWorkflow(workflow, deps) {
897
+ return {
898
+ workflowId: workflow.id,
899
+ status: workflow.status,
900
+ stages: workflow.stages.map((spec) => {
901
+ const runtime = runtimeOf(workflow, spec.id);
902
+ const safeRuntime = runtime ?? {
903
+ stageId: spec.id,
904
+ status: "pending",
905
+ attempts: 0,
906
+ evidence: [],
907
+ };
908
+ const plan = spec.planId !== undefined ? deps.getPlan(spec.planId) : undefined;
909
+ return {
910
+ stageId: spec.id,
911
+ status: safeRuntime.status,
912
+ attempts: safeRuntime.attempts,
913
+ waitingOn: computeWaitingOn(workflow, spec, safeRuntime, deps),
914
+ ...(spec.planId !== undefined ? { planId: spec.planId, planStatus: plan?.status ?? "missing" } : {}),
915
+ acceptance: spec.objective.acceptance.map((item) => {
916
+ const refs = safeRuntime.evidence
917
+ .filter((entry) => entry.acceptanceId === item.id)
918
+ .map((entry) => entry.ref);
919
+ return { id: item.id, statement: item.statement, covered: refs.length > 0, refs };
920
+ }),
921
+ };
922
+ }),
923
+ };
924
+ }
925
+ //# sourceMappingURL=engine.js.map