@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/README.md +29 -0
- package/agent-forge.json +19 -0
- package/dist/engine.d.ts +130 -0
- package/dist/engine.d.ts.map +1 -0
- package/dist/engine.js +925 -0
- package/dist/engine.js.map +1 -0
- package/dist/entry.d.ts +14 -0
- package/dist/entry.d.ts.map +1 -0
- package/dist/entry.js +23 -0
- package/dist/entry.js.map +1 -0
- package/dist/index.d.ts +28 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +456 -0
- package/dist/index.js.map +1 -0
- package/dist/render.d.ts +20 -0
- package/dist/render.d.ts.map +1 -0
- package/dist/render.js +128 -0
- package/dist/render.js.map +1 -0
- package/dist/types.d.ts +89 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +14 -0
- package/dist/types.js.map +1 -0
- package/dist/validate.d.ts +60 -0
- package/dist/validate.d.ts.map +1 -0
- package/dist/validate.js +311 -0
- package/dist/validate.js.map +1 -0
- package/package.json +54 -0
- package/plugin.json +12 -0
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
|