@guanlin4924/devflow-pi 0.1.24 → 0.1.26
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 +8 -1
- package/package.json +1 -1
- package/plugins/devflow-core/extensions/devflow-core.ts +252 -33
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/acceptance_test_runner.py +50 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/advance_state.rb +9 -1
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/api_test_matrix.py +2 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/architecture_brief.rb +19 -3
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/architecture_constraints.rb +24 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/architecture_feasibility.rb +25 -1
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/control_io.rb +4 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/execution_contract.rb +1 -1
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/requirement_graph.rb +136 -15
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/session_rollover.rb +159 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/validate_state.rb +36 -0
- package/plugins/devflow-design/skills/workflow-api-contract/SKILL.md +4 -3
- package/plugins/devflow-design/skills/workflow-architecture-feasibility/SKILL.md +4 -0
- package/plugins/devflow-design/skills/workflow-backend-tech-spec/SKILL.md +2 -0
- package/plugins/devflow-quality/skills/workflow-acceptance-test-design/SKILL.md +13 -1
- package/plugins/devflow-quality/skills/workflow-run-acceptance-tests/SKILL.md +2 -0
- package/src/constants.ts +1 -1
- package/src/runtime.ts +36 -1
package/README.md
CHANGED
|
@@ -11,7 +11,7 @@ Devflow Pi 是一个面向项目研发交付的独立 OMP 产品入口,内置
|
|
|
11
11
|
安装与启动:
|
|
12
12
|
|
|
13
13
|
```bash
|
|
14
|
-
npm install -g @guanlin4924/devflow-pi@0.1.
|
|
14
|
+
npm install -g @guanlin4924/devflow-pi@0.1.26
|
|
15
15
|
cd /path/to/your-project
|
|
16
16
|
devflow-pi init
|
|
17
17
|
devflow-pi doctor
|
|
@@ -115,6 +115,12 @@ bash .skills/install.sh --apply --force-config
|
|
|
115
115
|
|
|
116
116
|
`/devflow start` 会先进入安全的需求梳理:可澄清并只读检查,不写入项目。用户随后直接补充需求,原文会被带入入口分诊并启动当前步骤。每个步骤结束后,系统展示产物和证据;用户明确回复“批准执行”才会推进并运行下一步,回复修改意见或“拒绝执行”则保持原步骤。普通需求描述绝不会自动确认 checkpoint。`/devflow explain-state` 发现控制面和制品 registry 漂移时会给出只读恢复计划;用户审查后可用 `/devflow recover` 确认仅修复可由文件系统证明的 registry 字段,它绝不推进步骤或自动通过 checkpoint。恢复已有流程使用 `/devflow resume`,它同样只恢复状态而不自动执行。
|
|
117
117
|
|
|
118
|
+
### 窗口操作指引与干净会话
|
|
119
|
+
|
|
120
|
+
Devflow 会在输入框上方显示与当前状态对应的“Devflow 操作指引”:空闲时给出初始化、创建功能和状态查看入口;需求梳理时提示直接补充需求;节点审查时提示回复“批准执行”或直接描述修改项;阻塞时只展示解释状态和安全恢复入口。
|
|
121
|
+
|
|
122
|
+
正式节点经用户批准后,Devflow 会把已校验的状态和产物引用固化为内容校验的 handoff,再自动创建干净 Session 执行下一节点。交接期间工作流冻结;只有新 Session 成功认领并消费同一份 handoff 后才解除冻结。Devflow 不使用 OMP 的模型摘要 handoff,因此 `.planning/STATE.md` 仍是唯一流程事实源。
|
|
123
|
+
|
|
118
124
|
### 需求验收测试与 TDD 证据链
|
|
119
125
|
|
|
120
126
|
新建功能在 `integration-verify` 后会先生成独立的 `TC-*` 验收测试设计,再生成变更清单:
|
|
@@ -130,6 +136,7 @@ bash .skills/install.sh --apply --force-config
|
|
|
130
136
|
- `TC-*` 的步骤和 oracle 在代码出现前冻结;CP-3 在同一次确认中先校验候选绑定,再冻结 TC 并固定绑定哈希。需求或契约变化会使它 stale,不能继续以旧测试集宣称通过。
|
|
131
137
|
- `tdd_required` 任务必须先由 Core 记录准备快照,再记录 RED(命中预期失败)、同一目标的 GREEN 和回归;缺失或环境失败不算完成。
|
|
132
138
|
- API、跨数据域、边界、迁移、工作负载与架构风险会派生相应的 `api`、`permission`、`boundary`、`migration`、`performance`、`resilience` TC 义务。
|
|
139
|
+
- 每个 TC 还要声明证明策略:`replay` 用组件/页面/API/集成的稳定回放;`causal` 用已定位的首个偏差加 HAR、控制台、DOM 快照、截图或日志等受控文件证明,Core 会记录文件哈希;`observation` 用于尚不可复现、也未能证明根因的问题,只能保持阻塞并带观测计划。后两者都不能被“AI 已复现”或 Markdown 自述放行。
|
|
133
140
|
- 代码完成后,`workflow-run-acceptance-tests` 只能运行绑定到冻结 TC、且已在初始化 `.planning/toolchain.md` 发现的测试运行器命令。任何失败、未执行、人工待执行或环境阻塞用例都会阻止 CP-4。
|
|
134
141
|
- 验收不是代码完成后的临时手工检查。它是需求基线在编码前派生并冻结的完整测试集,覆盖主流程、异常、边界、权限、迁移、兼容、韧性和工作负载义务;代码只能补充执行适配信息,不能改变测试意图。
|
|
135
142
|
- 验收失败不会创建另一套人工台账,而是进入 `acceptance-remediation`:诊断 → 修复 → code-index 刷新 → 原冻结 `TC-*` 重跑。
|
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, writeFile } from "node:fs/promises";
|
|
3
|
+
import { 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";
|
|
@@ -29,6 +29,7 @@ type ActiveExecutionContract = {
|
|
|
29
29
|
step: string;
|
|
30
30
|
stateRevision?: number;
|
|
31
31
|
graphRevision?: number;
|
|
32
|
+
workflowFingerprint?: string;
|
|
32
33
|
allowedPaths: string[];
|
|
33
34
|
allowedAgents: string[];
|
|
34
35
|
allowedTools: string[];
|
|
@@ -41,6 +42,30 @@ type ActiveExecutionContract = {
|
|
|
41
42
|
onUnknown: "block_and_escalate";
|
|
42
43
|
};
|
|
43
44
|
|
|
45
|
+
function contractFromRecord(record: Record<string, unknown>): ActiveExecutionContract | null {
|
|
46
|
+
const strings = (key: string): string[] =>
|
|
47
|
+
Array.isArray(record[key]) ? record[key].filter((item): item is string => typeof item === "string") : [];
|
|
48
|
+
const contract: ActiveExecutionContract = {
|
|
49
|
+
contractId: String(record.contract_id ?? ""),
|
|
50
|
+
feature: String(record.feature ?? ""),
|
|
51
|
+
step: String(record.step ?? ""),
|
|
52
|
+
stateRevision: typeof record.state_revision === "number" ? record.state_revision : undefined,
|
|
53
|
+
graphRevision: typeof record.graph_revision === "number" ? record.graph_revision : undefined,
|
|
54
|
+
workflowFingerprint: typeof record.workflow_fingerprint === "string" ? record.workflow_fingerprint : undefined,
|
|
55
|
+
allowedPaths: strings("allowed_paths"),
|
|
56
|
+
allowedAgents: strings("allowed_agents"),
|
|
57
|
+
allowedTools: strings("allowed_tools"),
|
|
58
|
+
checkerIds: strings("checker_ids"),
|
|
59
|
+
toolBudget: typeof record.tool_budget === "number" ? record.tool_budget : 0,
|
|
60
|
+
frozenHash: String(record.frozen_hash ?? ""),
|
|
61
|
+
acceptance: strings("acceptance"),
|
|
62
|
+
allowedGraphDelta: strings("allowed_graph_delta"),
|
|
63
|
+
onUnknown: "block_and_escalate",
|
|
64
|
+
};
|
|
65
|
+
if (!contract.contractId || !contract.frozenHash) return null;
|
|
66
|
+
return contract;
|
|
67
|
+
}
|
|
68
|
+
|
|
44
69
|
const READ_ONLY_TOOLS = new Set([
|
|
45
70
|
"read",
|
|
46
71
|
"grep",
|
|
@@ -56,7 +81,6 @@ const READ_ONLY_TOOLS = new Set([
|
|
|
56
81
|
"devflow_usage_stats",
|
|
57
82
|
"usage_stats",
|
|
58
83
|
]);
|
|
59
|
-
|
|
60
84
|
const REQUIREMENT_DRAFT_TOOLS = new Set([...READ_ONLY_TOOLS, "ask"]);
|
|
61
85
|
// A Todo update changes only the client-visible task list, never the project
|
|
62
86
|
// or Devflow state. Permit it during planning so a harmless checklist cannot
|
|
@@ -414,6 +438,7 @@ const REQUIRED_SCRIPTS = [
|
|
|
414
438
|
"workflow-orchestrator/scripts/phase_ownership.rb",
|
|
415
439
|
"workflow-orchestrator/scripts/inspect_state.rb",
|
|
416
440
|
"workflow-orchestrator/scripts/advance_state.rb",
|
|
441
|
+
"workflow-orchestrator/scripts/session_rollover.rb",
|
|
417
442
|
"workflow-orchestrator/scripts/sync_artifacts.rb",
|
|
418
443
|
"workflow-orchestrator/scripts/requirement_graph.rb",
|
|
419
444
|
"workflow-orchestrator/scripts/acceptance_test_suite.rb",
|
|
@@ -840,9 +865,35 @@ export default function devflowCore(pi: ExtensionAPI) {
|
|
|
840
865
|
let activeContract: ActiveExecutionContract | undefined;
|
|
841
866
|
let contractAgentCalls = 0;
|
|
842
867
|
|
|
843
|
-
function
|
|
868
|
+
function controlGuide(state: SessionControlState): string[] {
|
|
869
|
+
const title = "Devflow 操作指引";
|
|
870
|
+
if (state.intent === "project-init" && state.phase === "planning") {
|
|
871
|
+
return [title, "初始化范围待确认:按界面完成选择;批准写入请回复“批准执行”。"];
|
|
872
|
+
}
|
|
873
|
+
if (state.intent === "requirement-draft" && state.phase === "planning") {
|
|
874
|
+
return [title, "需求梳理:直接补充需求正文、目标、边界和验收结果;此时不会推进实现。"];
|
|
875
|
+
}
|
|
876
|
+
if (state.phase === "waiting-user" || state.phase === "awaiting-approval") {
|
|
877
|
+
return [title, "当前节点等待审查:批准请回复“批准执行”;需修改请直接描述修改项。", "状态说明:/devflow explain-state"];
|
|
878
|
+
}
|
|
879
|
+
if (state.phase === "executing") {
|
|
880
|
+
return [title, `正在处理:${state.label}`, "请等待当前步骤输出;完成后会提示你审查。"];
|
|
881
|
+
}
|
|
882
|
+
if (state.phase === "blocked") {
|
|
883
|
+
return [title, "当前步骤被阻塞:使用 /devflow explain-state 查看证据与可恢复路径。", "仅在系统给出安全恢复计划后使用 /devflow recover。"];
|
|
884
|
+
}
|
|
885
|
+
return [title, "初始化项目:/devflow project-init", "创建功能:/devflow start <功能名> 查看状态:/devflow explain-state"];
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
function setControlState(ctx: {
|
|
889
|
+
ui: {
|
|
890
|
+
setStatus(key: string, text: string | undefined): void;
|
|
891
|
+
setWidget?(key: string, content: string[] | undefined, options?: { placement?: "aboveEditor" | "belowEditor" }): void;
|
|
892
|
+
};
|
|
893
|
+
}, state: SessionControlState) {
|
|
844
894
|
controlState = state;
|
|
845
895
|
ctx.ui.setStatus("devflow-control", statusText(state));
|
|
896
|
+
ctx.ui.setWidget?.("devflow-operation-guide", controlGuide(state), { placement: "aboveEditor" });
|
|
846
897
|
}
|
|
847
898
|
|
|
848
899
|
function normalizeContractSegment(value: string, label: string): string {
|
|
@@ -862,11 +913,57 @@ export default function devflowCore(pi: ExtensionAPI) {
|
|
|
862
913
|
const stateRevision = typeof state?.revision === "number" ? state.revision : undefined;
|
|
863
914
|
const graphResult = parseJsonResult(await runRuby(cwd, "workflow-orchestrator/scripts/requirement_graph.rb", ["inspect", "--feature", contract.feature, "--json"]));
|
|
864
915
|
const graph = graphResult?.graph;
|
|
865
|
-
const graphRevision =
|
|
866
|
-
|
|
916
|
+
const graphRevision =
|
|
917
|
+
graph && typeof graph === "object" && typeof (graph as Record<string, unknown>).revision === "number"
|
|
918
|
+
? (graph as Record<string, number>).revision
|
|
919
|
+
: undefined;
|
|
867
920
|
return stateRevision === contract.stateRevision && graphRevision === contract.graphRevision;
|
|
868
921
|
}
|
|
869
922
|
|
|
923
|
+
async function restoreActiveContract(cwd: string): Promise<ActiveExecutionContract | undefined> {
|
|
924
|
+
try {
|
|
925
|
+
const state = parseJsonResult(await runRuby(cwd, "workflow-orchestrator/scripts/inspect_state.rb", ["--json"]));
|
|
926
|
+
const feature = typeof state?.current_feature === "string" ? state.current_feature : undefined;
|
|
927
|
+
const rawStep = state?.current_step;
|
|
928
|
+
const step =
|
|
929
|
+
typeof rawStep === "string"
|
|
930
|
+
? rawStep
|
|
931
|
+
: rawStep && typeof rawStep === "object" && typeof (rawStep as { id?: unknown }).id === "string"
|
|
932
|
+
? (rawStep as { id: string }).id
|
|
933
|
+
: undefined;
|
|
934
|
+
if (!feature || !step) return undefined;
|
|
935
|
+
const contractsDir = path.join(cwd, ".planning", "contracts", feature);
|
|
936
|
+
if (!existsSync(contractsDir)) return undefined;
|
|
937
|
+
const candidates = readdirSync(contractsDir)
|
|
938
|
+
.filter(name => name.startsWith("EC-") && name.endsWith(".json"))
|
|
939
|
+
.map(name => path.join(contractsDir, name))
|
|
940
|
+
.sort((a, b) => lstatSync(b).mtimeMs - lstatSync(a).mtimeMs);
|
|
941
|
+
for (const candidate of candidates) {
|
|
942
|
+
let parsed: Record<string, unknown>;
|
|
943
|
+
try {
|
|
944
|
+
parsed = JSON.parse(readFileSync(candidate, "utf8")) as Record<string, unknown>;
|
|
945
|
+
} catch {
|
|
946
|
+
continue;
|
|
947
|
+
}
|
|
948
|
+
const contract = contractFromRecord(parsed);
|
|
949
|
+
if (!contract || contract.feature !== feature || contract.step !== step) continue;
|
|
950
|
+
const inspected = parseJsonResult(await runRuby(cwd, "workflow-orchestrator/scripts/execution_contract.rb", ["inspect", "--feature", feature, "--step", step, "--json"]));
|
|
951
|
+
const currentRaw = inspected?.contract;
|
|
952
|
+
const current = currentRaw && typeof currentRaw === "object" ? contractFromRecord(currentRaw as Record<string, unknown>) : null;
|
|
953
|
+
if (!current) continue;
|
|
954
|
+
if (current.contractId !== contract.contractId) continue;
|
|
955
|
+
if (current.frozenHash !== contract.frozenHash) continue;
|
|
956
|
+
if (current.workflowFingerprint !== contract.workflowFingerprint) continue;
|
|
957
|
+
if (!(await contractIsFresh(cwd, contract))) continue;
|
|
958
|
+
contract.contractPath = candidate;
|
|
959
|
+
return contract;
|
|
960
|
+
}
|
|
961
|
+
} catch {
|
|
962
|
+
// 恢复失败不阻塞会话启动;对应步骤执行时会提示重新合成合同。
|
|
963
|
+
}
|
|
964
|
+
return undefined;
|
|
965
|
+
}
|
|
966
|
+
|
|
870
967
|
async function collectProjectInitialization(ctx: {
|
|
871
968
|
cwd: string;
|
|
872
969
|
ui: {
|
|
@@ -1190,6 +1287,32 @@ export default function devflowCore(pi: ExtensionAPI) {
|
|
|
1190
1287
|
|
|
1191
1288
|
let activeWorkflowTurn = false;
|
|
1192
1289
|
let pendingProjectInitialization: ProjectInitializationRequest | undefined;
|
|
1290
|
+
let pendingRollover: { snapshot: Record<string, unknown>; preparedRevision: number; rolloverId: string } | undefined;
|
|
1291
|
+
|
|
1292
|
+
async function enqueueControlledRolloverRequest(cwd: string, rolloverId: string, sourceSessionId: string) {
|
|
1293
|
+
const requestPath = path.join(cwd, ".planning", "rollover-request.json");
|
|
1294
|
+
const temporary = `${requestPath}.${randomUUID()}.tmp`;
|
|
1295
|
+
await writeFile(temporary, JSON.stringify({ schema_version: 1, rollover_id: rolloverId, source_session_id: sourceSessionId }) + "\n", "utf8");
|
|
1296
|
+
await rename(temporary, requestPath);
|
|
1297
|
+
}
|
|
1298
|
+
|
|
1299
|
+
async function requestControlledRollover(ctx: { cwd: string; sessionManager: { getSessionId(): string } }, snapshot: Record<string, unknown>) {
|
|
1300
|
+
const revision = typeof snapshot.revision === "number" ? snapshot.revision : undefined;
|
|
1301
|
+
if (revision === undefined) throw new Error("Cannot prepare session rollover without a STATE revision.");
|
|
1302
|
+
const prepared = await runRuby(ctx.cwd, "workflow-orchestrator/scripts/session_rollover.rb", [
|
|
1303
|
+
"prepare", "--write", "--json", "--expected-revision", String(revision),
|
|
1304
|
+
"--source-session-id", ctx.sessionManager.getSessionId(), "--reason", "workflow_step_boundary",
|
|
1305
|
+
]);
|
|
1306
|
+
const payload = parseJsonResult(prepared);
|
|
1307
|
+
if (!prepared.ok || !payload || typeof payload.revision !== "number") {
|
|
1308
|
+
throw new Error(prepared.stderr || prepared.stdout || "rollover prepare failed");
|
|
1309
|
+
}
|
|
1310
|
+
const rollover = payload.rollover as Record<string, unknown> | undefined;
|
|
1311
|
+
const rolloverId = typeof rollover?.id === "string" ? rollover.id : undefined;
|
|
1312
|
+
if (!rolloverId) throw new Error("rollover prepare returned no rollover id");
|
|
1313
|
+
await enqueueControlledRolloverRequest(ctx.cwd, rolloverId, ctx.sessionManager.getSessionId());
|
|
1314
|
+
pendingRollover = { snapshot, preparedRevision: payload.revision, rolloverId };
|
|
1315
|
+
}
|
|
1193
1316
|
|
|
1194
1317
|
async function triggerWorkflow(snapshot: Record<string, unknown>, source: "start" | "approval", requirementDraft?: string) {
|
|
1195
1318
|
activeWorkflowTurn = true;
|
|
@@ -1211,7 +1334,71 @@ export default function devflowCore(pi: ExtensionAPI) {
|
|
|
1211
1334
|
return;
|
|
1212
1335
|
}
|
|
1213
1336
|
ctx.ui.setStatus("devflow", "Devflow ready");
|
|
1337
|
+
const restored = await restoreActiveContract(ctx.cwd);
|
|
1338
|
+
if (restored) {
|
|
1339
|
+
activeContract = restored;
|
|
1340
|
+
contractAgentCalls = 0;
|
|
1341
|
+
ctx.ui.notify(`已恢复执行合同 ${restored.contractId}(${restored.feature}/${restored.step});步骤推进后自动失效。`, "info");
|
|
1342
|
+
}
|
|
1214
1343
|
setControlState(ctx, initialControlState());
|
|
1344
|
+
// Recovery never searches the handoff directory. STATE.rollover is the
|
|
1345
|
+
// sole authority: only the original source session may resume a prepared
|
|
1346
|
+
// transition, and only the already-recorded target may consume a claim.
|
|
1347
|
+
const rolloverResult = await runRuby(ctx.cwd, "workflow-orchestrator/scripts/session_rollover.rb", ["inspect", "--json"]);
|
|
1348
|
+
const rolloverState = parseJsonResult(rolloverResult);
|
|
1349
|
+
const rollover = rolloverState?.rollover as Record<string, unknown> | undefined;
|
|
1350
|
+
const currentSessionId = ctx.sessionManager.getSessionId();
|
|
1351
|
+
if (rolloverState && typeof rolloverState.revision === "number" && rollover?.status === "prepared" && rollover.source_session_id === currentSessionId && typeof rollover.id === "string") {
|
|
1352
|
+
const workflow = await inspectWorkflow(ctx.cwd);
|
|
1353
|
+
if (workflow.result.ok && workflow.snapshot) {
|
|
1354
|
+
pendingRollover = { snapshot: workflow.snapshot, preparedRevision: rolloverState.revision, rolloverId: rollover.id };
|
|
1355
|
+
await enqueueControlledRolloverRequest(ctx.cwd, rollover.id, currentSessionId);
|
|
1356
|
+
ctx.ui.notify("已恢复待完成的受控会话交接;当前回合结束后将继续切换。", "info");
|
|
1357
|
+
}
|
|
1358
|
+
} else if (rolloverState && typeof rolloverState.revision === "number" && rollover?.status === "claimed" && rollover.target_session_id === currentSessionId) {
|
|
1359
|
+
const consumed = await runRuby(ctx.cwd, "workflow-orchestrator/scripts/session_rollover.rb", [
|
|
1360
|
+
"consume", "--write", "--json", "--expected-revision", String(rolloverState.revision), "--target-session-id", currentSessionId,
|
|
1361
|
+
]);
|
|
1362
|
+
const after = await inspectWorkflow(ctx.cwd);
|
|
1363
|
+
if (consumed.ok && after.result.ok && after.snapshot) {
|
|
1364
|
+
setControlState(ctx, executingState("恢复已认领的干净会话"));
|
|
1365
|
+
await triggerWorkflow(after.snapshot, "approval");
|
|
1366
|
+
} else if (!consumed.ok) {
|
|
1367
|
+
ctx.ui.notify(`已认领的会话交接无法恢复;工作流保持冻结:${consumed.stderr || consumed.stdout}`, "error");
|
|
1368
|
+
}
|
|
1369
|
+
}
|
|
1370
|
+
});
|
|
1371
|
+
|
|
1372
|
+
pi.on("session_before_switch", async (event) => {
|
|
1373
|
+
// A controlled rollover must always create a fresh session. Other session
|
|
1374
|
+
// navigation remains OMP-owned and never consumes a Devflow handoff.
|
|
1375
|
+
if (pendingRollover && event.reason !== "new") return { cancel: true };
|
|
1376
|
+
});
|
|
1377
|
+
|
|
1378
|
+
pi.on("session_switch", async (event, ctx) => {
|
|
1379
|
+
if (!pendingRollover || event.reason !== "new") return;
|
|
1380
|
+
const pending = pendingRollover;
|
|
1381
|
+
const targetSessionId = ctx.sessionManager.getSessionId();
|
|
1382
|
+
const claimed = await runRuby(ctx.cwd, "workflow-orchestrator/scripts/session_rollover.rb", [
|
|
1383
|
+
"claim", "--write", "--json", "--expected-revision", String(pending.preparedRevision), "--target-session-id", targetSessionId,
|
|
1384
|
+
]);
|
|
1385
|
+
const claimedPayload = parseJsonResult(claimed);
|
|
1386
|
+
if (!claimed.ok || !claimedPayload || typeof claimedPayload.revision !== "number") {
|
|
1387
|
+
ctx.ui.notify(`Devflow 会话交接认领失败;工作流保持冻结:${claimed.stderr || claimed.stdout}`, "error");
|
|
1388
|
+
return;
|
|
1389
|
+
}
|
|
1390
|
+
const consumed = await runRuby(ctx.cwd, "workflow-orchestrator/scripts/session_rollover.rb", [
|
|
1391
|
+
"consume", "--write", "--json", "--expected-revision", String(claimedPayload.revision), "--target-session-id", targetSessionId,
|
|
1392
|
+
]);
|
|
1393
|
+
if (!consumed.ok) {
|
|
1394
|
+
ctx.ui.notify(`Devflow 会话交接消费失败;工作流保持冻结:${consumed.stderr || consumed.stdout}`, "error");
|
|
1395
|
+
return;
|
|
1396
|
+
}
|
|
1397
|
+
pendingRollover = undefined;
|
|
1398
|
+
activeContract = undefined;
|
|
1399
|
+
contractAgentCalls = 0;
|
|
1400
|
+
setControlState(ctx, executingState("在干净会话中执行下一工作流步骤"));
|
|
1401
|
+
await triggerWorkflow(pending.snapshot, "approval");
|
|
1215
1402
|
});
|
|
1216
1403
|
|
|
1217
1404
|
pi.on("before_agent_start", async event => ({
|
|
@@ -1277,7 +1464,10 @@ export default function devflowCore(pi: ExtensionAPI) {
|
|
|
1277
1464
|
return { handled: true };
|
|
1278
1465
|
}
|
|
1279
1466
|
const result = await runRuby(ctx.cwd, "workflow-orchestrator/scripts/advance_state.rb", [
|
|
1280
|
-
"--write",
|
|
1467
|
+
"--write",
|
|
1468
|
+
"--json",
|
|
1469
|
+
"--expected-revision",
|
|
1470
|
+
String(revision),
|
|
1281
1471
|
...(checkpoint ? ["--confirm-checkpoint"] : ["--complete-step", stepId!]),
|
|
1282
1472
|
]);
|
|
1283
1473
|
if (!result.ok) {
|
|
@@ -1286,13 +1476,21 @@ export default function devflowCore(pi: ExtensionAPI) {
|
|
|
1286
1476
|
return { handled: true };
|
|
1287
1477
|
}
|
|
1288
1478
|
const after = await inspectWorkflow(ctx.cwd);
|
|
1479
|
+
activeContract = undefined;
|
|
1480
|
+
contractAgentCalls = 0;
|
|
1289
1481
|
if (!after.result.ok || !after.snapshot || after.snapshot.action === "workflow_complete") {
|
|
1290
1482
|
setControlState(ctx, initialControlState());
|
|
1291
1483
|
ctx.ui.notify(after.snapshot?.action === "workflow_complete" ? "工作流已完成。" : "已推进,但读取下一步骤失败。", "info");
|
|
1292
1484
|
return { handled: true };
|
|
1293
1485
|
}
|
|
1294
|
-
setControlState(ctx,
|
|
1295
|
-
|
|
1486
|
+
setControlState(ctx, waitingUserState("正在准备干净会话以执行下一工作流步骤"));
|
|
1487
|
+
try {
|
|
1488
|
+
await requestControlledRollover(ctx, after.snapshot);
|
|
1489
|
+
ctx.ui.notify("当前节点的证据已固定;本回合结束后将自动切换到干净会话并继续下一节点。", "info");
|
|
1490
|
+
} catch (error) {
|
|
1491
|
+
setControlState(ctx, { intent: "workflow-execute", phase: "blocked", label: "会话轮换准备失败" });
|
|
1492
|
+
ctx.ui.notify(`未开始下一节点:会话轮换准备失败:${String(error)}`, "error");
|
|
1493
|
+
}
|
|
1296
1494
|
return { handled: true };
|
|
1297
1495
|
}
|
|
1298
1496
|
}
|
|
@@ -1538,9 +1736,11 @@ export default function devflowCore(pi: ExtensionAPI) {
|
|
|
1538
1736
|
});
|
|
1539
1737
|
|
|
1540
1738
|
pi.registerTool({
|
|
1739
|
+
|
|
1541
1740
|
name: "devflow_requirement_graph",
|
|
1542
1741
|
label: "Devflow Requirement Graph",
|
|
1543
|
-
description: "Submit bounded requirement-graph candidates or inspect
|
|
1742
|
+
description: "Submit bounded requirement-graph candidates or inspect and validate the Core-owned canonical graph.",
|
|
1743
|
+
|
|
1544
1744
|
parameters: z.object({
|
|
1545
1745
|
action: z.enum(["propose", "inspect", "validate", "context", "confirm"]),
|
|
1546
1746
|
feature: z.string().min(1),
|
|
@@ -1552,7 +1752,7 @@ export default function devflowCore(pi: ExtensionAPI) {
|
|
|
1552
1752
|
read: z.boolean().optional(),
|
|
1553
1753
|
delta: z.object({
|
|
1554
1754
|
op: z.enum(["add", "link", "update"]),
|
|
1555
|
-
kind: z.enum(["source", "code", "term", "object", "behavior", "requirement", "rule", "boundary", "slice", "risk", "decision", "acceptance", "api", "workload_shape", "workload_constraint", "test_case"]).optional(),
|
|
1755
|
+
kind: z.enum(["source", "code", "term", "object", "behavior", "requirement", "rule", "boundary", "slice", "risk", "decision", "acceptance", "api", "workload_shape", "workload_constraint", "metric", "read_model", "test_case"]).optional(),
|
|
1556
1756
|
id: z.string().min(1).optional(),
|
|
1557
1757
|
data: z.record(z.string(), z.unknown()).optional(),
|
|
1558
1758
|
from: z.string().min(1).optional(),
|
|
@@ -1596,23 +1796,49 @@ export default function devflowCore(pi: ExtensionAPI) {
|
|
|
1596
1796
|
const node = Array.isArray(inspected?.graph?.nodes)
|
|
1597
1797
|
? inspected.graph.nodes.find((candidate: unknown) => typeof candidate === "object" && candidate !== null && (candidate as { id?: unknown }).id === params.nodeId) as { kind?: unknown; data?: unknown } | undefined
|
|
1598
1798
|
: undefined;
|
|
1599
|
-
const architectureDecision =
|
|
1600
|
-
|
|
1601
|
-
|
|
1799
|
+
const architectureDecision =
|
|
1800
|
+
node?.kind === "decision" && typeof node?.data === "object" && node.data !== null && (node.data as { decision_type?: unknown }).decision_type === "architecture_option_comparison";
|
|
1801
|
+
const architectureRisk =
|
|
1802
|
+
node?.kind === "risk" && typeof node?.data === "object" && node.data !== null && (node.data as { risk_type?: unknown }).risk_type === "architecture_option_risk";
|
|
1803
|
+
const metricNode = node?.kind === "metric";
|
|
1804
|
+
const readModelNode = node?.kind === "read_model";
|
|
1805
|
+
if (node?.kind === "workload_shape" || node?.kind === "workload_constraint" || metricNode || readModelNode || architectureDecision || architectureRisk) {
|
|
1602
1806
|
const preview = JSON.stringify(node.data ?? {}, null, 2);
|
|
1603
|
-
const decisionDialog = architectureDecision;
|
|
1604
|
-
const riskDialog = architectureRisk;
|
|
1605
1807
|
const confirmed = await ctx.ui.confirm(
|
|
1606
|
-
|
|
1808
|
+
architectureDecision
|
|
1809
|
+
? "确认架构方案取舍"
|
|
1810
|
+
: architectureRisk
|
|
1811
|
+
? "确认架构风险处理"
|
|
1812
|
+
: metricNode
|
|
1813
|
+
? "确认业务指标定义"
|
|
1814
|
+
: readModelNode
|
|
1815
|
+
? "确认读模型定义"
|
|
1816
|
+
: "确认工作负载事实或缺口",
|
|
1607
1817
|
[
|
|
1608
1818
|
`Feature:${params.feature}`,
|
|
1609
1819
|
`节点:${params.nodeId}`,
|
|
1610
|
-
`类型:${
|
|
1611
|
-
|
|
1820
|
+
`类型:${
|
|
1821
|
+
architectureDecision
|
|
1822
|
+
? "架构方案比较"
|
|
1823
|
+
: architectureRisk
|
|
1824
|
+
? "架构风险处理"
|
|
1825
|
+
: metricNode
|
|
1826
|
+
? "业务指标"
|
|
1827
|
+
: readModelNode
|
|
1828
|
+
? "读模型"
|
|
1829
|
+
: node?.kind === "workload_shape"
|
|
1830
|
+
? "工作负载形态"
|
|
1831
|
+
: "工作负载约束"
|
|
1832
|
+
}`,
|
|
1833
|
+
architectureDecision
|
|
1612
1834
|
? "确认的含义是:你已核对候选方案、证据与取舍,并接受其中 recommendation_option_id 作为本次架构选择。"
|
|
1613
|
-
:
|
|
1614
|
-
? "
|
|
1615
|
-
|
|
1835
|
+
: architectureRisk
|
|
1836
|
+
? "确认的含义是:你已核对风险陈述、缓解策略与剩余风险,并接受该风险登记结果。"
|
|
1837
|
+
: metricNode
|
|
1838
|
+
? "确认的含义是:你已核对该业务指标的口径、粒度、包含/排除规则与归属;它代表可追踪的业务事实,不只是文本标签。"
|
|
1839
|
+
: readModelNode
|
|
1840
|
+
? "确认的含义是:你已核对该读模型的投影语义、消费边界与查询责任;它代表 Core 认可的读侧契约。"
|
|
1841
|
+
: "确认的含义仅是:该事实或缺口、来源与责任归属已被你核对;它不代表容量已经满足,也不会替你选择实现方案。",
|
|
1616
1842
|
"待确认内容:",
|
|
1617
1843
|
preview.length > 8_000 ? `${preview.slice(0, 8_000)}\n…(内容过长,已截断)` : preview,
|
|
1618
1844
|
].join("\n\n"),
|
|
@@ -2029,19 +2255,12 @@ export default function devflowCore(pi: ExtensionAPI) {
|
|
|
2029
2255
|
const payload = parseJsonResult(inspected);
|
|
2030
2256
|
const raw = payload?.contract;
|
|
2031
2257
|
if (!controlPlaneSucceeded(inspected) || !raw || typeof raw !== "object") return renderResult(inspected, { action: input.action });
|
|
2032
|
-
const
|
|
2033
|
-
|
|
2034
|
-
const contract: ActiveExecutionContract = {
|
|
2035
|
-
contractId: String(record.contract_id ?? ""), feature: String(record.feature ?? ""), step: String(record.step ?? ""),
|
|
2036
|
-
stateRevision: typeof record.state_revision === "number" ? record.state_revision : undefined,
|
|
2037
|
-
graphRevision: typeof record.graph_revision === "number" ? record.graph_revision : undefined,
|
|
2038
|
-
allowedPaths: strings("allowed_paths"), allowedAgents: strings("allowed_agents"), allowedTools: strings("allowed_tools"), checkerIds: strings("checker_ids"),
|
|
2039
|
-
toolBudget: typeof record.tool_budget === "number" ? record.tool_budget : 0,
|
|
2040
|
-
frozenHash: String(record.frozen_hash ?? ""), acceptance: strings("acceptance"), allowedGraphDelta: strings("allowed_graph_delta"), onUnknown: "block_and_escalate",
|
|
2041
|
-
};
|
|
2042
|
-
if (!contract.contractId || !contract.frozenHash) return renderResult({ ok: false, exitCode: 1, stdout: "", stderr: "Core returned an invalid execution contract." });
|
|
2258
|
+
const contract = contractFromRecord(raw as Record<string, unknown>);
|
|
2259
|
+
if (!contract) return renderResult({ ok: false, exitCode: 1, stdout: "", stderr: "Core returned an invalid execution contract." });
|
|
2043
2260
|
if (input.action === "inspect") return { content: [{ type: "text" as const, text: JSON.stringify(contract, null, 2) }], details: { contract } };
|
|
2044
|
-
if (
|
|
2261
|
+
if (!["planning", "waiting-user", "awaiting-approval"].includes(controlState.phase)) {
|
|
2262
|
+
return renderResult({ ok: false, exitCode: 2, stdout: "", stderr: "Contracts may only be activated from the planning phase or while the step waits for user review." });
|
|
2263
|
+
}
|
|
2045
2264
|
const confirmed = await ctx.ui.confirm(
|
|
2046
2265
|
"激活 Devflow 执行合同",
|
|
2047
2266
|
[
|
|
@@ -20,6 +20,8 @@ from test_command_policy import validate_test_command
|
|
|
20
20
|
|
|
21
21
|
FEATURE_RE = re.compile(r"^[^/\\\x00-\x1f\x7f]+$")
|
|
22
22
|
AUTOMATION_MODES = {"automated", "manual", "blocked"}
|
|
23
|
+
EVIDENCE_STRATEGIES = {"replay", "causal", "observation"}
|
|
24
|
+
MAX_EVIDENCE_FILE_BYTES = 25 * 1024 * 1024
|
|
23
25
|
|
|
24
26
|
|
|
25
27
|
def utc_now() -> str:
|
|
@@ -142,6 +144,38 @@ def file_fingerprint(path: Path) -> str | None:
|
|
|
142
144
|
return digest(path.read_bytes()) if path.is_file() else None
|
|
143
145
|
|
|
144
146
|
|
|
147
|
+
def evidence_artifacts(root: Path, value: Any) -> tuple[list[dict[str, Any]], list[str]]:
|
|
148
|
+
"""Hash causal evidence from bounded project-relative files only."""
|
|
149
|
+
if not isinstance(value, list) or not value:
|
|
150
|
+
return [], ["causal verification requires non-empty evidence_files"]
|
|
151
|
+
root_real = root.resolve()
|
|
152
|
+
artifacts: list[dict[str, Any]] = []
|
|
153
|
+
errors: list[str] = []
|
|
154
|
+
for raw in value:
|
|
155
|
+
if not isinstance(raw, str) or not raw.strip() or "\x00" in raw:
|
|
156
|
+
errors.append("evidence_files entries must be non-empty relative paths")
|
|
157
|
+
continue
|
|
158
|
+
candidate = Path(raw)
|
|
159
|
+
if candidate.is_absolute() or ".." in candidate.parts:
|
|
160
|
+
errors.append(f"evidence file escapes project root: {raw}")
|
|
161
|
+
continue
|
|
162
|
+
resolved = (root_real / candidate).resolve()
|
|
163
|
+
try:
|
|
164
|
+
relative = resolved.relative_to(root_real)
|
|
165
|
+
except ValueError:
|
|
166
|
+
errors.append(f"evidence file escapes project root: {raw}")
|
|
167
|
+
continue
|
|
168
|
+
if not resolved.is_file():
|
|
169
|
+
errors.append(f"causal evidence file is missing: {raw}")
|
|
170
|
+
continue
|
|
171
|
+
size = resolved.stat().st_size
|
|
172
|
+
if size > MAX_EVIDENCE_FILE_BYTES:
|
|
173
|
+
errors.append(f"causal evidence file exceeds {MAX_EVIDENCE_FILE_BYTES} bytes: {raw}")
|
|
174
|
+
continue
|
|
175
|
+
artifacts.append({"path": relative.as_posix(), "sha256": digest(resolved.read_bytes()), "bytes": size})
|
|
176
|
+
return artifacts, errors
|
|
177
|
+
|
|
178
|
+
|
|
145
179
|
def run_http_case(source: dict[str, Any], adapter: dict[str, Any]) -> tuple[bool, int, str]:
|
|
146
180
|
execution = source["execution"]
|
|
147
181
|
request_spec = execution["request"]
|
|
@@ -248,6 +282,11 @@ def validate(root: Path, feature: str) -> tuple[dict[str, Any], dict[str, Any] |
|
|
|
248
282
|
if source is None:
|
|
249
283
|
errors.append(f"unknown TC reference: {tc_id}")
|
|
250
284
|
continue
|
|
285
|
+
strategy = source.get("evidence_strategy")
|
|
286
|
+
if strategy is not None and strategy not in EVIDENCE_STRATEGIES:
|
|
287
|
+
errors.append(f"{label}.source evidence_strategy is invalid")
|
|
288
|
+
if strategy == "observation" and source.get("automation_mode") != "blocked":
|
|
289
|
+
errors.append(f"{label}.observation source must be blocked")
|
|
251
290
|
mode = entry.get("mode")
|
|
252
291
|
if mode not in AUTOMATION_MODES:
|
|
253
292
|
errors.append(f"{label}.mode is invalid")
|
|
@@ -278,6 +317,9 @@ def validate(root: Path, feature: str) -> tuple[dict[str, Any], dict[str, Any] |
|
|
|
278
317
|
errors.append(f"{label}.adapter.setup: {setup_error}")
|
|
279
318
|
else:
|
|
280
319
|
errors.append(f"{label}.adapter kind must be command or http")
|
|
320
|
+
if strategy == "causal":
|
|
321
|
+
_artifacts, artifact_errors = evidence_artifacts(root, entry.get("evidence_files"))
|
|
322
|
+
errors.extend(f"{label}.{error}" for error in artifact_errors)
|
|
281
323
|
elif mode == "manual":
|
|
282
324
|
if not isinstance(entry.get("owner"), str) or not entry["owner"].strip() or not isinstance(entry.get("reason"), str) or not entry["reason"].strip():
|
|
283
325
|
errors.append(f"{label}.manual requires owner and reason")
|
|
@@ -303,6 +345,7 @@ def run(root: Path, feature: str, write: bool) -> dict[str, Any]:
|
|
|
303
345
|
for entry in bindings["cases"]:
|
|
304
346
|
tc_id = entry["tc_id"]
|
|
305
347
|
mode = entry["mode"]
|
|
348
|
+
strategy = cases[tc_id].get("evidence_strategy")
|
|
306
349
|
if mode == "automated":
|
|
307
350
|
started_at = utc_now()
|
|
308
351
|
adapter = entry["adapter"]
|
|
@@ -326,6 +369,13 @@ def run(root: Path, feature: str, write: bool) -> dict[str, Any]:
|
|
|
326
369
|
result = {"tc_id": tc_id, "status": "passed" if passed else "failed", "mode": mode, "adapter_kind": "http",
|
|
327
370
|
"adapter_fingerprint": digest(json.dumps(adapter, ensure_ascii=False, separators=(",", ":"))), "started_at": started_at,
|
|
328
371
|
"finished_at": utc_now(), "exit_code": exit_code, "output_sha256": digest(output)}
|
|
372
|
+
if strategy == "causal":
|
|
373
|
+
artifacts, artifact_errors = evidence_artifacts(root, entry.get("evidence_files"))
|
|
374
|
+
if artifact_errors:
|
|
375
|
+
result["status"] = "failed"
|
|
376
|
+
result["evidence_error"] = "; ".join(artifact_errors)
|
|
377
|
+
else:
|
|
378
|
+
result["evidence_artifacts"] = artifacts
|
|
329
379
|
results.append(result)
|
|
330
380
|
elif mode == "manual":
|
|
331
381
|
results.append({"tc_id": tc_id, "status": "blocked", "mode": mode, "owner": entry["owner"], "reason": entry["reason"],
|
|
@@ -504,7 +504,15 @@ result =
|
|
|
504
504
|
next_intent_requested = false
|
|
505
505
|
current_revision = Integer(state.fetch("revision", 0))
|
|
506
506
|
|
|
507
|
-
if
|
|
507
|
+
if state.dig("rollover", "status") && %w[prepared claimed].include?(state.dig("rollover", "status"))
|
|
508
|
+
result = fail_result(
|
|
509
|
+
"session_rollover_in_progress",
|
|
510
|
+
"rollover" => state["rollover"],
|
|
511
|
+
"message" => "Workflow transitions are frozen until the controlled session handoff is consumed or explicitly recovered."
|
|
512
|
+
)
|
|
513
|
+
end
|
|
514
|
+
|
|
515
|
+
if result.nil? && !options[:expected_revision].nil? && current_revision != options[:expected_revision]
|
|
508
516
|
result = fail_result(
|
|
509
517
|
"stale_revision",
|
|
510
518
|
"expected_revision" => options[:expected_revision],
|
|
@@ -554,6 +554,8 @@ def tc_candidate(api: dict[str, Any], graph: dict[str, Any], key: str, obligatio
|
|
|
554
554
|
"steps": ["发送 execution.request 中定义的 HTTP 请求"],
|
|
555
555
|
"oracle": oracle,
|
|
556
556
|
"automation_mode": "automated",
|
|
557
|
+
"evidence_strategy": "replay",
|
|
558
|
+
"replay_scope": "api",
|
|
557
559
|
"api_refs": [api["id"]],
|
|
558
560
|
"execution": execution,
|
|
559
561
|
}
|
|
@@ -96,13 +96,29 @@ module ArchitectureBrief
|
|
|
96
96
|
assessment["triggers"].each { |trigger| lines << "- `#{trigger["code"]}` — #{trigger["node_id"]}" }
|
|
97
97
|
end
|
|
98
98
|
|
|
99
|
-
lines.concat(["", "## 工作负载形态", "", "| 节点 | 状态 | 执行 | 操作 | 扇出 | 数据影响 |
|
|
99
|
+
lines.concat(["", "## 工作负载形态", "", "| 节点 | 状态 | 类型 | 一致性 | 执行 | 操作 | 扇出 | 数据影响 |", "|---|---|---|---|---|---|---|---|"])
|
|
100
100
|
if workload_shapes.empty?
|
|
101
|
-
lines << "| — | — | — | — | — | — | — |"
|
|
101
|
+
lines << "| — | — | — | — | — | — | — | — |"
|
|
102
102
|
else
|
|
103
103
|
workload_shapes.each do |node|
|
|
104
104
|
facts = node.fetch("data", {}).fetch("facts", {})
|
|
105
|
-
lines << "| #{node["id"]} | #{node["status"]} | #{text(facts["
|
|
105
|
+
lines << "| #{node["id"]} | #{node["status"]} | #{text(facts["archetype"] || "legacy")} | #{text(facts["consistency"] || "—")} | #{text(facts["execution"])} | #{text(facts["operation"])} | #{text(facts["fanout"])} | #{text(facts["data_effect"])} |"
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
metrics = Array(graph["nodes"]).select { |node| node["kind"] == "metric" }
|
|
110
|
+
read_models = Array(graph["nodes"]).select { |node| node["kind"] == "read_model" }
|
|
111
|
+
lines.concat(["", "## 统计指标与读模型", "", "| 节点 | 状态 | 定义/服务对象 | 时间与修正 |", "|---|---|---|---|"])
|
|
112
|
+
if metrics.empty? && read_models.empty?
|
|
113
|
+
lines << "| — | — | — | — |"
|
|
114
|
+
else
|
|
115
|
+
metrics.each do |node|
|
|
116
|
+
data = node.fetch("data", {})
|
|
117
|
+
lines << "| #{node["id"]} | #{node["status"]} | 指标:#{text(data["name"])};粒度:#{text(data["grain"])} | #{text(data["time_basis"])} / #{text(data["timezone"])};#{text(data["correction_policy"])} |"
|
|
118
|
+
end
|
|
119
|
+
read_models.each do |node|
|
|
120
|
+
data = node.fetch("data", {})
|
|
121
|
+
lines << "| #{node["id"]} | #{node["status"]} | 读模型:#{text(data["projection_mode"])};服务:#{text(Array(data["serves"]).join(", "))} | 重建:#{text(data.dig("rebuild", "method"))};迟到数据:#{text(data["late_data_policy"])} |"
|
|
106
122
|
end
|
|
107
123
|
end
|
|
108
124
|
|
package/plugins/devflow-core/skills/workflow-orchestrator/scripts/architecture_constraints.rb
CHANGED
|
@@ -13,6 +13,10 @@ module ArchitectureConstraints
|
|
|
13
13
|
OPERATIONS = %w[read write batch_read batch_write].freeze
|
|
14
14
|
FANOUTS = %w[none bounded unbounded unknown].freeze
|
|
15
15
|
DATA_EFFECTS = %w[none update append_only migration unknown].freeze
|
|
16
|
+
ARCHETYPES = %w[transactional_command operational_query analytics_query analytics_projection batch_processing external_integration].freeze
|
|
17
|
+
DATA_ROLES = %w[source_of_truth derived_read_model external_projection].freeze
|
|
18
|
+
CONSISTENCIES = %w[strong read_your_write bounded_staleness eventual unknown].freeze
|
|
19
|
+
EVENT_TIMES = %w[required not_applicable unknown].freeze
|
|
16
20
|
METRICS = %w[peak_qps data_growth p95_latency rpo rto].freeze
|
|
17
21
|
SOURCE_STATUSES = %w[measured contracted approved_assumption unknown].freeze
|
|
18
22
|
EVIDENCE_KINDS = %w[source_file cp_record external_measurement].freeze
|
|
@@ -52,6 +56,26 @@ module ArchitectureConstraints
|
|
|
52
56
|
errors_for_enum(errors, facts, "data_effect", DATA_EFFECTS)
|
|
53
57
|
external_calls = facts["external_calls"]
|
|
54
58
|
errors << "external_calls must be an array" unless external_calls.nil? || external_calls.is_a?(Array)
|
|
59
|
+
# `archetype` is optional for pre-v5 graphs. Once supplied it is a
|
|
60
|
+
# contract, not prose: analytics shapes must say what they serve and how
|
|
61
|
+
# their time semantics behave.
|
|
62
|
+
if facts.key?("archetype")
|
|
63
|
+
errors_for_enum(errors, facts, "archetype", ARCHETYPES)
|
|
64
|
+
errors_for_enum(errors, facts, "data_role", DATA_ROLES)
|
|
65
|
+
errors_for_enum(errors, facts, "consistency", CONSISTENCIES)
|
|
66
|
+
time = facts["time_semantics"]
|
|
67
|
+
errors << "time_semantics must be an object" unless time.is_a?(Hash)
|
|
68
|
+
errors_for_enum(errors, time || {}, "event_time", EVENT_TIMES)
|
|
69
|
+
errors << "time_semantics.timezone is required" unless present?(time && time["timezone"])
|
|
70
|
+
if %w[analytics_query analytics_projection].include?(facts["archetype"])
|
|
71
|
+
refs = shape["metric_refs"]
|
|
72
|
+
errors << "analytics workloads require non-empty metric_refs" unless refs.is_a?(Array) && !refs.empty?
|
|
73
|
+
end
|
|
74
|
+
if facts["archetype"] == "analytics_projection"
|
|
75
|
+
refs = shape["read_model_refs"]
|
|
76
|
+
errors << "analytics projections require non-empty read_model_refs" unless refs.is_a?(Array) && !refs.empty?
|
|
77
|
+
end
|
|
78
|
+
end
|
|
55
79
|
source_refs = shape["source_refs"]
|
|
56
80
|
errors << "source_refs must be a non-empty array" unless source_refs.is_a?(Array) && !source_refs.empty?
|
|
57
81
|
Array(source_refs).each { |ref| errors << "source_refs must reference REQ/BEH/API" unless reference?(ref, %w[REQ BEH API]) }
|