@guanlin4924/devflow-pi 0.1.37 → 0.1.39
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 +1 -1
- package/package.json +1 -1
- package/plugins/devflow-core/extensions/devflow-core.ts +93 -18
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/advance_state.rb +94 -1
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/checker_registry.rb +30 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/gate_contract.rb +64 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/gate_result.rb +40 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/gate_runtime.rb +88 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/machine_evidence.rb +73 -3
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/requirement_coverage.rb +173 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/requirement_graph.rb +14 -8
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/validate_state.rb +55 -4
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/workflow_gate_contract.rb +56 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/templates/feature-development.yaml +24 -0
- package/src/constants.ts +1 -1
package/README.md
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { createHash, randomUUID } from "node:crypto";
|
|
2
2
|
import { existsSync, lstatSync, readFileSync, readdirSync, realpathSync } from "node:fs";
|
|
3
|
-
import { mkdir, rename, writeFile } from "node:fs/promises";
|
|
3
|
+
import { appendFile, mkdir, rename, writeFile } from "node:fs/promises";
|
|
4
4
|
import path from "node:path";
|
|
5
5
|
import { fileURLToPath } from "node:url";
|
|
6
6
|
import type { ExtensionAPI } from "@oh-my-pi/pi-coding-agent";
|
|
@@ -864,6 +864,27 @@ export default function devflowCore(pi: ExtensionAPI) {
|
|
|
864
864
|
let controlState: SessionControlState = initialControlState();
|
|
865
865
|
let activeContract: ActiveExecutionContract | undefined;
|
|
866
866
|
let contractAgentCalls = 0;
|
|
867
|
+
let projectRoot: string | undefined;
|
|
868
|
+
|
|
869
|
+
async function appendEngineEvidence(cwd: string | undefined, event: string, extra: Record<string, unknown> = {}) {
|
|
870
|
+
if (!cwd || !activeContract) return;
|
|
871
|
+
const feature = activeContract.feature;
|
|
872
|
+
const step = activeContract.step;
|
|
873
|
+
if (!/^[\p{L}\p{N}._-]{1,160}$/u.test(feature) || !/^[\p{L}\p{N}._-]{1,160}$/u.test(step)) return;
|
|
874
|
+
const directory = path.join(cwd, ".planning", "evidence", feature);
|
|
875
|
+
const destination = path.join(directory, `${step}.engine-events.jsonl`);
|
|
876
|
+
await mkdir(directory, { recursive: true });
|
|
877
|
+
await appendFile(destination, `${JSON.stringify({
|
|
878
|
+
schema_version: 1,
|
|
879
|
+
timestamp: new Date().toISOString(),
|
|
880
|
+
session_id: sessionId,
|
|
881
|
+
feature,
|
|
882
|
+
step,
|
|
883
|
+
event,
|
|
884
|
+
collector: "devflow-runtime/harness",
|
|
885
|
+
...extra,
|
|
886
|
+
})}\n`, "utf8");
|
|
887
|
+
}
|
|
867
888
|
|
|
868
889
|
function controlGuide(state: SessionControlState): string[] {
|
|
869
890
|
const title = "Devflow 操作指引";
|
|
@@ -1285,6 +1306,22 @@ export default function devflowCore(pi: ExtensionAPI) {
|
|
|
1285
1306
|
return { result, snapshot: parseJsonResult(result) };
|
|
1286
1307
|
}
|
|
1287
1308
|
|
|
1309
|
+
async function inspectWorkflowWithRegistryRecovery(cwd: string): Promise<{ result: ProcessResult; snapshot: Record<string, unknown> | null }> {
|
|
1310
|
+
let inspected = await inspectWorkflow(cwd);
|
|
1311
|
+
const errors = Array.isArray(inspected.snapshot?.state_validation && (inspected.snapshot.state_validation as Record<string, unknown>).errors)
|
|
1312
|
+
? (inspected.snapshot!.state_validation as Record<string, unknown>).errors as Array<Record<string, unknown>>
|
|
1313
|
+
: [];
|
|
1314
|
+
const onlyRegistryDrift = inspected.snapshot?.reason === "state_validation_failed" &&
|
|
1315
|
+
errors.length > 0 && errors.every(error => error.code === "artifact_registry_revision_mismatch");
|
|
1316
|
+
if (!onlyRegistryDrift) return inspected;
|
|
1317
|
+
const synced = await runRuby(cwd, "workflow-orchestrator/scripts/sync_artifacts.rb", [
|
|
1318
|
+
"--write", "--no-preserve", "--json",
|
|
1319
|
+
]);
|
|
1320
|
+
if (!synced.ok) return inspected;
|
|
1321
|
+
inspected = await inspectWorkflow(cwd);
|
|
1322
|
+
return inspected;
|
|
1323
|
+
}
|
|
1324
|
+
|
|
1288
1325
|
let activeWorkflowTurn = false;
|
|
1289
1326
|
let pendingProjectInitialization: ProjectInitializationRequest | undefined;
|
|
1290
1327
|
let pendingRollover: { snapshot: Record<string, unknown>; preparedRevision: number; rolloverId: string } | undefined;
|
|
@@ -1322,6 +1359,7 @@ export default function devflowCore(pi: ExtensionAPI) {
|
|
|
1322
1359
|
pi.setLabel("Devflow Project Runtime");
|
|
1323
1360
|
|
|
1324
1361
|
pi.on("session_start", async (_event, ctx) => {
|
|
1362
|
+
projectRoot = ctx.cwd;
|
|
1325
1363
|
sessionId = randomUUID();
|
|
1326
1364
|
const result = await runHook(ctx.cwd, "start.py", {
|
|
1327
1365
|
session_id: sessionId,
|
|
@@ -1362,7 +1400,7 @@ export default function devflowCore(pi: ExtensionAPI) {
|
|
|
1362
1400
|
}
|
|
1363
1401
|
}
|
|
1364
1402
|
if (rolloverState && typeof rolloverState.revision === "number" && rollover?.status === "prepared" && typeof rollover.id === "string") {
|
|
1365
|
-
const workflow = await
|
|
1403
|
+
const workflow = await inspectWorkflowWithRegistryRecovery(ctx.cwd);
|
|
1366
1404
|
const expectedStateRevision = typeof rollover.expected_state_revision === "number" ? rollover.expected_state_revision : undefined;
|
|
1367
1405
|
if (workflow.result.ok && workflow.snapshot && expectedStateRevision !== undefined && expectedStateRevision + 1 === rolloverState.revision) {
|
|
1368
1406
|
pendingRollover = { snapshot: workflow.snapshot, preparedRevision: rolloverState.revision, rolloverId: rollover.id };
|
|
@@ -1373,8 +1411,13 @@ export default function devflowCore(pi: ExtensionAPI) {
|
|
|
1373
1411
|
const consumed = await runRuby(ctx.cwd, "workflow-orchestrator/scripts/session_rollover.rb", [
|
|
1374
1412
|
"consume", "--write", "--json", "--expected-revision", String(rolloverState.revision), "--target-session-id", currentSessionId,
|
|
1375
1413
|
]);
|
|
1376
|
-
const after = await
|
|
1414
|
+
const after = await inspectWorkflowWithRegistryRecovery(ctx.cwd);
|
|
1377
1415
|
if (consumed.ok && after.result.ok && after.snapshot) {
|
|
1416
|
+
if (after.snapshot.action === "blocked") {
|
|
1417
|
+
setControlState(ctx, { intent: "workflow-execute", phase: "blocked", label: "启动恢复校验失败" });
|
|
1418
|
+
ctx.ui.notify(`会话已恢复,但工作流校验失败,未启动 AI:${String(after.snapshot.reason ?? "state validation failed")}`, "error");
|
|
1419
|
+
return;
|
|
1420
|
+
}
|
|
1378
1421
|
setControlState(ctx, executingState("恢复已认领的干净会话"));
|
|
1379
1422
|
await triggerWorkflow(after.snapshot, "approval");
|
|
1380
1423
|
} else if (!consumed.ok) {
|
|
@@ -1421,13 +1464,23 @@ export default function devflowCore(pi: ExtensionAPI) {
|
|
|
1421
1464
|
pendingRollover = undefined;
|
|
1422
1465
|
activeContract = undefined;
|
|
1423
1466
|
contractAgentCalls = 0;
|
|
1467
|
+
const next = await inspectWorkflowWithRegistryRecovery(ctx.cwd);
|
|
1468
|
+
if (!next.result.ok || !next.snapshot || next.snapshot.action === "blocked") {
|
|
1469
|
+
setControlState(ctx, { intent: "workflow-execute", phase: "blocked", label: "启动恢复校验失败" });
|
|
1470
|
+
ctx.ui.notify(`会话已切换,但工作流校验失败,未启动 AI:${String(next.snapshot?.reason ?? next.result.stderr ?? next.result.stdout ?? "state validation failed")}`, "error");
|
|
1471
|
+
return;
|
|
1472
|
+
}
|
|
1424
1473
|
setControlState(ctx, executingState("在干净会话中执行下一工作流步骤"));
|
|
1425
|
-
await triggerWorkflow(
|
|
1474
|
+
await triggerWorkflow(next.snapshot, "approval");
|
|
1426
1475
|
});
|
|
1427
1476
|
|
|
1428
|
-
pi.on("before_agent_start", async event =>
|
|
1429
|
-
|
|
1430
|
-
|
|
1477
|
+
pi.on("before_agent_start", async event => {
|
|
1478
|
+
if (activeWorkflowTurn && activeContract) {
|
|
1479
|
+
await appendEngineEvidence(projectRoot, "ai_runtime_initialized", { source: "runtime_before_agent_start" });
|
|
1480
|
+
await appendEngineEvidence(projectRoot, "ai_invocation", { source: "runtime_before_agent_start" });
|
|
1481
|
+
}
|
|
1482
|
+
return { systemPrompt: [...event.systemPrompt, DEVFLOW_CHINESE_LANGUAGE_POLICY] };
|
|
1483
|
+
});
|
|
1431
1484
|
|
|
1432
1485
|
pi.on("agent_end", async (event, ctx) => {
|
|
1433
1486
|
if (!activeWorkflowTurn || event.willContinue) return;
|
|
@@ -1444,7 +1497,7 @@ export default function devflowCore(pi: ExtensionAPI) {
|
|
|
1444
1497
|
if (controlState.intent === "requirement-draft" && controlState.phase === "planning") {
|
|
1445
1498
|
const requirementDraft = event.text.trim();
|
|
1446
1499
|
if (!requirementDraft) return { handled: true };
|
|
1447
|
-
const inspected = await
|
|
1500
|
+
const inspected = await inspectWorkflowWithRegistryRecovery(ctx.cwd);
|
|
1448
1501
|
if (!inspected.result.ok || !inspected.snapshot) {
|
|
1449
1502
|
ctx.ui.notify(`需求分诊启动失败:${inspected.result.stderr || inspected.result.stdout || "invalid inspector JSON"}`, "error");
|
|
1450
1503
|
return { handled: true };
|
|
@@ -1472,7 +1525,7 @@ export default function devflowCore(pi: ExtensionAPI) {
|
|
|
1472
1525
|
// fresh-session handoff. Keep approval useful in this recovery state, but
|
|
1473
1526
|
// retry only the handoff; never repeat the already-persisted transition.
|
|
1474
1527
|
if (controlState.phase === "blocked" && controlState.label === "会话轮换准备失败" && controlAction === "approve") {
|
|
1475
|
-
const inspected = await
|
|
1528
|
+
const inspected = await inspectWorkflowWithRegistryRecovery(ctx.cwd);
|
|
1476
1529
|
if (!inspected.result.ok || !inspected.snapshot) {
|
|
1477
1530
|
ctx.ui.notify(`会话交接重试失败:${inspected.result.stderr || inspected.result.stdout || "invalid inspector JSON"}`, "error");
|
|
1478
1531
|
return { handled: true };
|
|
@@ -1498,7 +1551,7 @@ export default function devflowCore(pi: ExtensionAPI) {
|
|
|
1498
1551
|
return { handled: true };
|
|
1499
1552
|
}
|
|
1500
1553
|
if (controlAction === "approve") {
|
|
1501
|
-
const inspected = await
|
|
1554
|
+
const inspected = await inspectWorkflowWithRegistryRecovery(ctx.cwd);
|
|
1502
1555
|
if (!inspected.result.ok || !inspected.snapshot) {
|
|
1503
1556
|
ctx.ui.notify(`审批处理失败:${inspected.result.stderr || inspected.result.stdout || "invalid inspector JSON"}`, "error");
|
|
1504
1557
|
return { handled: true };
|
|
@@ -1516,14 +1569,14 @@ export default function devflowCore(pi: ExtensionAPI) {
|
|
|
1516
1569
|
"--json",
|
|
1517
1570
|
"--expected-revision",
|
|
1518
1571
|
String(revision),
|
|
1519
|
-
...(checkpoint ? ["--confirm-checkpoint"] : ["--complete-step", stepId!]),
|
|
1572
|
+
...(checkpoint ? ["--confirm-checkpoint", "--runtime-confirmed"] : ["--complete-step", stepId!]),
|
|
1520
1573
|
]);
|
|
1521
1574
|
if (!result.ok) {
|
|
1522
1575
|
setControlState(ctx, { intent: "workflow-execute", phase: "blocked", label: "推进校验失败" });
|
|
1523
1576
|
ctx.ui.notify(`未推进:${result.stderr || result.stdout}`, "error");
|
|
1524
1577
|
return { handled: true };
|
|
1525
1578
|
}
|
|
1526
|
-
const after = await
|
|
1579
|
+
const after = await inspectWorkflowWithRegistryRecovery(ctx.cwd);
|
|
1527
1580
|
activeContract = undefined;
|
|
1528
1581
|
contractAgentCalls = 0;
|
|
1529
1582
|
if (!after.result.ok || !after.snapshot || after.snapshot.action === "workflow_complete") {
|
|
@@ -1664,6 +1717,18 @@ export default function devflowCore(pi: ExtensionAPI) {
|
|
|
1664
1717
|
"Devflow 状态迁移只能由运行时在用户明确批准已展示的步骤或 checkpoint 后执行;LLM 不得自行推进。",
|
|
1665
1718
|
};
|
|
1666
1719
|
}
|
|
1720
|
+
if (isUserConfirmedStepTransition && activeContract) {
|
|
1721
|
+
const evidence = await runRuby(ctx.cwd, "workflow-orchestrator/scripts/machine_evidence.rb", [
|
|
1722
|
+
"--feature", activeContract.feature, "--step", activeContract.step, "--write", "--json",
|
|
1723
|
+
]);
|
|
1724
|
+
const evidencePayload = parseJsonResult(evidence);
|
|
1725
|
+
if (!evidence.ok || !evidencePayload || evidencePayload.complete !== true) {
|
|
1726
|
+
return {
|
|
1727
|
+
block: true,
|
|
1728
|
+
reason: `Devflow machine evidence is incomplete; collect independent runtime evidence before completing ${activeContract.step}.`,
|
|
1729
|
+
};
|
|
1730
|
+
}
|
|
1731
|
+
}
|
|
1667
1732
|
if (
|
|
1668
1733
|
!toolName.startsWith("devflow_") &&
|
|
1669
1734
|
["write", "edit", "apply_patch", "notebook_edit", "bash", "shell", "exec_command"].includes(toolName) &&
|
|
@@ -1740,6 +1805,12 @@ export default function devflowCore(pi: ExtensionAPI) {
|
|
|
1740
1805
|
|
|
1741
1806
|
pi.on("tool_result", async (event, ctx) => {
|
|
1742
1807
|
if (event.isError) return;
|
|
1808
|
+
if (activeWorkflowTurn && activeContract) {
|
|
1809
|
+
await appendEngineEvidence(ctx.cwd, "engine_dispatched", {
|
|
1810
|
+
engine: event.toolName,
|
|
1811
|
+
ai_capable: event.toolName === "task" || event.toolName.toLowerCase().includes("ai") || event.toolName.toLowerCase().includes("midscene"),
|
|
1812
|
+
});
|
|
1813
|
+
}
|
|
1743
1814
|
await runHook(ctx.cwd, "post_tool.py", {
|
|
1744
1815
|
session_id: sessionId,
|
|
1745
1816
|
cwd: ctx.cwd,
|
|
@@ -1840,7 +1911,11 @@ export default function devflowCore(pi: ExtensionAPI) {
|
|
|
1840
1911
|
stderr: "TC candidates are accepted only during the active acceptance-test-design contract.",
|
|
1841
1912
|
});
|
|
1842
1913
|
}
|
|
1843
|
-
args.push("--
|
|
1914
|
+
args.push("--expected-revision", String(params.expectedRevision), "--delta-json", JSON.stringify(params.delta));
|
|
1915
|
+
// `write` is an explicit dry-run switch. Omit it only when the caller
|
|
1916
|
+
// explicitly asks for a preview; the normal approved proposal remains
|
|
1917
|
+
// a Core-owned write.
|
|
1918
|
+
if (params.write !== false) args.push("--write");
|
|
1844
1919
|
if (activeContract?.allowedGraphDelta?.length) args.push("--allowed-kinds", activeContract.allowedGraphDelta.join(","));
|
|
1845
1920
|
}
|
|
1846
1921
|
if (params.action === "confirm") {
|
|
@@ -2513,7 +2588,7 @@ export default function devflowCore(pi: ExtensionAPI) {
|
|
|
2513
2588
|
}
|
|
2514
2589
|
args.push("--checkpoint-decision", params.decision);
|
|
2515
2590
|
} else if (params.action === "cancel-feature") {
|
|
2516
|
-
const inspected = await
|
|
2591
|
+
const inspected = await inspectWorkflowWithRegistryRecovery(ctx.cwd);
|
|
2517
2592
|
const snapshot = inspected.snapshot;
|
|
2518
2593
|
const feature = typeof snapshot?.current_feature === "string" ? snapshot.current_feature : "当前需求";
|
|
2519
2594
|
const revision = typeof snapshot?.revision === "number" ? snapshot.revision : undefined;
|
|
@@ -2641,7 +2716,7 @@ export default function devflowCore(pi: ExtensionAPI) {
|
|
|
2641
2716
|
return;
|
|
2642
2717
|
}
|
|
2643
2718
|
if (trimmed === "explain-state") {
|
|
2644
|
-
const inspected = await
|
|
2719
|
+
const inspected = await inspectWorkflowWithRegistryRecovery(ctx.cwd);
|
|
2645
2720
|
ctx.ui.notify(
|
|
2646
2721
|
inspected.result.ok ? explainWorkflowSnapshot(inspected.snapshot) : `状态读取失败:${inspected.result.stderr || inspected.result.stdout}`,
|
|
2647
2722
|
inspected.result.ok ? "info" : "error",
|
|
@@ -2650,7 +2725,7 @@ export default function devflowCore(pi: ExtensionAPI) {
|
|
|
2650
2725
|
return;
|
|
2651
2726
|
}
|
|
2652
2727
|
if (trimmed === "recover") {
|
|
2653
|
-
const inspected = await
|
|
2728
|
+
const inspected = await inspectWorkflowWithRegistryRecovery(ctx.cwd);
|
|
2654
2729
|
const plan = inspected.snapshot?.recovery_plan as Record<string, unknown> | undefined;
|
|
2655
2730
|
const revision = plan?.expected_revision;
|
|
2656
2731
|
if (!inspected.result.ok || !plan || plan.classification !== "safe_repairable" || typeof revision !== "number") {
|
|
@@ -2689,7 +2764,7 @@ export default function devflowCore(pi: ExtensionAPI) {
|
|
|
2689
2764
|
return;
|
|
2690
2765
|
}
|
|
2691
2766
|
if (trimmed === "abandon" || trimmed.startsWith("abandon ") || trimmed === "cancel" || trimmed.startsWith("cancel ")) {
|
|
2692
|
-
const inspected = await
|
|
2767
|
+
const inspected = await inspectWorkflowWithRegistryRecovery(ctx.cwd);
|
|
2693
2768
|
const snapshot = inspected.snapshot;
|
|
2694
2769
|
const revision = typeof snapshot?.revision === "number" ? snapshot.revision : undefined;
|
|
2695
2770
|
const feature = typeof snapshot?.current_feature === "string" ? snapshot.current_feature : undefined;
|
|
@@ -2758,7 +2833,7 @@ export default function devflowCore(pi: ExtensionAPI) {
|
|
|
2758
2833
|
ctx.ui.notify(formatDoctorReport(report).slice(0, 10000), "error");
|
|
2759
2834
|
return;
|
|
2760
2835
|
}
|
|
2761
|
-
const inspected = await
|
|
2836
|
+
const inspected = await inspectWorkflowWithRegistryRecovery(ctx.cwd);
|
|
2762
2837
|
if (!inspected.result.ok || !inspected.snapshot) {
|
|
2763
2838
|
ctx.ui.notify(
|
|
2764
2839
|
`Devflow resume failed: ${inspected.result.stderr || inspected.result.stdout || "invalid inspector JSON"}`,
|
|
@@ -12,6 +12,7 @@ require_relative "control_io"
|
|
|
12
12
|
require_relative "validate_state"
|
|
13
13
|
require_relative "phase_ownership"
|
|
14
14
|
require_relative "requirement_graph"
|
|
15
|
+
require_relative "gate_runtime"
|
|
15
16
|
|
|
16
17
|
options = {
|
|
17
18
|
state: nil,
|
|
@@ -24,7 +25,8 @@ options = {
|
|
|
24
25
|
cancel: false,
|
|
25
26
|
cancel_reason: nil,
|
|
26
27
|
expected_revision: nil,
|
|
27
|
-
json: false
|
|
28
|
+
json: false,
|
|
29
|
+
runtime_confirmed: false
|
|
28
30
|
}
|
|
29
31
|
|
|
30
32
|
OptionParser.new do |opts|
|
|
@@ -40,6 +42,7 @@ OptionParser.new do |opts|
|
|
|
40
42
|
opts.on("--cancel-reason REASON", "Record why the active feature was cancelled") { |reason| options[:cancel_reason] = reason }
|
|
41
43
|
opts.on("--expected-revision REVISION", Integer, "Reject a stale write unless STATE.md has this revision") { |value| options[:expected_revision] = value }
|
|
42
44
|
opts.on("--json", "Print JSON output") { options[:json] = true }
|
|
45
|
+
opts.on("--runtime-confirmed", "Internal runtime authorization for checkpoint confirmation") { options[:runtime_confirmed] = true }
|
|
43
46
|
end.parse!
|
|
44
47
|
|
|
45
48
|
root = Dir.pwd
|
|
@@ -64,6 +67,63 @@ def load_workflow(root, state)
|
|
|
64
67
|
[relative, load_yaml_file(path)]
|
|
65
68
|
end
|
|
66
69
|
|
|
70
|
+
def gate_contract_enabled?(workflow)
|
|
71
|
+
workflow.dig("governance", "gate_contract_version").to_i >= 3
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Build the runtime view from the existing workflow declaration. This keeps
|
|
75
|
+
# governance.step_checkers as the single checker namespace and avoids creating
|
|
76
|
+
# a second hand-maintained per-step transition table.
|
|
77
|
+
def transition_gate_contract(workflow, step)
|
|
78
|
+
checkpoints = workflow["checkpoints"] || {}
|
|
79
|
+
checkpoint_id = step["checkpoint"]
|
|
80
|
+
checkpoint = checkpoint_id && checkpoints[checkpoint_id]
|
|
81
|
+
track_names = Array(workflow["parallel_windows"]).flat_map do |window|
|
|
82
|
+
next [] unless window.is_a?(Hash) && window["branches"].is_a?(Hash)
|
|
83
|
+
window["branches"].keys.map { |name| name.to_s.sub(/\Atrack_/, "").upcase }
|
|
84
|
+
end
|
|
85
|
+
allowed_tracks = track_names.empty? ? ["main"] : ["main", *track_names]
|
|
86
|
+
reentries = (workflow["failure_branches"] || {}).each_with_object([]) do |(source_step, branch), entries|
|
|
87
|
+
next unless branch.is_a?(Hash) && branch["resume_after_success"].is_a?(Hash)
|
|
88
|
+
entries << { "source" => { "flow" => branch["workflow"], "step" => source_step }, "target" => branch["resume_after_success"], "requires_checkpoint_reconfirm" => true }
|
|
89
|
+
end
|
|
90
|
+
{
|
|
91
|
+
"gate_id" => "#{step.fetch("id")}-gate-v1",
|
|
92
|
+
"step_id" => step.fetch("id"),
|
|
93
|
+
"topology" => {
|
|
94
|
+
"allowed_tracks" => allowed_tracks,
|
|
95
|
+
"allowed_predecessors" => Array(step["depends_on"]),
|
|
96
|
+
"allowed_reentry" => reentries,
|
|
97
|
+
"checkpoint" => checkpoint && checkpoint.merge("id" => checkpoint_id)
|
|
98
|
+
},
|
|
99
|
+
"entry" => { "required_inputs" => Array(step["inputs"]) },
|
|
100
|
+
"outputs" => { "required" => Array(step["outputs"]).select { |output| output["required"] } },
|
|
101
|
+
"quality" => { "checker_ids" => Array(workflow.dig("governance", "step_checkers", step["id"])) },
|
|
102
|
+
"concurrency" => { "project_lock" => "required", "optimistic_revision" => "required", "registry_view" => "candidate_overlay" },
|
|
103
|
+
"recovery" => { "allowed" => ["sync_artifacts", "repair_outputs"], "max_attempts" => 2 }
|
|
104
|
+
}
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def transition_gate_preflight(workflow, state, step, current_revision, checkpoint_authorized = false, transition_kind = nil)
|
|
108
|
+
return nil unless gate_contract_enabled?(workflow)
|
|
109
|
+
|
|
110
|
+
contract = transition_gate_contract(workflow, step)
|
|
111
|
+
track = state["active_track"].to_s
|
|
112
|
+
track = "main" unless %w[A B].include?(track)
|
|
113
|
+
GateRuntime.evaluate(
|
|
114
|
+
contract: contract,
|
|
115
|
+
workflow_state: state,
|
|
116
|
+
current_revision: current_revision,
|
|
117
|
+
expected_revision: current_revision,
|
|
118
|
+
track: track,
|
|
119
|
+
source: "advance_state",
|
|
120
|
+
root: Dir.pwd,
|
|
121
|
+
check_outputs: step["id"].to_s == state["current_step"].to_s,
|
|
122
|
+
transition_kind: transition_kind,
|
|
123
|
+
checkpoint_authorized: checkpoint_authorized
|
|
124
|
+
)
|
|
125
|
+
end
|
|
126
|
+
|
|
67
127
|
def interpolate_path(path, state)
|
|
68
128
|
path
|
|
69
129
|
.gsub("{feature}", state.fetch("current_feature", "").to_s)
|
|
@@ -516,6 +576,39 @@ result =
|
|
|
516
576
|
)
|
|
517
577
|
end
|
|
518
578
|
|
|
579
|
+
# GateRuntime is a read-only boundary check. It runs while the project
|
|
580
|
+
# lock is held and before any candidate state mutation; the existing
|
|
581
|
+
# validator and advance_state CAS remain the final write guard.
|
|
582
|
+
if result.nil? && gate_contract_enabled?(workflow) &&
|
|
583
|
+
!%w[prepared claimed].include?(state.dig("rollover", "status")) &&
|
|
584
|
+
(options[:complete_step] || options[:confirm_checkpoint] || options[:enter_failure_branch] || options[:exit_failure_branch])
|
|
585
|
+
target_step_id = if options[:complete_step]
|
|
586
|
+
options[:complete_step]
|
|
587
|
+
elsif options[:confirm_checkpoint]
|
|
588
|
+
state["current_step"]
|
|
589
|
+
else
|
|
590
|
+
state["current_step"]
|
|
591
|
+
end
|
|
592
|
+
target_step = step_by_id[target_step_id]
|
|
593
|
+
if target_step.nil?
|
|
594
|
+
result = fail_result("gate_target_step_unknown", "step" => target_step_id)
|
|
595
|
+
elsif options[:complete_step] && state["current_step"].to_s != target_step_id.to_s
|
|
596
|
+
result = fail_result("gate_current_step_mismatch", "current_step" => state["current_step"], "requested_step" => target_step_id)
|
|
597
|
+
else
|
|
598
|
+
transition_kind = if options[:confirm_checkpoint]
|
|
599
|
+
"confirm_checkpoint"
|
|
600
|
+
elsif options[:enter_failure_branch]
|
|
601
|
+
"enter_failure_branch"
|
|
602
|
+
elsif options[:exit_failure_branch]
|
|
603
|
+
"exit_failure_branch"
|
|
604
|
+
end
|
|
605
|
+
gate_result = transition_gate_preflight(workflow, state, target_step, current_revision, options[:runtime_confirmed], transition_kind)
|
|
606
|
+
if gate_result && gate_result["verdict"] != "allow"
|
|
607
|
+
result = fail_result("gate_boundary_rejected", "gate" => gate_result)
|
|
608
|
+
end
|
|
609
|
+
end
|
|
610
|
+
end
|
|
611
|
+
|
|
519
612
|
if result.nil? && options[:checkpoint_decision]
|
|
520
613
|
state["checkpoint_decision"] = options[:checkpoint_decision]
|
|
521
614
|
changed = true
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module CheckerRegistry
|
|
5
|
+
DECLARED = %w[
|
|
6
|
+
requirement-graph-check slice-independence-check acceptance-coverage-check
|
|
7
|
+
api-test-schema-check contract-coverage-check api-test-matrix-check
|
|
8
|
+
test-coverage-check test-obligation-check acceptance-test-results-check
|
|
9
|
+
change-impact-check tdd-evidence-check test-task-binding-check
|
|
10
|
+
].freeze
|
|
11
|
+
EMBEDDED = %w[requirement-coverage-check source-authority-check traceability-check].freeze
|
|
12
|
+
|
|
13
|
+
module_function
|
|
14
|
+
|
|
15
|
+
def known?(id)
|
|
16
|
+
(DECLARED + EMBEDDED).include?(id.to_s)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def kind(id)
|
|
20
|
+
return "declared" if DECLARED.include?(id.to_s)
|
|
21
|
+
return "embedded" if EMBEDDED.include?(id.to_s)
|
|
22
|
+
nil
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def validate!(ids)
|
|
26
|
+
unknown = Array(ids).map(&:to_s).reject { |id| known?(id) }
|
|
27
|
+
raise ArgumentError, "unknown checker id(s): #{unknown.join(", ")}" unless unknown.empty?
|
|
28
|
+
true
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "yaml"
|
|
5
|
+
|
|
6
|
+
module GateContract
|
|
7
|
+
module_function
|
|
8
|
+
|
|
9
|
+
def load(path)
|
|
10
|
+
value = YAML.safe_load(File.read(path), permitted_classes: [], aliases: false)
|
|
11
|
+
validate!(value)
|
|
12
|
+
value
|
|
13
|
+
rescue Psych::Exception => e
|
|
14
|
+
raise ArgumentError, "invalid GateContract YAML: #{e.message}"
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def validate!(contract)
|
|
18
|
+
raise ArgumentError, "GateContract must be a mapping" unless contract.is_a?(Hash)
|
|
19
|
+
%w[gate_id step_id topology entry outputs quality recovery].each do |field|
|
|
20
|
+
raise ArgumentError, "GateContract missing #{field}" unless contract.key?(field)
|
|
21
|
+
end
|
|
22
|
+
topology = contract["topology"]
|
|
23
|
+
raise ArgumentError, "topology must be a mapping" unless topology.is_a?(Hash)
|
|
24
|
+
%w[allowed_tracks allowed_predecessors].each do |field|
|
|
25
|
+
raise ArgumentError, "#{field} must be an array" unless topology[field].is_a?(Array)
|
|
26
|
+
end
|
|
27
|
+
raise ArgumentError, "allowed_reentry must be an array" unless topology["allowed_reentry"].is_a?(Array)
|
|
28
|
+
validate_reentries!(topology["allowed_reentry"])
|
|
29
|
+
checkpoint = topology["checkpoint"]
|
|
30
|
+
validate_checkpoint!(checkpoint) unless checkpoint.nil?
|
|
31
|
+
concurrency = contract["concurrency"]
|
|
32
|
+
raise ArgumentError, "concurrency must be a mapping" unless concurrency.is_a?(Hash)
|
|
33
|
+
%w[project_lock optimistic_revision registry_view].each do |field|
|
|
34
|
+
raise ArgumentError, "concurrency missing #{field}" unless concurrency.key?(field)
|
|
35
|
+
end
|
|
36
|
+
raise ArgumentError, "project_lock must be required" unless concurrency["project_lock"].to_s == "required"
|
|
37
|
+
raise ArgumentError, "optimistic_revision must be required" unless concurrency["optimistic_revision"].to_s == "required"
|
|
38
|
+
raise ArgumentError, "registry_view must be candidate_overlay" unless concurrency["registry_view"].to_s == "candidate_overlay"
|
|
39
|
+
recovery = contract["recovery"]
|
|
40
|
+
raise ArgumentError, "recovery must be a mapping" unless recovery.is_a?(Hash)
|
|
41
|
+
forbidden = Array(recovery["allowed"]).select { |action| action.to_s.start_with?("reenter_") || %w[confirm_checkpoint transition].include?(action.to_s) }
|
|
42
|
+
raise ArgumentError, "state transition cannot be an automatic recovery action: #{forbidden.join(", ")}" unless forbidden.empty?
|
|
43
|
+
contract
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def validate_reentries!(entries)
|
|
47
|
+
entries.each do |entry|
|
|
48
|
+
raise ArgumentError, "reentry must be a mapping" unless entry.is_a?(Hash)
|
|
49
|
+
source = entry["source"]
|
|
50
|
+
target = entry["target"]
|
|
51
|
+
raise ArgumentError, "reentry source/target are required" unless source.is_a?(Hash) && target.is_a?(Hash)
|
|
52
|
+
%w[flow step].each { |field| raise ArgumentError, "reentry target missing #{field}" if target[field].to_s.empty? }
|
|
53
|
+
raise ArgumentError, "reentry must declare checkpoint reconfirmation" unless entry.key?("requires_checkpoint_reconfirm")
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def validate_checkpoint!(checkpoint)
|
|
58
|
+
raise ArgumentError, "checkpoint must be a mapping" unless checkpoint.is_a?(Hash)
|
|
59
|
+
%w[id producing_step required_outputs required_meta_output prompt confirm_options skip_policy resume_behavior].each do |field|
|
|
60
|
+
raise ArgumentError, "checkpoint missing #{field}" unless checkpoint.key?(field)
|
|
61
|
+
end
|
|
62
|
+
raise ArgumentError, "checkpoint confirm_options must be an array" unless checkpoint["confirm_options"].is_a?(Array)
|
|
63
|
+
end
|
|
64
|
+
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module GateResult
|
|
5
|
+
VERDICTS = %w[allow allow_with_warning await_user waiting_external_input block_unsafe].freeze
|
|
6
|
+
CONTROL_PLANE_ERROR = "control_plane_error"
|
|
7
|
+
|
|
8
|
+
module_function
|
|
9
|
+
|
|
10
|
+
def build(verdict:, gate_id:, step_id:, errors: [], warnings: [], recovery: nil, evidence: {}, revisions: {})
|
|
11
|
+
valid = VERDICTS.include?(verdict.to_s) || verdict.to_s == CONTROL_PLANE_ERROR
|
|
12
|
+
raise ArgumentError, "invalid gate verdict: #{verdict}" unless valid
|
|
13
|
+
|
|
14
|
+
{
|
|
15
|
+
"ok" => %w[allow allow_with_warning].include?(verdict.to_s),
|
|
16
|
+
"verdict" => verdict.to_s,
|
|
17
|
+
"gate_id" => gate_id.to_s,
|
|
18
|
+
"step_id" => step_id.to_s,
|
|
19
|
+
"errors" => Array(errors),
|
|
20
|
+
"warnings" => Array(warnings),
|
|
21
|
+
"recovery" => recovery,
|
|
22
|
+
"evidence" => evidence.is_a?(Hash) ? evidence : {},
|
|
23
|
+
"revisions" => revisions.is_a?(Hash) ? revisions : {}
|
|
24
|
+
}
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def allow(gate_id:, step_id:, **kwargs)
|
|
28
|
+
build(verdict: "allow", gate_id: gate_id, step_id: step_id, **kwargs)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def await_user(gate_id:, step_id:, recovery: nil, **kwargs)
|
|
32
|
+
build(verdict: "await_user", gate_id: gate_id, step_id: step_id, recovery: recovery, **kwargs)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def block(gate_id:, step_id:, code:, message:, recovery: nil, details: {}, **kwargs)
|
|
36
|
+
error = { "code" => code.to_s, "message" => message.to_s }
|
|
37
|
+
error.merge!(details) if details.is_a?(Hash)
|
|
38
|
+
build(verdict: "block_unsafe", gate_id: gate_id, step_id: step_id, errors: [error], recovery: recovery, **kwargs)
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require_relative "gate_result"
|
|
5
|
+
require_relative "gate_contract"
|
|
6
|
+
require_relative "checker_registry"
|
|
7
|
+
|
|
8
|
+
module GateRuntime
|
|
9
|
+
WORKFLOW_STATUSES = %w[in_progress waiting_checkpoint completed blocked failed cancelled remediation].freeze
|
|
10
|
+
|
|
11
|
+
module_function
|
|
12
|
+
|
|
13
|
+
def validate_contract!(contract)
|
|
14
|
+
GateContract.validate!(contract)
|
|
15
|
+
CheckerRegistry.validate!(Array(contract.dig("quality", "checker_ids")))
|
|
16
|
+
contract
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def evaluate(contract:, workflow_state:, current_revision:, expected_revision:, track: nil, source: "harness", evidence: {}, root: nil, check_outputs: false, transition_kind: nil, checkpoint_authorized: false)
|
|
20
|
+
validate_contract!(contract)
|
|
21
|
+
gate_id = contract.fetch("gate_id")
|
|
22
|
+
step_id = contract.fetch("step_id")
|
|
23
|
+
base = { gate_id: gate_id, step_id: step_id, evidence: evidence.merge("collector" => source), revisions: { "state" => current_revision } }
|
|
24
|
+
return GateResult.block(**base, code: "REVISION_CONFLICT", message: "current revision does not match expected revision", recovery: { "kind" => "reinspect", "expected_revision" => expected_revision }) unless expected_revision.nil? || current_revision.to_i == expected_revision.to_i
|
|
25
|
+
unless WORKFLOW_STATUSES.include?(workflow_state["status"].to_s)
|
|
26
|
+
return GateResult.block(**base, code: "WORKFLOW_STATUS_INVALID", message: "workflow status is not recognized")
|
|
27
|
+
end
|
|
28
|
+
unless %w[confirm_checkpoint enter_failure_branch exit_failure_branch].include?(transition_kind)
|
|
29
|
+
allowed_tracks = Array(contract.dig("topology", "allowed_tracks"))
|
|
30
|
+
if track && !allowed_tracks.empty? && !allowed_tracks.include?(track)
|
|
31
|
+
return GateResult.block(**base, code: "TRACK_SCOPE_VIOLATION", message: "current track is not allowed by GateContract", recovery: { "kind" => "registered_reentry" })
|
|
32
|
+
end
|
|
33
|
+
allowed_predecessors = Array(contract.dig("topology", "allowed_predecessors"))
|
|
34
|
+
previous = workflow_state["last_completed_step"] || workflow_state["previous_step"]
|
|
35
|
+
if previous && !allowed_predecessors.empty? && !allowed_predecessors.include?(previous)
|
|
36
|
+
return GateResult.block(**base, code: "STEP_PREDECESSOR_INVALID", message: "previous step is not allowed by GateContract")
|
|
37
|
+
end
|
|
38
|
+
if root
|
|
39
|
+
missing = required_paths(contract.dig("entry", "required_inputs"), root, workflow_state)
|
|
40
|
+
return GateResult.block(**base, code: "REQUIRED_INPUT_MISSING", message: "required gate input is missing", details: { "missing" => missing }) unless missing.empty?
|
|
41
|
+
if check_outputs
|
|
42
|
+
missing_outputs = required_paths(contract.dig("outputs", "required"), root, workflow_state)
|
|
43
|
+
return GateResult.block(**base, code: "REQUIRED_OUTPUT_MISSING", message: "required step output is missing", details: { "missing" => missing_outputs }) unless missing_outputs.empty?
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
if transition_kind == "confirm_checkpoint"
|
|
48
|
+
checkpoint_id = workflow_state["checkpoint"].to_s
|
|
49
|
+
checkpoint = contract.dig("topology", "checkpoint")
|
|
50
|
+
checkpoint_matches = checkpoint.is_a?(Hash) && checkpoint["id"].to_s == checkpoint_id
|
|
51
|
+
unless workflow_state["status"].to_s == "waiting_checkpoint" && checkpoint_matches && checkpoint_authorized
|
|
52
|
+
return GateResult.block(**base, code: "CHECKPOINT_NOT_AUTHORIZED", message: "checkpoint confirmation is not authorized by the runtime", details: { "checkpoint" => checkpoint_id })
|
|
53
|
+
end
|
|
54
|
+
unless workflow_state["completed_steps"].to_a.include?(checkpoint["producing_step"].to_s)
|
|
55
|
+
return GateResult.block(**base, code: "CHECKPOINT_PRODUCER_INCOMPLETE", message: "checkpoint producing step is not completed", details: { "checkpoint" => checkpoint_id })
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
GateResult.allow(**base)
|
|
59
|
+
rescue KeyError, ArgumentError => e
|
|
60
|
+
GateResult.block(gate_id: contract.is_a?(Hash) ? contract["gate_id"] : "unknown", step_id: contract.is_a?(Hash) ? contract["step_id"] : "unknown", code: "GATE_CONTRACT_INVALID", message: e.message)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def run_checker(contract:, workflow_state:, current_revision:, expected_revision:, checker:, **kwargs)
|
|
64
|
+
boundary = evaluate(contract: contract, workflow_state: workflow_state, current_revision: current_revision, expected_revision: expected_revision, **kwargs)
|
|
65
|
+
return boundary unless boundary["verdict"] == "allow"
|
|
66
|
+
result = checker.call
|
|
67
|
+
raise ArgumentError, "checker must return a GateResult hash" unless result.is_a?(Hash) && result["verdict"]
|
|
68
|
+
result.merge(
|
|
69
|
+
"gate_id" => result["gate_id"] || boundary["gate_id"],
|
|
70
|
+
"step_id" => result["step_id"] || boundary["step_id"],
|
|
71
|
+
"evidence" => boundary.fetch("evidence", {}).merge(result.fetch("evidence", {})),
|
|
72
|
+
"revisions" => boundary.fetch("revisions", {}).merge(result.fetch("revisions", {}))
|
|
73
|
+
)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def required_paths(entries, root, state)
|
|
77
|
+
Array(entries).each_with_object([]) do |entry, missing|
|
|
78
|
+
raw = entry.is_a?(Hash) ? entry["path"] : entry
|
|
79
|
+
next if raw.to_s.empty?
|
|
80
|
+
relative = raw.to_s.gsub("{feature}", state.fetch("current_feature", "").to_s).gsub("{phase}", state.fetch("current_phase", "").to_s)
|
|
81
|
+
absolute = File.expand_path(relative, root)
|
|
82
|
+
root_real = File.realpath(root)
|
|
83
|
+
actual = File.exist?(absolute) ? File.realpath(absolute) : absolute
|
|
84
|
+
next if actual.start_with?("#{root_real}#{File::SEPARATOR}") && File.exist?(actual)
|
|
85
|
+
missing << relative
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|