@guanlin4924/devflow-pi 0.1.0 → 0.1.4
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 +2 -1
- package/bin/devflow-pi +3 -0
- package/package.json +3 -2
- package/plugins/devflow-core/extensions/devflow-core.ts +53 -11
- package/plugins/devflow-core/skills/workflow-orchestrator/SKILL.md +11 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/advance_state.rb +18 -3
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/inspect_state.rb +41 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/repair_control_plane.rb +91 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/validate_state.rb +45 -9
package/README.md
CHANGED
|
@@ -27,6 +27,7 @@ devflow-pi
|
|
|
27
27
|
/devflow doctor
|
|
28
28
|
/devflow project-init
|
|
29
29
|
/devflow explain-state
|
|
30
|
+
/devflow recover
|
|
30
31
|
/devflow start <feature>|<phase>
|
|
31
32
|
/devflow continue
|
|
32
33
|
/devflow advance
|
|
@@ -114,7 +115,7 @@ bash .skills/install.sh --apply --force-config
|
|
|
114
115
|
|
|
115
116
|
首次使用时,在 OMP 的模型角色界面中分别为 `smol`、`task`、`slow` 选择具体模型;`modelRoleStorage: project` 会把选择保存在项目作用域。建议分别使用快速探索模型、代码实现模型和独立推理/审查模型。配置完成后以 `/devflow doctor` 的 `models:` 行为准。
|
|
116
117
|
|
|
117
|
-
`/devflow start` 只创建 Feature 状态,不会自动启动整条流程。`/devflow continue` 只运行当前一个工作流步骤并停止;产物审查后由用户显式执行 `/devflow advance`,checkpoint 也必须由用户通过 `/devflow checkpoint`
|
|
118
|
+
`/devflow start` 只创建 Feature 状态,不会自动启动整条流程。`/devflow continue` 只运行当前一个工作流步骤并停止;产物审查后由用户显式执行 `/devflow advance`,checkpoint 也必须由用户通过 `/devflow checkpoint` 确认。`/devflow explain-state` 发现控制面和制品 registry 漂移时会给出只读恢复计划;用户审查后可用 `/devflow recover` 确认仅修复可由文件系统证明的 registry 字段,它绝不推进步骤或自动通过 checkpoint。恢复已有流程使用 `/devflow resume`,它同样只恢复状态而不自动执行。普通中文输入“初始化项目”“查看状态”“需求:……”会进入相应的受控只读分诊,不会偷偷启动实现或状态迁移。
|
|
118
119
|
|
|
119
120
|
## Marketplace 能力包
|
|
120
121
|
|
package/bin/devflow-pi
ADDED
package/package.json
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@guanlin4924/devflow-pi",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.4",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Devflow 专项研发 Pi,基于固定版本 OMP Runtime 组装研发状态机、代码智能、设计、实现与质量门禁。",
|
|
6
6
|
"license": "UNLICENSED",
|
|
7
7
|
"bin": {
|
|
8
|
-
"devflow-pi": "
|
|
8
|
+
"devflow-pi": "bin/devflow-pi"
|
|
9
9
|
},
|
|
10
10
|
"scripts": {
|
|
11
11
|
"dev": "bun run src/cli.ts",
|
|
@@ -25,6 +25,7 @@
|
|
|
25
25
|
"typescript": "^5.9.3"
|
|
26
26
|
},
|
|
27
27
|
"files": [
|
|
28
|
+
"bin",
|
|
28
29
|
"src",
|
|
29
30
|
"plugins",
|
|
30
31
|
"!plugins/**/__pycache__",
|
|
@@ -46,6 +46,7 @@ const STATE_MUTATION_TOOLS = new Set([
|
|
|
46
46
|
"devflow_bootstrap",
|
|
47
47
|
"devflow_transition",
|
|
48
48
|
"devflow_artifact",
|
|
49
|
+
"devflow_recovery",
|
|
49
50
|
"devflow_context_sync",
|
|
50
51
|
"devflow_evidence",
|
|
51
52
|
]);
|
|
@@ -163,6 +164,7 @@ const REQUIRED_EXTENSION_TOOLS = [
|
|
|
163
164
|
"devflow_bootstrap",
|
|
164
165
|
"devflow_transition",
|
|
165
166
|
"devflow_artifact",
|
|
167
|
+
"devflow_recovery",
|
|
166
168
|
"devflow_skill_script",
|
|
167
169
|
"devflow_context_sync",
|
|
168
170
|
"devflow_code_index",
|
|
@@ -593,11 +595,8 @@ export default function devflowCore(pi: ExtensionAPI) {
|
|
|
593
595
|
let sessionId = randomUUID();
|
|
594
596
|
let controlState: SessionControlState = initialControlState();
|
|
595
597
|
let activeContract: ActiveExecutionContract | undefined;
|
|
596
|
-
let toolCalls = 0;
|
|
597
|
-
const toolCallBudget = 40;
|
|
598
598
|
|
|
599
599
|
function setControlState(ctx: { ui: { setStatus(key: string, text: string | undefined): void } }, state: SessionControlState) {
|
|
600
|
-
if (state.phase === "executing" && controlState.phase !== "executing") toolCalls = 0;
|
|
601
600
|
controlState = state;
|
|
602
601
|
ctx.ui.setStatus("devflow-control", statusText(state));
|
|
603
602
|
}
|
|
@@ -925,13 +924,6 @@ export default function devflowCore(pi: ExtensionAPI) {
|
|
|
925
924
|
});
|
|
926
925
|
|
|
927
926
|
pi.on("tool_call", async (event, ctx) => {
|
|
928
|
-
toolCalls += 1;
|
|
929
|
-
if (toolCalls > toolCallBudget && controlState.phase === "executing") {
|
|
930
|
-
return {
|
|
931
|
-
block: true,
|
|
932
|
-
reason: `本步骤已达到工具调用上限(${toolCallBudget})。已自动停止,请检查证据后使用 /devflow advance 或重新 /devflow continue。`,
|
|
933
|
-
};
|
|
934
|
-
}
|
|
935
927
|
const toolName = String(event.toolName).toLowerCase();
|
|
936
928
|
if (toolName === "task") {
|
|
937
929
|
const requested = requestedTaskAgents(event.input);
|
|
@@ -1347,6 +1339,26 @@ export default function devflowCore(pi: ExtensionAPI) {
|
|
|
1347
1339
|
},
|
|
1348
1340
|
});
|
|
1349
1341
|
|
|
1342
|
+
pi.registerTool({
|
|
1343
|
+
name: "devflow_recovery",
|
|
1344
|
+
label: "Devflow Recovery",
|
|
1345
|
+
description: "Apply a previewed, registry-only control-plane repair. Requires explicit confirmation and the inspected state revision; never advances a workflow.",
|
|
1346
|
+
parameters: z.object({
|
|
1347
|
+
expectedRevision: z.number().int().min(0),
|
|
1348
|
+
confirm: z.literal(true),
|
|
1349
|
+
state: z.string().optional(),
|
|
1350
|
+
}),
|
|
1351
|
+
approval: "write",
|
|
1352
|
+
async execute(_id, params, _signal, _onUpdate, ctx) {
|
|
1353
|
+
const args = ["--write", "--confirm", "--expected-revision", String(params.expectedRevision), "--json"];
|
|
1354
|
+
if (params.state) args.push("--state", params.state);
|
|
1355
|
+
return renderResult(
|
|
1356
|
+
await runRuby(ctx.cwd, "workflow-orchestrator/scripts/repair_control_plane.rb", args),
|
|
1357
|
+
{ action: "repair-control-plane" },
|
|
1358
|
+
);
|
|
1359
|
+
},
|
|
1360
|
+
});
|
|
1361
|
+
|
|
1350
1362
|
pi.registerTool({
|
|
1351
1363
|
name: "devflow_skill_script",
|
|
1352
1364
|
label: "Devflow Skill Script",
|
|
@@ -1415,6 +1427,36 @@ export default function devflowCore(pi: ExtensionAPI) {
|
|
|
1415
1427
|
setControlState(ctx, initialControlState());
|
|
1416
1428
|
return;
|
|
1417
1429
|
}
|
|
1430
|
+
if (trimmed === "recover") {
|
|
1431
|
+
const inspected = await inspectWorkflow(ctx.cwd);
|
|
1432
|
+
const plan = inspected.snapshot?.recovery_plan as Record<string, unknown> | undefined;
|
|
1433
|
+
const revision = plan?.expected_revision;
|
|
1434
|
+
if (!inspected.result.ok || !plan || plan.classification !== "safe_repairable" || typeof revision !== "number") {
|
|
1435
|
+
ctx.ui.notify("当前状态没有可安全执行的恢复计划;请先运行 /devflow explain-state,并按其中的阻塞证据处理。", "error");
|
|
1436
|
+
return;
|
|
1437
|
+
}
|
|
1438
|
+
const confirmed = await ctx.ui.confirm(
|
|
1439
|
+
"确认修复 Devflow 控制面",
|
|
1440
|
+
"将只根据已存在的文件同步 artifact registry;不会推进步骤、确认 checkpoint 或修改需求产物。继续吗?",
|
|
1441
|
+
);
|
|
1442
|
+
if (!confirmed) {
|
|
1443
|
+
ctx.ui.notify("恢复未确认;状态保持不变。", "info");
|
|
1444
|
+
return;
|
|
1445
|
+
}
|
|
1446
|
+
const result = await runRuby(ctx.cwd, "workflow-orchestrator/scripts/repair_control_plane.rb", [
|
|
1447
|
+
"--write",
|
|
1448
|
+
"--confirm",
|
|
1449
|
+
"--expected-revision",
|
|
1450
|
+
String(revision),
|
|
1451
|
+
"--json",
|
|
1452
|
+
]);
|
|
1453
|
+
if (!result.ok) {
|
|
1454
|
+
ctx.ui.notify(`控制面恢复失败:${result.stderr || result.stdout}`, "error");
|
|
1455
|
+
return;
|
|
1456
|
+
}
|
|
1457
|
+
ctx.ui.notify("控制面 registry 已修复;未推进工作流。请审查当前步骤产物后使用 /devflow advance。", "info");
|
|
1458
|
+
return;
|
|
1459
|
+
}
|
|
1418
1460
|
if (trimmed === "project-init") {
|
|
1419
1461
|
const summary = await collectProjectInitialization(ctx);
|
|
1420
1462
|
if (!summary) return;
|
|
@@ -1634,7 +1676,7 @@ export default function devflowCore(pi: ExtensionAPI) {
|
|
|
1634
1676
|
return;
|
|
1635
1677
|
}
|
|
1636
1678
|
ctx.ui.notify(
|
|
1637
|
-
"Usage: /devflow doctor | status | explain-state | project-init | start <feature>|<phase> | resume | continue | advance | checkpoint",
|
|
1679
|
+
"Usage: /devflow doctor | status | explain-state | recover | project-init | start <feature>|<phase> | resume | continue | advance | checkpoint",
|
|
1638
1680
|
"warning",
|
|
1639
1681
|
);
|
|
1640
1682
|
},
|
|
@@ -37,6 +37,7 @@ Use the JSON result to choose one of these actions:
|
|
|
37
37
|
| `run_project_context` | Load and execute `workflow-project-context`, then run the state inspector again. |
|
|
38
38
|
| `run_next_step` | Load and execute the indicated `next_step.skill`. |
|
|
39
39
|
| `run_current_step` | Re-run or finish the indicated `current_step.skill`. |
|
|
40
|
+
| `recover_artifact_registry` | Review `recovery_plan`; if it is `safe_repairable`, call `devflow_recovery(expectedRevision=<plan revision>, confirm=true)`, re-inspect, then explicitly complete the current step when its output is ready. |
|
|
40
41
|
| `workflow_complete` | Report completion and do not call another workflow skill. |
|
|
41
42
|
| `blocked` | Report the blocking reason and ask for the minimum missing input. |
|
|
42
43
|
|
|
@@ -188,6 +189,16 @@ revision is rejected without changing state, registry, or next-intent files.
|
|
|
188
189
|
|
|
189
190
|
Checkpoint confirmation also writes the configured `.planning/.next-intent`.
|
|
190
191
|
|
|
192
|
+
## Control-plane recovery
|
|
193
|
+
|
|
194
|
+
`devflow_state(action="inspect")` reports `recovery_plan` for output/registry drift. Only a plan classified `safe_repairable` may be applied, and it must be explicitly confirmed with the exact inspected revision:
|
|
195
|
+
|
|
196
|
+
```text
|
|
197
|
+
devflow_recovery(expectedRevision=<recovery_plan.expected_revision>, confirm=true)
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
This is registry-only: it derives file existence/content metadata from the filesystem and never changes `completed_steps`, `pending_steps`, `current_step`, checkpoint state, or workflow status. Plans classified `unsafe` (for example missing required output or conflicting state tracking) must be corrected manually; recovery refuses them.
|
|
201
|
+
|
|
191
202
|
When `devflow_transition` succeeds, it automatically regenerates the Markdown body of STATE.md from the YAML state + artifact registry. The body is marked `<!-- GENERATED — DO NOT EDIT -->` and must not be manually edited.
|
|
192
203
|
|
|
193
204
|
## Lessons Rules
|
|
@@ -96,9 +96,9 @@ def content_validity(root, path, checks)
|
|
|
96
96
|
{ "valid" => issues.empty?, "issues" => issues }
|
|
97
97
|
end
|
|
98
98
|
|
|
99
|
-
def
|
|
99
|
+
def build_artifact_registry(root, workflow, state)
|
|
100
100
|
registry_path = workflow.dig("artifact_registry", "source")
|
|
101
|
-
return nil unless registry_path
|
|
101
|
+
return [nil, nil] unless registry_path
|
|
102
102
|
|
|
103
103
|
abs = File.join(root, registry_path)
|
|
104
104
|
existing_by_path = if File.exist?(abs)
|
|
@@ -159,6 +159,15 @@ def sync_artifact_registry(root, workflow, state)
|
|
|
159
159
|
"artifacts" => artifacts
|
|
160
160
|
}
|
|
161
161
|
|
|
162
|
+
[registry_path, registry]
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def sync_artifact_registry(root, workflow, state, registry: nil)
|
|
166
|
+
registry_path = workflow.dig("artifact_registry", "source")
|
|
167
|
+
return nil unless registry_path
|
|
168
|
+
|
|
169
|
+
registry ||= build_artifact_registry(root, workflow, state).last
|
|
170
|
+
abs = File.join(root, registry_path)
|
|
162
171
|
DevflowControlIO.atomic_write(abs, registry.to_yaml)
|
|
163
172
|
registry_path
|
|
164
173
|
end
|
|
@@ -479,10 +488,16 @@ result =
|
|
|
479
488
|
state["state_updated_at"] = Date.today.to_s
|
|
480
489
|
state["revision"] = current_revision + 1
|
|
481
490
|
end
|
|
491
|
+
# Build the registry from the candidate state before validating it. A
|
|
492
|
+
# step's output is normally written before this transition, so the
|
|
493
|
+
# persisted registry may still describe the previous state revision.
|
|
494
|
+
# Validating against it would make complete-step self-blocking.
|
|
495
|
+
_candidate_registry_path, candidate_registry = changed ? build_artifact_registry(root, workflow, state) : [nil, nil]
|
|
482
496
|
validation = WorkflowStateValidator.validate(
|
|
483
497
|
root: root,
|
|
484
498
|
state_path: state_path,
|
|
485
499
|
state_override: state,
|
|
500
|
+
artifact_registry_override: candidate_registry,
|
|
486
501
|
strict_artifact_status: false,
|
|
487
502
|
strict_registry_revision: false
|
|
488
503
|
)
|
|
@@ -490,7 +505,7 @@ result =
|
|
|
490
505
|
if changed && !validation["ok"]
|
|
491
506
|
result = fail_result("state_validation_failed", "validation" => validation)
|
|
492
507
|
elsif changed && options[:write]
|
|
493
|
-
sync_artifact_registry(root, workflow, state)
|
|
508
|
+
sync_artifact_registry(root, workflow, state, registry: candidate_registry)
|
|
494
509
|
artifact_by_path = load_artifact_registry(root, workflow)
|
|
495
510
|
write_state_file(root, state_path, state, workflow, artifact_by_path, action_description)
|
|
496
511
|
next_intent_written = write_next_intent(root, workflow) if next_intent_requested
|
|
@@ -382,8 +382,49 @@ result =
|
|
|
382
382
|
action: "blocked",
|
|
383
383
|
reason: "state_validation_failed",
|
|
384
384
|
state_validation: validation,
|
|
385
|
+
recovery_plan: {
|
|
386
|
+
classification: "unsafe",
|
|
387
|
+
state_transition: "none",
|
|
388
|
+
registry_write: false,
|
|
389
|
+
reasons: validation["errors"].map { |issue| issue["code"] }
|
|
390
|
+
},
|
|
385
391
|
recovery: "Fix missing/invalid artifacts or call devflow_artifact with action=sync and preserve=false, then call devflow_state with action=inspect."
|
|
386
392
|
}
|
|
393
|
+
elsif (drift = validation["warnings"].find { |issue| issue["code"] == "current_output_registry_drift" })
|
|
394
|
+
{
|
|
395
|
+
current_flow: state["current_flow"],
|
|
396
|
+
current_feature: state["current_feature"],
|
|
397
|
+
current_phase: state["current_phase"],
|
|
398
|
+
status: state["status"],
|
|
399
|
+
completed_steps: completed_ids,
|
|
400
|
+
pending_steps: pending_ids,
|
|
401
|
+
current_step: current_step && {
|
|
402
|
+
id: current_step["id"],
|
|
403
|
+
label: current_step["label"],
|
|
404
|
+
skill: current_step["skill"],
|
|
405
|
+
action: current_step["action"]
|
|
406
|
+
},
|
|
407
|
+
next_step: nil,
|
|
408
|
+
workflow_config: workflow_path,
|
|
409
|
+
artifact_registry: artifact_registry_summary(root, artifact_by_path).merge(
|
|
410
|
+
"path" => artifact_registry_path,
|
|
411
|
+
"error" => artifact_registry_error,
|
|
412
|
+
"current_outputs_registered" => 0
|
|
413
|
+
),
|
|
414
|
+
action: "recover_artifact_registry",
|
|
415
|
+
reason: "current_output_registry_drift",
|
|
416
|
+
drift: drift,
|
|
417
|
+
recovery_plan: {
|
|
418
|
+
classification: "safe_repairable",
|
|
419
|
+
state_transition: "none",
|
|
420
|
+
registry_write: true,
|
|
421
|
+
confirmation_required: true,
|
|
422
|
+
expected_revision: state.fetch("revision", 0),
|
|
423
|
+
diff: [{ path: drift["path"], registry_exists: drift["registry_exists"], filesystem_exists: true,
|
|
424
|
+
registry_content_valid: drift["registry_content_valid"], filesystem_content_valid: true }]
|
|
425
|
+
},
|
|
426
|
+
recovery: "After reviewing this diff, call devflow_recovery(expectedRevision=#{state.fetch("revision", 0)}, confirm=true). It only reconciles registry metadata; explicitly complete the step separately when ready."
|
|
427
|
+
}
|
|
387
428
|
elsif current_step.nil?
|
|
388
429
|
fallback_next_id = next_pending_step_id(state, pending_ids, step_by_id, completed_ids)
|
|
389
430
|
fallback_next_step = fallback_next_id && step_by_id[fallback_next_id]
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "date"
|
|
5
|
+
require "json"
|
|
6
|
+
require "optparse"
|
|
7
|
+
require "yaml"
|
|
8
|
+
require_relative "control_io"
|
|
9
|
+
require_relative "validate_state"
|
|
10
|
+
|
|
11
|
+
options = { state: ".planning/STATE.md", expected_revision: nil, confirm: false, write: false, json: false }
|
|
12
|
+
OptionParser.new do |opts|
|
|
13
|
+
opts.banner = "Usage: repair_control_plane.rb --expected-revision N --confirm --write"
|
|
14
|
+
opts.on("--state PATH") { |value| options[:state] = value }
|
|
15
|
+
opts.on("--expected-revision N", Integer) { |value| options[:expected_revision] = value }
|
|
16
|
+
opts.on("--confirm", "Required acknowledgement of the proposed registry-only repair") { options[:confirm] = true }
|
|
17
|
+
opts.on("--write", "Persist the repair") { options[:write] = true }
|
|
18
|
+
opts.on("--json") { options[:json] = true }
|
|
19
|
+
end.parse!
|
|
20
|
+
|
|
21
|
+
def front_matter(markdown)
|
|
22
|
+
markdown.match(/\A---\n(.*?)\n---\n/m)&.[](1)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def interpolate_path(path, state)
|
|
26
|
+
path.gsub("{feature}", state.fetch("current_feature", "").to_s).gsub("{phase}", state.fetch("current_phase", "").to_s)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def build_registry(root, workflow, state)
|
|
30
|
+
checks = workflow.dig("artifact_registry", "content_checks") || {}
|
|
31
|
+
artifacts = Array(workflow["steps"]).flat_map do |step|
|
|
32
|
+
Array(step["outputs"]).map do |output|
|
|
33
|
+
path = interpolate_path(output.fetch("path"), state)
|
|
34
|
+
abs = File.join(root, path)
|
|
35
|
+
exists = File.exist?(abs)
|
|
36
|
+
issues = []
|
|
37
|
+
issues << "file_missing" unless exists
|
|
38
|
+
issues << "below_min_bytes(#{File.size(abs)} < #{checks["min_bytes"]})" if exists && checks["min_bytes"] && File.size(abs) < checks["min_bytes"]
|
|
39
|
+
if exists && checks["required_header"]
|
|
40
|
+
head = File.read(abs, [checks["required_header"].length + 50, 200].min)
|
|
41
|
+
issues << "missing_required_header(#{checks["required_header"].inspect})" unless head.include?(checks["required_header"])
|
|
42
|
+
end
|
|
43
|
+
completed = Array(state["completed_steps"]).include?(step.fetch("id"))
|
|
44
|
+
status = completed ? (step["checkpoint"] ? "approved" : "completed") : "planned"
|
|
45
|
+
entry = { "path" => path, "type" => output["type"], "workflow_step" => step.fetch("id"), "produced_by" => step["skill"] || step["action"], "status" => status, "exists" => exists, "content_valid" => issues.empty? }
|
|
46
|
+
entry["checkpoint"] = step["checkpoint"] if step["checkpoint"]
|
|
47
|
+
entry["content_issues"] = issues unless issues.empty?
|
|
48
|
+
entry
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
{ "schema_version" => 1, "state_revision" => state.fetch("revision", 0), "updated_at" => Date.today.to_s,
|
|
52
|
+
"registry_scope" => { "flow" => state["current_flow"], "feature" => state["current_feature"], "phase" => state["current_phase"] }, "artifacts" => artifacts }
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
root = Dir.pwd
|
|
56
|
+
state_path = File.expand_path(options[:state], root)
|
|
57
|
+
result = nil
|
|
58
|
+
lock = options[:write] ? DevflowControlIO.acquire_project_lock(root) : nil
|
|
59
|
+
begin
|
|
60
|
+
unless File.exist?(state_path)
|
|
61
|
+
result = { "ok" => false, "classification" => "unsafe", "reason" => "missing_state_file" }
|
|
62
|
+
else
|
|
63
|
+
state = YAML.safe_load(front_matter(File.read(state_path)), permitted_classes: [], aliases: false)
|
|
64
|
+
workflow_path = File.expand_path(state.fetch("workflow_config"), root)
|
|
65
|
+
workflow = YAML.safe_load(File.read(workflow_path), permitted_classes: [], aliases: false)
|
|
66
|
+
candidate = build_registry(root, workflow, state)
|
|
67
|
+
validation = WorkflowStateValidator.validate(root: root, state_path: state_path, state_override: state, workflow_override: workflow, artifact_registry_override: candidate, strict_artifact_status: true, strict_registry_revision: true)
|
|
68
|
+
current = Array(workflow["steps"]).find { |step| step["id"] == state["current_step"] }
|
|
69
|
+
missing_current_outputs = Array(current && current["outputs"]).map do |output|
|
|
70
|
+
next unless output["required"]
|
|
71
|
+
path = interpolate_path(output.fetch("path"), state)
|
|
72
|
+
path unless File.exist?(File.join(root, path))
|
|
73
|
+
end.compact
|
|
74
|
+
if missing_current_outputs.any?
|
|
75
|
+
result = { "ok" => false, "classification" => "unsafe", "reason" => "missing_required_output", "missing_outputs" => missing_current_outputs, "plan" => { "state_transition" => "none", "registry_write" => false } }
|
|
76
|
+
elsif !validation["ok"]
|
|
77
|
+
result = { "ok" => false, "classification" => "unsafe", "reason" => "ambiguous_or_invalid_state", "issues" => validation["errors"], "plan" => { "state_transition" => "none", "registry_write" => false } }
|
|
78
|
+
elsif !options[:confirm] || !options[:write]
|
|
79
|
+
result = { "ok" => false, "classification" => "safe_repairable", "reason" => "confirmation_required", "revision" => state.fetch("revision", 0), "plan" => { "state_transition" => "none", "registry_write" => true, "registry" => candidate } }
|
|
80
|
+
elsif options[:expected_revision] != state.fetch("revision", 0)
|
|
81
|
+
result = { "ok" => false, "classification" => "unsafe", "reason" => "stale_revision", "expected_revision" => options[:expected_revision], "actual_revision" => state.fetch("revision", 0) }
|
|
82
|
+
else
|
|
83
|
+
registry_path = File.join(root, workflow.dig("artifact_registry", "source"))
|
|
84
|
+
DevflowControlIO.atomic_write(registry_path, candidate.to_yaml)
|
|
85
|
+
result = { "ok" => true, "classification" => "safe_repairable", "written" => true, "revision" => state.fetch("revision", 0), "plan" => { "state_transition" => "none", "registry_write" => true } }
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
ensure
|
|
89
|
+
DevflowControlIO.release_lock(lock)
|
|
90
|
+
end
|
|
91
|
+
puts JSON.pretty_generate(result)
|
|
@@ -21,7 +21,7 @@ module WorkflowStateValidator
|
|
|
21
21
|
|
|
22
22
|
module_function
|
|
23
23
|
|
|
24
|
-
def validate(root:, state_path:, state_override: nil, workflow_override: nil, strict_artifact_status: true, strict_registry_revision: true)
|
|
24
|
+
def validate(root:, state_path:, state_override: nil, workflow_override: nil, artifact_registry_override: nil, strict_artifact_status: true, strict_registry_revision: true)
|
|
25
25
|
root = File.expand_path(root)
|
|
26
26
|
state_path = File.expand_path(state_path, root)
|
|
27
27
|
issues = []
|
|
@@ -45,7 +45,8 @@ module WorkflowStateValidator
|
|
|
45
45
|
registry_path,
|
|
46
46
|
issues,
|
|
47
47
|
state_revision: state.fetch("revision", 0),
|
|
48
|
-
strict_registry_revision: strict_registry_revision
|
|
48
|
+
strict_registry_revision: strict_registry_revision,
|
|
49
|
+
registry_override: artifact_registry_override
|
|
49
50
|
)
|
|
50
51
|
|
|
51
52
|
steps = Array(workflow["steps"])
|
|
@@ -60,6 +61,7 @@ module WorkflowStateValidator
|
|
|
60
61
|
validate_dependencies(issues, step_by_id, completed_ids)
|
|
61
62
|
validate_completed_outputs(issues, root, state, step_by_id, completed_ids, artifact_by_path, strict_artifact_status)
|
|
62
63
|
validate_waiting_checkpoint(issues, root, state, step_by_id, artifact_by_path, strict_artifact_status)
|
|
64
|
+
validate_current_in_progress_output_drift(issues, root, state, step_by_id, artifact_by_path)
|
|
63
65
|
|
|
64
66
|
ok = issues.none? { |entry| entry["severity"] == "error" }
|
|
65
67
|
result(ok, state_path, workflow_path, registry_path, issues)
|
|
@@ -96,16 +98,19 @@ module WorkflowStateValidator
|
|
|
96
98
|
nil
|
|
97
99
|
end
|
|
98
100
|
|
|
99
|
-
def load_artifacts(root, registry_path, issues, state_revision:, strict_registry_revision:)
|
|
101
|
+
def load_artifacts(root, registry_path, issues, state_revision:, strict_registry_revision:, registry_override: nil)
|
|
100
102
|
return {} unless registry_path
|
|
101
103
|
|
|
102
|
-
|
|
103
|
-
unless
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
104
|
+
registry = registry_override
|
|
105
|
+
unless registry
|
|
106
|
+
abs_registry_path = File.expand_path(registry_path, root)
|
|
107
|
+
unless File.exist?(abs_registry_path)
|
|
108
|
+
issues << issue("error", "missing_artifact_registry", "Artifact registry does not exist", path: registry_path)
|
|
109
|
+
return {}
|
|
110
|
+
end
|
|
107
111
|
|
|
108
|
-
|
|
112
|
+
registry = YAML.safe_load(File.read(abs_registry_path), permitted_classes: [], aliases: false)
|
|
113
|
+
end
|
|
109
114
|
if strict_registry_revision && registry.key?("state_revision") && Integer(registry["state_revision"]) != Integer(state_revision)
|
|
110
115
|
issues << issue(
|
|
111
116
|
"error",
|
|
@@ -207,6 +212,37 @@ module WorkflowStateValidator
|
|
|
207
212
|
validate_step_outputs(issues, root, state, step, artifact_by_path, strict_artifact_status, true)
|
|
208
213
|
end
|
|
209
214
|
|
|
215
|
+
# An in-progress step may have written its output before its complete-step
|
|
216
|
+
# transition. Treat a stale registry observation as recoverable drift, not as
|
|
217
|
+
# proof that the output is absent. Completion will reconcile it atomically.
|
|
218
|
+
def validate_current_in_progress_output_drift(issues, root, state, step_by_id, artifact_by_path)
|
|
219
|
+
return unless state["status"] == "in_progress"
|
|
220
|
+
|
|
221
|
+
step = step_by_id[state["current_step"]]
|
|
222
|
+
return unless step
|
|
223
|
+
|
|
224
|
+
Array(step["outputs"]).each do |output|
|
|
225
|
+
next unless output["required"]
|
|
226
|
+
|
|
227
|
+
path = interpolate_path(output.fetch("path"), state)
|
|
228
|
+
next unless File.exist?(File.expand_path(path, root))
|
|
229
|
+
|
|
230
|
+
artifact = artifact_by_path[path]
|
|
231
|
+
next if artifact && artifact["exists"] != false && artifact["content_valid"] != false
|
|
232
|
+
|
|
233
|
+
issues << issue(
|
|
234
|
+
"warning",
|
|
235
|
+
"current_output_registry_drift",
|
|
236
|
+
"Current in-progress step output exists but artifact registry is missing or marks it invalid",
|
|
237
|
+
step: step["id"],
|
|
238
|
+
path: path,
|
|
239
|
+
registry_exists: artifact && artifact["exists"],
|
|
240
|
+
registry_content_valid: artifact && artifact["content_valid"],
|
|
241
|
+
recovery: "Call devflow_artifact with action=sync and preserve=false, then retry the complete-step transition."
|
|
242
|
+
)
|
|
243
|
+
end
|
|
244
|
+
end
|
|
245
|
+
|
|
210
246
|
def validate_step_outputs(issues, root, state, step, artifact_by_path, strict_artifact_status, waiting_checkpoint)
|
|
211
247
|
Array(step["outputs"]).each do |output|
|
|
212
248
|
next unless output["required"]
|