@kontourai/flow-agents 2.0.0 → 2.0.1
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/.github/workflows/runtime-compat.yml +1 -1
- package/CHANGELOG.md +8 -0
- package/build/src/cli/workflow-sidecar.d.ts +16 -0
- package/build/src/cli/workflow-sidecar.js +64 -10
- package/build/src/lib/flow-resolver.d.ts +29 -0
- package/build/src/lib/flow-resolver.js +71 -0
- package/evals/ci/antigaming-suite.sh +1 -0
- package/evals/integration/test_command_log_fork_classification.sh +134 -0
- package/evals/integration/test_kit_identity_trust.sh +393 -0
- package/evals/run.sh +2 -0
- package/package.json +4 -4
- package/scripts/hooks/stop-goal-fit.js +76 -23
- package/scripts/repair-command-log.js +115 -0
- package/src/cli/workflow-sidecar.ts +65 -9
- package/src/lib/flow-resolver.ts +85 -0
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
/**
|
|
4
|
+
* repair-command-log.js — deterministic re-linearization of a concurrent-fork
|
|
5
|
+
* command-log.jsonl.
|
|
6
|
+
*
|
|
7
|
+
* A "fork" happens when two PostToolUse captures append off the same parent tip
|
|
8
|
+
* (parallel writers before the writer lock, flow-agents#232). The records are
|
|
9
|
+
* all genuine and self-consistent; only their linear order is ambiguous. This
|
|
10
|
+
* tool produces THE canonical order — sort chained entries by (capturedAt, then
|
|
11
|
+
* hash) — and re-chains them, so any party re-running it gets the identical
|
|
12
|
+
* result. It is therefore a verifiable repair, not a judgement call.
|
|
13
|
+
*
|
|
14
|
+
* SAFETY: it refuses to run unless verifyCommandLogChain() reports "forked".
|
|
15
|
+
* - "broken" (real tamper: edited content, reorder, deletion) → REFUSE. The
|
|
16
|
+
* repair must never be usable to launder tampering.
|
|
17
|
+
* - "ok" / "legacy" → nothing to do.
|
|
18
|
+
* No record content is altered — only the _chain wrappers and line order. The
|
|
19
|
+
* original is backed up, and an in-chain `chain-repair` marker records that the
|
|
20
|
+
* re-linearization happened (so the repair is itself auditable).
|
|
21
|
+
*
|
|
22
|
+
* Usage: node scripts/repair-command-log.js <artifact-dir> [--reason "..."]
|
|
23
|
+
*/
|
|
24
|
+
const fs = require('fs');
|
|
25
|
+
const path = require('path');
|
|
26
|
+
const crypto = require('crypto');
|
|
27
|
+
|
|
28
|
+
const gate = require(path.join(__dirname, 'hooks', 'stop-goal-fit.js'));
|
|
29
|
+
const GENESIS = gate.CHAIN_GENESIS_VERIFY;
|
|
30
|
+
|
|
31
|
+
function canon(rec) {
|
|
32
|
+
const keys = Object.keys(rec).filter((k) => k !== '_chain').sort();
|
|
33
|
+
const obj = {};
|
|
34
|
+
for (const k of keys) obj[k] = rec[k];
|
|
35
|
+
return JSON.stringify(obj);
|
|
36
|
+
}
|
|
37
|
+
function hashLink(prev, rec) {
|
|
38
|
+
return crypto.createHash('sha256').update(prev + canon(rec), 'utf8').digest('hex');
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function main() {
|
|
42
|
+
const dir = process.argv[2];
|
|
43
|
+
if (!dir) { console.error('usage: repair-command-log.js <artifact-dir> [--reason "..."]'); process.exit(2); }
|
|
44
|
+
const reasonIdx = process.argv.indexOf('--reason');
|
|
45
|
+
const reason = reasonIdx !== -1 ? (process.argv[reasonIdx + 1] || '') : 'deterministic concurrent-fork re-linearization';
|
|
46
|
+
|
|
47
|
+
const verdict = gate.verifyCommandLogChain(dir);
|
|
48
|
+
if (verdict.status === 'ok' || verdict.status === 'legacy') {
|
|
49
|
+
console.log(`nothing to repair: chain status is "${verdict.status}"`);
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
if (verdict.status !== 'forked') {
|
|
53
|
+
console.error(`REFUSING to repair: chain status is "${verdict.status}" (entry ${verdict.brokenAt}). ` +
|
|
54
|
+
'This tool only re-linearizes benign concurrent forks; it will not touch a tampered chain.');
|
|
55
|
+
process.exit(1);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const file = path.join(dir, 'command-log.jsonl');
|
|
59
|
+
const lines = fs.readFileSync(file, 'utf8').split('\n').filter((l) => l.trim());
|
|
60
|
+
|
|
61
|
+
// Preserve legacy prefix verbatim; collect the chained records (content only).
|
|
62
|
+
const legacyPrefix = [];
|
|
63
|
+
const records = [];
|
|
64
|
+
let started = false;
|
|
65
|
+
for (const line of lines) {
|
|
66
|
+
let e;
|
|
67
|
+
try { e = JSON.parse(line); } catch { if (!started) { legacyPrefix.push(line); } continue; }
|
|
68
|
+
const isChained = e._chain && typeof e._chain.hash === 'string';
|
|
69
|
+
if (!started && !isChained) { legacyPrefix.push(line); continue; }
|
|
70
|
+
started = true;
|
|
71
|
+
const rec = { ...e };
|
|
72
|
+
delete rec._chain;
|
|
73
|
+
records.push(rec);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Canonical deterministic order: capturedAt asc, then a stable content hash.
|
|
77
|
+
records.sort((a, b) => {
|
|
78
|
+
const ta = String(a.capturedAt || ''), tb = String(b.capturedAt || '');
|
|
79
|
+
if (ta !== tb) return ta < tb ? -1 : 1;
|
|
80
|
+
const ha = crypto.createHash('sha256').update(canon(a)).digest('hex');
|
|
81
|
+
const hb = crypto.createHash('sha256').update(canon(b)).digest('hex');
|
|
82
|
+
return ha < hb ? -1 : ha > hb ? 1 : 0;
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
// Re-chain from genesis.
|
|
86
|
+
const out = [...legacyPrefix];
|
|
87
|
+
let prev = GENESIS;
|
|
88
|
+
let seq = 0;
|
|
89
|
+
for (const rec of records) {
|
|
90
|
+
const h = hashLink(prev, rec);
|
|
91
|
+
out.push(JSON.stringify({ ...rec, _chain: { seq, prevHash: prev, hash: h } }));
|
|
92
|
+
prev = h; seq += 1;
|
|
93
|
+
}
|
|
94
|
+
// Append an in-chain repair marker so the re-linearization is itself auditable.
|
|
95
|
+
const marker = {
|
|
96
|
+
command: '(chain-repair marker)',
|
|
97
|
+
observedResult: `re-linearized ${records.length} entries from concurrent fork`,
|
|
98
|
+
exitCode: 0,
|
|
99
|
+
capturedAt: new Date().toISOString(),
|
|
100
|
+
source: 'chain-repair',
|
|
101
|
+
repair: { reason, entries: records.length, forkAt: verdict.forkAt },
|
|
102
|
+
};
|
|
103
|
+
const mh = hashLink(prev, marker);
|
|
104
|
+
out.push(JSON.stringify({ ...marker, _chain: { seq, prevHash: prev, hash: mh } }));
|
|
105
|
+
|
|
106
|
+
fs.copyFileSync(file, file + '.prebackup-repair');
|
|
107
|
+
fs.writeFileSync(file, out.join('\n') + '\n');
|
|
108
|
+
|
|
109
|
+
const after = gate.verifyCommandLogChain(dir);
|
|
110
|
+
console.log(`repaired: re-linearized ${records.length} entries (legacy prefix: ${legacyPrefix.length}); ` +
|
|
111
|
+
`chain status now "${after.status}". backup: command-log.jsonl.prebackup-repair`);
|
|
112
|
+
if (after.status !== 'ok') { console.error('repair did not produce a clean chain'); process.exit(1); }
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
main();
|
|
@@ -6,7 +6,7 @@ import { createHash } from "node:crypto";
|
|
|
6
6
|
import { createRequire } from "node:module";
|
|
7
7
|
import { fileURLToPath } from "node:url";
|
|
8
8
|
// ADR 0016 Abstraction A: shared FlowDefinition resolver (P-a)
|
|
9
|
-
import { resolveActiveFlowStep, resolveFlowFilePath, resolvePhaseMap, type ActiveFlowStep } from "../lib/flow-resolver.js";
|
|
9
|
+
import { resolveActiveFlowStep, resolveFlowFilePath, resolvePhaseMap, resolveRouteBackPolicy, type ActiveFlowStep } from "../lib/flow-resolver.js";
|
|
10
10
|
|
|
11
11
|
type AnyObj = Record<string, any>;
|
|
12
12
|
|
|
@@ -1026,21 +1026,71 @@ function deriveSurfaceStatus(ref: AnyObj): string {
|
|
|
1026
1026
|
if (ref.integrity?.status !== "matched") return "fail";
|
|
1027
1027
|
return "pass";
|
|
1028
1028
|
}
|
|
1029
|
+
/**
|
|
1030
|
+
* Derive kit identity from a parsed trust.bundle by structurally reading the
|
|
1031
|
+
* DECLARED primary claim (kit-typed) rather than hardcoding "builder".
|
|
1032
|
+
*
|
|
1033
|
+
* Resolution order (no fallbacks to "builder"):
|
|
1034
|
+
* 1. First non-workflow.* claim in bundle.claims[] → claimType drives kitId + subject.
|
|
1035
|
+
* 2. No kit-typed claim: try current.json active_flow_id adjacent to the bundle file
|
|
1036
|
+
* (bundle lives at <session-dir>/trust.bundle → flowAgentsDir = grandparent).
|
|
1037
|
+
* 3. Genuinely unknown: mark as "unknown" — never hardcode a kit identity.
|
|
1038
|
+
*/
|
|
1039
|
+
export function kitIdentityFromBundle(
|
|
1040
|
+
raw: AnyObj,
|
|
1041
|
+
bundleFile: string,
|
|
1042
|
+
): { claimType: string; kitId: string; subject: string; gateId: string } {
|
|
1043
|
+
// 1. Structurally read the bundle's declared kit-typed claim.
|
|
1044
|
+
const claims: AnyObj[] = Array.isArray(raw.claims) ? raw.claims : [];
|
|
1045
|
+
for (const claim of claims) {
|
|
1046
|
+
const ct = typeof claim?.claimType === "string" ? claim.claimType : "";
|
|
1047
|
+
if (ct && !ct.startsWith("workflow.")) {
|
|
1048
|
+
const kitId = ct.split(".")[0] ?? "unknown";
|
|
1049
|
+
if (kitId && kitId !== "unknown") {
|
|
1050
|
+
return { claimType: ct, kitId, subject: `${kitId}-kit`, gateId: ct };
|
|
1051
|
+
}
|
|
1052
|
+
}
|
|
1053
|
+
}
|
|
1054
|
+
// 2. No kit-typed claim in bundle — try to derive kit from current.json active_flow_id.
|
|
1055
|
+
// The bundle lives at <session-dir>/trust.bundle, so:
|
|
1056
|
+
// sessionDir = path.dirname(bundleFile)
|
|
1057
|
+
// flowAgentsDir = path.dirname(sessionDir)
|
|
1058
|
+
try {
|
|
1059
|
+
const sessionDir = path.dirname(bundleFile);
|
|
1060
|
+
const flowAgentsDir = path.dirname(sessionDir);
|
|
1061
|
+
const currentFile = path.join(flowAgentsDir, "current.json");
|
|
1062
|
+
const current = JSON.parse(fs.readFileSync(currentFile, "utf8")) as Record<string, unknown>;
|
|
1063
|
+
const flowId = typeof current["active_flow_id"] === "string" ? current["active_flow_id"] : null;
|
|
1064
|
+
if (flowId && flowId.includes(".")) {
|
|
1065
|
+
const kitId = flowId.split(".")[0]!;
|
|
1066
|
+
if (kitId) {
|
|
1067
|
+
const derivedClaimType = `${kitId}.trust.bundle`;
|
|
1068
|
+
return { claimType: derivedClaimType, kitId, subject: `${kitId}-kit`, gateId: derivedClaimType };
|
|
1069
|
+
}
|
|
1070
|
+
}
|
|
1071
|
+
} catch {
|
|
1072
|
+
// Ignore — fall through to unknown
|
|
1073
|
+
}
|
|
1074
|
+
// 3. Genuinely unknown — never fallback to "builder".
|
|
1075
|
+
return { claimType: "unknown.trust.bundle", kitId: "unknown", subject: "unknown-kit", gateId: "unknown.trust.bundle" };
|
|
1076
|
+
}
|
|
1029
1077
|
function surfaceCheckFromArtifact(file: string, index: number): AnyObj {
|
|
1030
1078
|
const raw = JSON.parse(read(file));
|
|
1031
1079
|
const lower = JSON.stringify(raw).toLowerCase();
|
|
1080
|
+
// Structurally read kit identity from the bundle — never hardcode "builder".
|
|
1081
|
+
const { claimType: bundleClaimType, subject: bundleSubject, gateId: bundleGateId } = kitIdentityFromBundle(raw, file);
|
|
1032
1082
|
let ref: AnyObj;
|
|
1033
1083
|
if (lower.includes("provider") && lower.includes("absent")) {
|
|
1034
|
-
ref = { artifact_kind: "trust.bundle", artifact_ref: file, gate_id: "provider.unavailable", claim_type:
|
|
1084
|
+
ref = { artifact_kind: "trust.bundle", artifact_ref: file, gate_id: "provider.unavailable", claim_type: bundleClaimType, claim_status: "unknown", subject: bundleSubject, freshness: { status: "unknown", summary: "No trust provider is configured" }, authority: { producer: "unknown", summary: "No trust provider is configured" }, integrity: { status: "unknown", summary: "Unknown" }, status: "not_verified", summary: "No trust provider is configured" };
|
|
1035
1085
|
} else if (lower.includes("artifact") && lower.includes("absent")) {
|
|
1036
|
-
ref = { artifact_kind: "trust.bundle", artifact_ref: file, gate_id: "artifact.unavailable", claim_type:
|
|
1086
|
+
ref = { artifact_kind: "trust.bundle", artifact_ref: file, gate_id: "artifact.unavailable", claim_type: bundleClaimType, claim_status: "unknown", subject: bundleSubject, freshness: { status: "unknown", summary: "Artifact not readable" }, authority: { producer: "unknown", summary: "Artifact not readable" }, integrity: { status: "unknown", summary: "Artifact not readable" }, status: "not_verified", summary: "artifact not readable" };
|
|
1037
1087
|
} else {
|
|
1038
1088
|
const claimStatus = lower.includes("rejected") ? "rejected" : "accepted";
|
|
1039
1089
|
const freshness = lower.includes("stale") ? "stale" : "fresh";
|
|
1040
1090
|
const producer = lower.includes("missing-authority") ? "unknown" : "surface-local";
|
|
1041
1091
|
const integrity = lower.includes("mismatch") ? "mismatch" : "matched";
|
|
1042
1092
|
// Use trust.bundle as the canonical Hachure-aligned artifact_kind for all trust-backed evidence refs
|
|
1043
|
-
ref = { artifact_kind: "trust.bundle", artifact_ref: file, gate_id:
|
|
1093
|
+
ref = { artifact_kind: "trust.bundle", artifact_ref: file, gate_id: bundleGateId, claim_type: bundleClaimType, claim_status: claimStatus, subject: bundleSubject, freshness: { status: freshness, summary: freshness === "fresh" ? "fresh" : "not currently verifiable" }, authority: { producer, summary: producer === "unknown" ? "missing authority" : "Local Surface trust producer." }, integrity: { status: integrity, summary: integrity === "matched" ? "matched" : "integrity mismatch" } };
|
|
1044
1094
|
ref.status = deriveSurfaceStatus(ref);
|
|
1045
1095
|
ref.summary = ref.status === "pass" ? "accepted" : ref.status === "not_verified" ? "not currently verifiable" : (claimStatus === "rejected" ? "rejected" : producer === "unknown" ? "missing authority" : "integrity mismatch");
|
|
1046
1096
|
}
|
|
@@ -1279,14 +1329,20 @@ async function advanceState(p: ReturnType<typeof parseArgs>): Promise<number> {
|
|
|
1279
1329
|
const prev = loadJson(path.join(dir, "state.json"));
|
|
1280
1330
|
if ((status === "archived" || status === "accepted") && prev.phase !== "learning") diagnostic(dir, "terminal_jump_rejected", "Terminal workflow states require release and learning gates.");
|
|
1281
1331
|
const flow = opt(p, "flow-definition");
|
|
1282
|
-
|
|
1332
|
+
// Route-back guard: FlowDefinition-driven (not hardcoded to builder.build).
|
|
1333
|
+
// Fires when the active flow's gate for prev.phase declares a route_back_policy
|
|
1334
|
+
// AND the target phase maps to a step listed in on_route_back values.
|
|
1335
|
+
// builder.build verify-gate already carries this declaration — behavior preserved.
|
|
1336
|
+
const repoRoot = flow ? findRepoRootFromDir(dir) : "";
|
|
1337
|
+
const routeBack = flow ? resolveRouteBackPolicy(flow, prev.phase, phase, repoRoot) : null;
|
|
1338
|
+
if (routeBack) {
|
|
1283
1339
|
const reason = opt(p, "route-back-reason");
|
|
1284
|
-
if (!reason) diagnostic(dir, "route_back_reason_required",
|
|
1340
|
+
if (!reason) diagnostic(dir, "route_back_reason_required", `Route-back from ${prev.phase} to ${phase} requires a --route-back-reason (e.g. implementation_defect).`);
|
|
1285
1341
|
const file = path.join(dir, "transition-attempts.json");
|
|
1286
1342
|
const attempts = loadJson(file);
|
|
1287
|
-
const key =
|
|
1343
|
+
const key = `${prev.phase}->${phase}:${reason}`;
|
|
1288
1344
|
const count = attempts[key]?.count ?? 0;
|
|
1289
|
-
if (count >=
|
|
1345
|
+
if (count >= routeBack.maxAttempts) diagnostic(dir, "route_back_attempts_exceeded", `Route-back attempt limit (${routeBack.maxAttempts}) exceeded for ${prev.phase}→${phase}.`);
|
|
1290
1346
|
attempts[key] = { count: count + 1, reason, updated_at: opt(p, "timestamp", now()) };
|
|
1291
1347
|
writeJson(file, attempts);
|
|
1292
1348
|
}
|
|
@@ -1300,7 +1356,7 @@ async function advanceState(p: ReturnType<typeof parseArgs>): Promise<number> {
|
|
|
1300
1356
|
// --step-id individually. The repoRoot is derived by walking up from dir to find kits/.
|
|
1301
1357
|
if (flow) {
|
|
1302
1358
|
const root = path.resolve(opt(p, "artifact-root", path.dirname(dir)));
|
|
1303
|
-
|
|
1359
|
+
// repoRoot already computed above when flow is present
|
|
1304
1360
|
const phaseMap = resolvePhaseMap(flow, repoRoot);
|
|
1305
1361
|
const stepId = phaseMap?.[phase] ?? undefined;
|
|
1306
1362
|
if (stepId) {
|
package/src/lib/flow-resolver.ts
CHANGED
|
@@ -124,6 +124,10 @@ export type ActiveFlowStep = {
|
|
|
124
124
|
type FlowGate = {
|
|
125
125
|
step: string;
|
|
126
126
|
expects?: GateExpectation[];
|
|
127
|
+
/** Reason-to-step mapping for route-back transitions (e.g. {"implementation_defect": "execute"}). */
|
|
128
|
+
on_route_back?: Record<string, string>;
|
|
129
|
+
/** Policy governing route-back attempt limits. */
|
|
130
|
+
route_back_policy?: { max_attempts: number; on_exceeded: string };
|
|
127
131
|
};
|
|
128
132
|
|
|
129
133
|
/** Shape of a FlowDefinition JSON file. */
|
|
@@ -282,3 +286,84 @@ export function resolveActiveFlowStep(flowAgentsDir: string): ActiveFlowStep | n
|
|
|
282
286
|
const repoRoot = findRepoRoot(path.dirname(flowAgentsDir));
|
|
283
287
|
return resolveFlowStep(flowId, stepId, repoRoot);
|
|
284
288
|
}
|
|
289
|
+
|
|
290
|
+
/** The resolved route-back policy for a phase transition. */
|
|
291
|
+
export type RouteBackPolicy = {
|
|
292
|
+
/** Maximum allowed route-back attempts for this transition key. */
|
|
293
|
+
maxAttempts: number;
|
|
294
|
+
/** Action when attempts are exceeded (e.g. "block"). */
|
|
295
|
+
onExceeded: string;
|
|
296
|
+
/** The step id whose gate declared this policy (e.g. "verify"). */
|
|
297
|
+
fromStepId: string;
|
|
298
|
+
};
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* Resolve the route-back policy for a phase transition, if the active FlowDefinition
|
|
302
|
+
* declares one on the source phase's gate.
|
|
303
|
+
*
|
|
304
|
+
* A route-back is a transition where the source phase's gate declares both
|
|
305
|
+
* `route_back_policy` and `on_route_back`, and the target phase maps to a step
|
|
306
|
+
* listed as a route-back target in `on_route_back` values.
|
|
307
|
+
*
|
|
308
|
+
* This is the FlowDefinition-driven replacement for the hardcoded
|
|
309
|
+
* `flow === "builder.build" && prev.phase === "verification" && phase === "execution"`
|
|
310
|
+
* guard in advance-state. Any flow that declares `route_back_policy` on a gate
|
|
311
|
+
* automatically gets route-back enforcement without code changes.
|
|
312
|
+
*
|
|
313
|
+
* @param flowId e.g. "builder.build" — kitId is the prefix before the first ".".
|
|
314
|
+
* @param fromPhase Lifecycle phase leaving (e.g. "verification").
|
|
315
|
+
* @param toPhase Lifecycle phase entering (e.g. "execution").
|
|
316
|
+
* @param repoRoot Absolute path to the repository root (kits/ lives here).
|
|
317
|
+
* @returns RouteBackPolicy when the transition is a declared route-back, null otherwise.
|
|
318
|
+
*/
|
|
319
|
+
export function resolveRouteBackPolicy(
|
|
320
|
+
flowId: string,
|
|
321
|
+
fromPhase: string,
|
|
322
|
+
toPhase: string,
|
|
323
|
+
repoRoot: string,
|
|
324
|
+
): RouteBackPolicy | null {
|
|
325
|
+
if (!flowId || !fromPhase || !toPhase) return null;
|
|
326
|
+
const dotIdx = flowId.indexOf(".");
|
|
327
|
+
if (dotIdx < 1) return null;
|
|
328
|
+
const kitId = flowId.slice(0, dotIdx);
|
|
329
|
+
const flowName = flowId.slice(dotIdx + 1);
|
|
330
|
+
if (!kitId || !flowName) return null;
|
|
331
|
+
|
|
332
|
+
const flowFilePath = resolveFlowFilePath(kitId, flowName, flowId, repoRoot);
|
|
333
|
+
if (!flowFilePath) return null;
|
|
334
|
+
|
|
335
|
+
let flowDef: FlowDefinition;
|
|
336
|
+
try {
|
|
337
|
+
const raw = fs.readFileSync(flowFilePath, "utf8");
|
|
338
|
+
flowDef = JSON.parse(raw) as FlowDefinition;
|
|
339
|
+
} catch {
|
|
340
|
+
return null; // ENOENT, permission error, or parse error — fail-open
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
if (!flowDef || typeof flowDef !== "object") return null;
|
|
344
|
+
const phaseMap = flowDef.phase_map;
|
|
345
|
+
if (!phaseMap || typeof phaseMap !== "object" || Array.isArray(phaseMap)) return null;
|
|
346
|
+
|
|
347
|
+
const fromStep = phaseMap[fromPhase];
|
|
348
|
+
const toStep = phaseMap[toPhase];
|
|
349
|
+
if (!fromStep || !toStep) return null; // phases not in this flow
|
|
350
|
+
|
|
351
|
+
if (!flowDef.gates) return null;
|
|
352
|
+
for (const gate of Object.values(flowDef.gates)) {
|
|
353
|
+
if (!gate || gate.step !== fromStep) continue;
|
|
354
|
+
if (!gate.route_back_policy || !gate.on_route_back) return null;
|
|
355
|
+
// Check if toStep is a valid route-back target declared in on_route_back
|
|
356
|
+
const routeBackTargets = Object.values(gate.on_route_back);
|
|
357
|
+
if (!routeBackTargets.includes(toStep)) return null;
|
|
358
|
+
const maxAttempts =
|
|
359
|
+
typeof gate.route_back_policy.max_attempts === "number"
|
|
360
|
+
? gate.route_back_policy.max_attempts
|
|
361
|
+
: 3;
|
|
362
|
+
return {
|
|
363
|
+
maxAttempts,
|
|
364
|
+
onExceeded: gate.route_back_policy.on_exceeded ?? "block",
|
|
365
|
+
fromStepId: fromStep,
|
|
366
|
+
};
|
|
367
|
+
}
|
|
368
|
+
return null;
|
|
369
|
+
}
|