@guanlin4924/devflow-pi 0.1.38 → 0.1.40
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/package.json +1 -1
- package/plugins/devflow-core/extensions/devflow-core.ts +178 -8
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/advance_state.rb +283 -1
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/checker_registry.rb +31 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/cross_track_consistency_check.rb +94 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/execution_contract.rb +1 -1
- 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 +157 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/inspect_state.rb +3 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/machine_evidence.rb +112 -9
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/parallel_run.rb +222 -0
- 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 +131 -5
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/workflow_gate_contract.rb +148 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/templates/feature-development.yaml +29 -0
- package/src/constants.ts +1 -1
- package/src/runtime.ts +8 -2
|
@@ -0,0 +1,31 @@
|
|
|
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
|
+
cross-track-consistency-check
|
|
11
|
+
].freeze
|
|
12
|
+
EMBEDDED = %w[requirement-coverage-check source-authority-check traceability-check].freeze
|
|
13
|
+
|
|
14
|
+
module_function
|
|
15
|
+
|
|
16
|
+
def known?(id)
|
|
17
|
+
(DECLARED + EMBEDDED).include?(id.to_s)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def kind(id)
|
|
21
|
+
return "declared" if DECLARED.include?(id.to_s)
|
|
22
|
+
return "embedded" if EMBEDDED.include?(id.to_s)
|
|
23
|
+
nil
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def validate!(ids)
|
|
27
|
+
unknown = Array(ids).map(&:to_s).reject { |id| known?(id) }
|
|
28
|
+
raise ArgumentError, "unknown checker id(s): #{unknown.join(", ")}" unless unknown.empty?
|
|
29
|
+
true
|
|
30
|
+
end
|
|
31
|
+
end
|
package/plugins/devflow-core/skills/workflow-orchestrator/scripts/cross_track_consistency_check.rb
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "json"
|
|
5
|
+
require "optparse"
|
|
6
|
+
require "yaml"
|
|
7
|
+
|
|
8
|
+
options = { feature: nil, json: false }
|
|
9
|
+
OptionParser.new do |opts|
|
|
10
|
+
opts.on("--feature FEATURE") { |value| options[:feature] = value }
|
|
11
|
+
opts.on("--json") { options[:json] = true }
|
|
12
|
+
end.parse!
|
|
13
|
+
|
|
14
|
+
raise ArgumentError, "--feature is required" if options[:feature].to_s.empty?
|
|
15
|
+
feature = options[:feature].to_s
|
|
16
|
+
raise ArgumentError, "feature contains path separators" if feature.include?("/") || feature.include?("\\")
|
|
17
|
+
|
|
18
|
+
root = Dir.pwd
|
|
19
|
+
base = File.join(root, ".planning", "features", feature)
|
|
20
|
+
files = {
|
|
21
|
+
"api_contract" => File.join(base, "api-contract.md"),
|
|
22
|
+
"api_call_guide" => File.join(base, "api-call-guide.md"),
|
|
23
|
+
"frontend_spec" => File.join(base, "前端技术方案.md"),
|
|
24
|
+
"backend_spec" => File.join(base, "后端技术方案.md"),
|
|
25
|
+
"api_supplement" => File.join(base, "api-supplement-requests.md"),
|
|
26
|
+
}
|
|
27
|
+
errors = []
|
|
28
|
+
missing = files.select { |_, path| !File.file?(path) }.keys
|
|
29
|
+
errors << { "code" => "CROSS_TRACK_ARTIFACT_MISSING", "files" => missing } unless missing.empty?
|
|
30
|
+
|
|
31
|
+
read = lambda do |key|
|
|
32
|
+
path = files.fetch(key)
|
|
33
|
+
File.file?(path) ? File.read(path) : ""
|
|
34
|
+
end
|
|
35
|
+
contract = read.call("api_contract")
|
|
36
|
+
guide = read.call("api_call_guide")
|
|
37
|
+
frontend = read.call("frontend_spec")
|
|
38
|
+
|
|
39
|
+
# Endpoint references are deliberately extracted from the authoritative API
|
|
40
|
+
# contract and checked for textual closure in both consumers. This is a
|
|
41
|
+
# deterministic reference-closure check; an AI integration summary is not used.
|
|
42
|
+
endpoint_pattern = %r{\b(?:GET|POST|PUT|PATCH|DELETE)\s+(/[A-Za-z0-9_./:-]+(?:\{[A-Za-z0-9_]+\})*)}
|
|
43
|
+
endpoints = contract.scan(endpoint_pattern).flatten.uniq
|
|
44
|
+
unreferenced = endpoints.reject { |endpoint| guide.include?(endpoint) && frontend.include?(endpoint) }
|
|
45
|
+
unless unreferenced.empty?
|
|
46
|
+
errors << { "code" => "CROSS_TRACK_ENDPOINT_REFERENCE_MISSING", "endpoints" => unreferenced }
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
contract_set = endpoints.to_h { |endpoint| [endpoint, true] }
|
|
50
|
+
{
|
|
51
|
+
"api_call_guide" => guide.scan(endpoint_pattern).flatten.uniq,
|
|
52
|
+
"frontend_spec" => frontend.scan(endpoint_pattern).flatten.uniq,
|
|
53
|
+
}.each do |consumer, references|
|
|
54
|
+
ghosts = references.reject { |endpoint| contract_set[endpoint] }
|
|
55
|
+
errors << { "code" => "CROSS_TRACK_ENDPOINT_NOT_IN_CONTRACT", "consumer" => consumer, "endpoints" => ghosts } unless ghosts.empty?
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
supplement = read.call("api_supplement")
|
|
59
|
+
supplement_endpoints = supplement.scan(endpoint_pattern).flatten.uniq
|
|
60
|
+
supplement_ghosts = supplement_endpoints.reject { |endpoint| contract_set[endpoint] }
|
|
61
|
+
errors << { "code" => "CROSS_TRACK_SUPPLEMENT_ENDPOINT_NOT_IN_CONTRACT", "endpoints" => supplement_ghosts } unless supplement_ghosts.empty?
|
|
62
|
+
supplement_unconsumed = supplement_endpoints.reject { |endpoint| guide.include?(endpoint) }
|
|
63
|
+
errors << { "code" => "CROSS_TRACK_SUPPLEMENT_GUIDE_MISMATCH", "endpoints" => supplement_unconsumed } unless supplement_unconsumed.empty?
|
|
64
|
+
|
|
65
|
+
meta_path = File.join(base, "api-call-guide.meta.json")
|
|
66
|
+
if File.file?(meta_path)
|
|
67
|
+
begin
|
|
68
|
+
meta = JSON.parse(File.read(meta_path))
|
|
69
|
+
errors << { "code" => "CROSS_TRACK_API_GUIDE_NOT_CONSUMABLE" } unless meta["frontend_consumable"] == true && meta["mock_ready"] == true
|
|
70
|
+
pending = meta["pending_items"]
|
|
71
|
+
errors << { "code" => "CROSS_TRACK_API_GUIDE_PENDING_ITEMS", "pending_items" => pending } unless pending.is_a?(Array) && pending.empty?
|
|
72
|
+
rescue JSON::ParserError => error
|
|
73
|
+
errors << { "code" => "CROSS_TRACK_META_INVALID", "detail" => error.message }
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
supplement_meta = File.join(base, "api-supplement-requests.meta.json")
|
|
78
|
+
if File.file?(supplement_meta)
|
|
79
|
+
begin
|
|
80
|
+
meta = JSON.parse(File.read(supplement_meta))
|
|
81
|
+
unresolved = meta["unresolved_supplement_count"]
|
|
82
|
+
errors << { "code" => "CROSS_TRACK_SUPPLEMENT_UNRESOLVED", "count" => unresolved } unless unresolved == 0
|
|
83
|
+
rescue JSON::ParserError => error
|
|
84
|
+
errors << { "code" => "CROSS_TRACK_META_INVALID", "detail" => error.message }
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
result = {
|
|
89
|
+
"ok" => errors.empty?,
|
|
90
|
+
"checker_id" => "cross-track-consistency-check",
|
|
91
|
+
"errors" => errors,
|
|
92
|
+
"evidence" => { "endpoint_count" => endpoints.length, "checked_files" => files.values.map { |path| path.sub("#{root}/", "") } },
|
|
93
|
+
}
|
|
94
|
+
puts(options[:json] ? JSON.generate(result) : result.to_yaml)
|
|
@@ -14,7 +14,7 @@ POLICY = {
|
|
|
14
14
|
"requirement-baseline" => { "checkers" => %w[requirement-graph-check slice-independence-check acceptance-coverage-check change-impact-check], "agents" => [], "budget" => 0, "allowed_graph_delta" => %w[requirement rule boundary slice acceptance] },
|
|
15
15
|
"api-contract" => { "checkers" => %w[requirement-graph-check contract-coverage-check api-test-schema-check change-impact-check], "agents" => [], "budget" => 0, "allowed_graph_delta" => %w[api] },
|
|
16
16
|
"architecture-feasibility" => { "checkers" => %w[requirement-graph-check change-impact-check], "agents" => [], "budget" => 0, "allowed_graph_delta" => %w[workload_shape workload_constraint decision risk metric read_model] },
|
|
17
|
-
"integration-verify" => { "checkers" => %w[requirement-graph-check slice-independence-check contract-coverage-check change-impact-check], "agents" => ["verification-runner"], "budget" => 1, "allowed_graph_delta" => %w[requirement risk decision] },
|
|
17
|
+
"integration-verify" => { "checkers" => %w[requirement-graph-check slice-independence-check contract-coverage-check change-impact-check cross-track-consistency-check], "agents" => ["verification-runner"], "budget" => 1, "allowed_graph_delta" => %w[requirement risk decision] },
|
|
18
18
|
"acceptance-test-design" => { "checkers" => %w[requirement-graph-check contract-coverage-check acceptance-coverage-check api-test-matrix-check test-coverage-check test-obligation-check change-impact-check], "agents" => [], "budget" => 0, "allowed_graph_delta" => %w[test_case] },
|
|
19
19
|
"change-plan" => { "checkers" => %w[requirement-graph-check slice-independence-check contract-coverage-check api-test-matrix-check test-coverage-check test-obligation-check test-task-binding-check change-impact-check], "agents" => [], "budget" => 0, "allowed_graph_delta" => %w[acceptance] },
|
|
20
20
|
"execute" => { "checkers" => %w[tdd-evidence-check change-impact-check], "agents" => ["implementation-worker"], "budget" => 1, "allowed_graph_delta" => %w[requirement rule boundary behavior] },
|
|
@@ -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,157 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "json"
|
|
5
|
+
require_relative "gate_result"
|
|
6
|
+
require_relative "gate_contract"
|
|
7
|
+
require_relative "checker_registry"
|
|
8
|
+
require_relative "parallel_run"
|
|
9
|
+
|
|
10
|
+
module GateRuntime
|
|
11
|
+
WORKFLOW_STATUSES = %w[in_progress waiting_checkpoint completed blocked failed cancelled remediation].freeze
|
|
12
|
+
|
|
13
|
+
module_function
|
|
14
|
+
|
|
15
|
+
def validate_contract!(contract)
|
|
16
|
+
GateContract.validate!(contract)
|
|
17
|
+
CheckerRegistry.validate!(Array(contract.dig("quality", "checker_ids")))
|
|
18
|
+
contract
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def evaluate(contract:, workflow_state:, current_revision:, expected_revision:, track: nil, branch_id: nil, attempt: nil, branch_revision: nil, source: "harness", evidence: {}, root: nil, check_outputs: false, transition_kind: nil, checkpoint_authorized: false)
|
|
22
|
+
validate_contract!(contract)
|
|
23
|
+
gate_id = contract.fetch("gate_id")
|
|
24
|
+
step_id = contract.fetch("step_id")
|
|
25
|
+
base = { gate_id: gate_id, step_id: step_id, evidence: evidence.merge("collector" => source), revisions: { "state" => current_revision } }
|
|
26
|
+
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
|
|
27
|
+
unless WORKFLOW_STATUSES.include?(workflow_state["status"].to_s)
|
|
28
|
+
return GateResult.block(**base, code: "WORKFLOW_STATUS_INVALID", message: "workflow status is not recognized")
|
|
29
|
+
end
|
|
30
|
+
if workflow_state["parallel_run_ref"]
|
|
31
|
+
branch_error = validate_parallel_branch(
|
|
32
|
+
contract: contract,
|
|
33
|
+
workflow_state: workflow_state,
|
|
34
|
+
current_revision: current_revision,
|
|
35
|
+
track: track,
|
|
36
|
+
branch_id: branch_id,
|
|
37
|
+
attempt: attempt,
|
|
38
|
+
branch_revision: branch_revision,
|
|
39
|
+
evidence: evidence,
|
|
40
|
+
root: root,
|
|
41
|
+
transition_kind: transition_kind,
|
|
42
|
+
)
|
|
43
|
+
return GateResult.block(**base, **branch_error) if branch_error
|
|
44
|
+
end
|
|
45
|
+
unless %w[confirm_checkpoint enter_failure_branch exit_failure_branch parallel_join].include?(transition_kind)
|
|
46
|
+
allowed_tracks = Array(contract.dig("topology", "allowed_tracks"))
|
|
47
|
+
if track && !allowed_tracks.empty? && !allowed_tracks.include?(track)
|
|
48
|
+
return GateResult.block(**base, code: "TRACK_SCOPE_VIOLATION", message: "current track is not allowed by GateContract", recovery: { "kind" => "registered_reentry" })
|
|
49
|
+
end
|
|
50
|
+
allowed_predecessors = Array(contract.dig("topology", "allowed_predecessors"))
|
|
51
|
+
previous = workflow_state["last_completed_step"] || workflow_state["previous_step"]
|
|
52
|
+
if previous && !allowed_predecessors.empty? && !allowed_predecessors.include?(previous)
|
|
53
|
+
return GateResult.block(**base, code: "STEP_PREDECESSOR_INVALID", message: "previous step is not allowed by GateContract")
|
|
54
|
+
end
|
|
55
|
+
if root
|
|
56
|
+
missing = required_paths(contract.dig("entry", "required_inputs"), root, workflow_state)
|
|
57
|
+
return GateResult.block(**base, code: "REQUIRED_INPUT_MISSING", message: "required gate input is missing", details: { "missing" => missing }) unless missing.empty?
|
|
58
|
+
if check_outputs
|
|
59
|
+
missing_outputs = required_paths(contract.dig("outputs", "required"), root, workflow_state)
|
|
60
|
+
return GateResult.block(**base, code: "REQUIRED_OUTPUT_MISSING", message: "required step output is missing", details: { "missing" => missing_outputs }) unless missing_outputs.empty?
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
if transition_kind == "confirm_checkpoint"
|
|
65
|
+
checkpoint_id = workflow_state["checkpoint"].to_s
|
|
66
|
+
checkpoint = contract.dig("topology", "checkpoint")
|
|
67
|
+
checkpoint_matches = checkpoint.is_a?(Hash) && checkpoint["id"].to_s == checkpoint_id
|
|
68
|
+
unless workflow_state["status"].to_s == "waiting_checkpoint" && checkpoint_matches && checkpoint_authorized
|
|
69
|
+
return GateResult.block(**base, code: "CHECKPOINT_NOT_AUTHORIZED", message: "checkpoint confirmation is not authorized by the runtime", details: { "checkpoint" => checkpoint_id })
|
|
70
|
+
end
|
|
71
|
+
unless workflow_state["completed_steps"].to_a.include?(checkpoint["producing_step"].to_s)
|
|
72
|
+
return GateResult.block(**base, code: "CHECKPOINT_PRODUCER_INCOMPLETE", message: "checkpoint producing step is not completed", details: { "checkpoint" => checkpoint_id })
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
GateResult.allow(**base)
|
|
76
|
+
rescue KeyError, ArgumentError => e
|
|
77
|
+
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)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def validate_parallel_branch(contract:, workflow_state:, current_revision:, track:, branch_id:, attempt:, branch_revision:, evidence:, root:, transition_kind:)
|
|
81
|
+
return { code: "PARALLEL_CONTEXT_MISSING", message: "parallel workflow requires root" } unless root
|
|
82
|
+
|
|
83
|
+
record = ParallelRun.load(root, workflow_state.fetch("current_feature"))
|
|
84
|
+
return { code: "PARALLEL_RUN_NOT_FOUND", message: "parallel_run_ref does not resolve to a run record" } unless record
|
|
85
|
+
return { code: "PARALLEL_RUN_REF_MISMATCH", message: "STATE parallel_run_ref does not match run record" } unless record["run_id"].to_s == workflow_state["parallel_run_ref"].to_s
|
|
86
|
+
bound_revision = Integer(record.fetch("bound_state_revision"))
|
|
87
|
+
return { code: "PARALLEL_STATE_REVISION_MISMATCH", message: "parallel run is bound to a different STATE revision", details: { expected_state_revision: bound_revision, actual_state_revision: current_revision } } unless bound_revision == current_revision.to_i
|
|
88
|
+
|
|
89
|
+
if transition_kind.to_s == "parallel_join"
|
|
90
|
+
branches = Array(record["branches"])
|
|
91
|
+
return { code: "PARALLEL_JOIN_INCOMPLETE", message: "parallel join requires every branch to succeed", details: { incomplete: branches.reject { |item| item["status"].to_s == "succeeded" }.map { |item| item["branch_id"] } } } unless branches.any? && branches.all? { |item| item["status"].to_s == "succeeded" }
|
|
92
|
+
return nil
|
|
93
|
+
end
|
|
94
|
+
return { code: "PARALLEL_CONTEXT_MISSING", message: "parallel workflow requires branch_id" } unless branch_id
|
|
95
|
+
|
|
96
|
+
branch = Array(record["branches"]).find { |item| item["branch_id"].to_s == branch_id.to_s }
|
|
97
|
+
return { code: "PARALLEL_BRANCH_NOT_FOUND", message: "branch is not registered in the parallel run", details: { branch_id: branch_id } } unless branch
|
|
98
|
+
return { code: "PARALLEL_STEP_MISMATCH", message: "branch is bound to a different workflow step", details: { branch_step: branch["workflow_step"], gate_step: contract["step_id"] } } unless branch["workflow_step"].to_s == contract["step_id"].to_s
|
|
99
|
+
expected_track = branch["track"].to_s.sub(/\Atrack_/, "").upcase
|
|
100
|
+
return { code: "PARALLEL_TRACK_MISMATCH", message: "branch track is not the requested track", details: { branch_track: expected_track, requested_track: track } } unless track.to_s.empty? || expected_track.empty? || expected_track == track.to_s
|
|
101
|
+
return { code: "PARALLEL_BRANCH_TERMINAL", message: "terminal branch cannot pass a gate; retry must create a new attempt" } if ParallelRun::TERMINAL_BRANCH_STATUSES.include?(branch["status"].to_s) && transition_kind.to_s != "parallel_branch_retry"
|
|
102
|
+
return { code: "PARALLEL_ATTEMPT_MISMATCH", message: "branch attempt does not match the current run record" } if attempt && Integer(branch.fetch("attempt", 0)) != attempt.to_i
|
|
103
|
+
return { code: "PARALLEL_BRANCH_REVISION_MISMATCH", message: "branch revision does not match the current run record" } if branch_revision && Integer(branch.fetch("branch_revision", 0)) != branch_revision.to_i
|
|
104
|
+
|
|
105
|
+
predecessor_ids = Array(contract.dig("topology", "allowed_predecessors"))
|
|
106
|
+
completed = Array(workflow_state["completed_steps"]).map(&:to_s)
|
|
107
|
+
completed.concat(Array(record["branches"]).select { |item| item["status"].to_s == "succeeded" }.map { |item| item["workflow_step"].to_s })
|
|
108
|
+
missing_predecessors = predecessor_ids.map(&:to_s).reject { |step| completed.include?(step) }
|
|
109
|
+
return { code: "PARALLEL_PREDECESSOR_INCOMPLETE", message: "parallel branch predecessor is incomplete", details: { missing: missing_predecessors } } unless missing_predecessors.empty?
|
|
110
|
+
|
|
111
|
+
if transition_kind.to_s == "parallel_branch_complete"
|
|
112
|
+
required = %w[branch_id attempt branch_revision]
|
|
113
|
+
missing = required.reject { |field| evidence.key?(field) }
|
|
114
|
+
return { code: "PARALLEL_EVIDENCE_MISSING", message: "branch completion requires independent evidence binding", details: { missing: missing } } unless missing.empty?
|
|
115
|
+
return { code: "PARALLEL_EVIDENCE_MISMATCH", message: "branch evidence does not match current branch", details: { branch_id: branch_id } } unless evidence["branch_id"].to_s == branch_id.to_s && evidence["attempt"].to_i == branch.fetch("attempt", 0).to_i && evidence["branch_revision"].to_i == branch.fetch("branch_revision", 0).to_i
|
|
116
|
+
evidence_path = evidence["evidence_path"].to_s
|
|
117
|
+
return { code: "PARALLEL_EVIDENCE_MISSING", message: "Core machine evidence file is required" } if evidence_path.empty? || !File.file?(File.expand_path(evidence_path, root))
|
|
118
|
+
begin
|
|
119
|
+
pack = JSON.parse(File.read(File.expand_path(evidence_path, root)))
|
|
120
|
+
binding = pack.dig("evidence", "parallel_binding")
|
|
121
|
+
expected = { "run_id" => record["run_id"], "branch_id" => branch["branch_id"], "attempt" => branch.fetch("attempt", 0), "branch_revision" => branch.fetch("branch_revision", 0), "state_revision" => current_revision.to_i }
|
|
122
|
+
return { code: "PARALLEL_EVIDENCE_MISMATCH", message: "machine evidence binding does not match current branch", details: { expected: expected, actual: binding } } unless binding.is_a?(Hash) && expected.all? { |key, value| binding[key].to_s == value.to_s }
|
|
123
|
+
rescue JSON::ParserError, Errno::ENOENT
|
|
124
|
+
return { code: "PARALLEL_EVIDENCE_MISMATCH", message: "machine evidence file is not valid JSON" }
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
nil
|
|
128
|
+
rescue KeyError, ArgumentError, TypeError
|
|
129
|
+
{ code: "PARALLEL_RECORD_INVALID", message: "parallel run or branch record is invalid" }
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def run_checker(contract:, workflow_state:, current_revision:, expected_revision:, checker:, **kwargs)
|
|
133
|
+
boundary = evaluate(contract: contract, workflow_state: workflow_state, current_revision: current_revision, expected_revision: expected_revision, **kwargs)
|
|
134
|
+
return boundary unless boundary["verdict"] == "allow"
|
|
135
|
+
result = checker.call
|
|
136
|
+
raise ArgumentError, "checker must return a GateResult hash" unless result.is_a?(Hash) && result["verdict"]
|
|
137
|
+
result.merge(
|
|
138
|
+
"gate_id" => result["gate_id"] || boundary["gate_id"],
|
|
139
|
+
"step_id" => result["step_id"] || boundary["step_id"],
|
|
140
|
+
"evidence" => boundary.fetch("evidence", {}).merge(result.fetch("evidence", {})),
|
|
141
|
+
"revisions" => boundary.fetch("revisions", {}).merge(result.fetch("revisions", {}))
|
|
142
|
+
)
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def required_paths(entries, root, state)
|
|
146
|
+
Array(entries).each_with_object([]) do |entry, missing|
|
|
147
|
+
raw = entry.is_a?(Hash) ? entry["path"] : entry
|
|
148
|
+
next if raw.to_s.empty?
|
|
149
|
+
relative = raw.to_s.gsub("{feature}", state.fetch("current_feature", "").to_s).gsub("{phase}", state.fetch("current_phase", "").to_s)
|
|
150
|
+
absolute = File.expand_path(relative, root)
|
|
151
|
+
root_real = File.realpath(root)
|
|
152
|
+
actual = File.exist?(absolute) ? File.realpath(absolute) : absolute
|
|
153
|
+
next if actual.start_with?("#{root_real}#{File::SEPARATOR}") && File.exist?(actual)
|
|
154
|
+
missing << relative
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
end
|
|
@@ -191,6 +191,9 @@ def active_parallel_window(workflow, state, root, step_by_id, completed_ids, pen
|
|
|
191
191
|
step = step_by_id[step_id]
|
|
192
192
|
pending_ids.include?(step_id) && step && dependencies_satisfied?(step, completed_ids)
|
|
193
193
|
end
|
|
194
|
+
# A single runnable branch is an intentional serial fallback (for example
|
|
195
|
+
# when active_track limits the window to one track), not a missing window.
|
|
196
|
+
# Only a genuinely multi-branch runnable set enters the parallel scheduler.
|
|
194
197
|
next unless runnable_ids.length > 1
|
|
195
198
|
|
|
196
199
|
policy = window["execution_policy"] || {}
|
|
@@ -10,14 +10,19 @@ require "optparse"
|
|
|
10
10
|
require "yaml"
|
|
11
11
|
require_relative "control_io"
|
|
12
12
|
require_relative "requirement_graph"
|
|
13
|
+
require_relative "checker_registry"
|
|
13
14
|
|
|
14
|
-
options = { feature: nil, step: nil, contract: nil, write: false, json: false }
|
|
15
|
+
options = { feature: nil, step: nil, contract: nil, write: false, json: false, branch_id: nil, attempt: nil, branch_revision: nil, lock_held: false }
|
|
15
16
|
OptionParser.new do |o|
|
|
16
17
|
o.on("--feature NAME") { |v| options[:feature] = v }
|
|
17
18
|
o.on("--step ID") { |v| options[:step] = v }
|
|
18
19
|
o.on("--contract PATH") { |v| options[:contract] = v }
|
|
19
20
|
o.on("--write") { options[:write] = true }
|
|
20
21
|
o.on("--json") { options[:json] = true }
|
|
22
|
+
o.on("--parallel-branch-id ID") { |v| options[:branch_id] = v }
|
|
23
|
+
o.on("--parallel-attempt N", Integer) { |v| options[:attempt] = v }
|
|
24
|
+
o.on("--parallel-branch-revision N", Integer) { |v| options[:branch_revision] = v }
|
|
25
|
+
o.on("--lock-held") { options[:lock_held] = true }
|
|
21
26
|
end.parse!
|
|
22
27
|
abort "feature, step and --write are required" unless options[:feature] && options[:step] && options[:write]
|
|
23
28
|
abort "invalid feature" unless RequirementGraph.valid_feature?(options[:feature])
|
|
@@ -33,6 +38,81 @@ def interpolate(path, state)
|
|
|
33
38
|
path.gsub("{feature}", state.fetch("current_feature", "").to_s).gsub("{phase}", state.fetch("current_phase", "").to_s)
|
|
34
39
|
end
|
|
35
40
|
|
|
41
|
+
def file_evidence(root, relative)
|
|
42
|
+
absolute, = DevflowControlIO.resolve_control_path(root, relative, :artifact)
|
|
43
|
+
return { "path" => relative, "exists" => false } unless File.file?(absolute)
|
|
44
|
+
|
|
45
|
+
{ "path" => relative, "exists" => true, "sha256" => Digest::SHA256.file(absolute).hexdigest, "bytes" => File.size(absolute) }
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def independent_evidence(root, feature, step, state, workflow, graph, files, checker_results, binding = nil, inputs = [])
|
|
49
|
+
source_nodes = Array(graph["nodes"]).select { |node| node.is_a?(Hash) && node["kind"] == "source" }
|
|
50
|
+
authority = source_nodes.map do |node|
|
|
51
|
+
evidence = node["evidence"].is_a?(Hash) ? node["evidence"] : {}
|
|
52
|
+
{
|
|
53
|
+
"source_id" => node["id"], "authority" => evidence["authority"], "path" => evidence["path"] || evidence["ref"],
|
|
54
|
+
"fingerprint" => evidence["sha256"] || evidence["source_document_fingerprint"]
|
|
55
|
+
}.compact
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# The harness may publish an append-only event log beside the machine pack.
|
|
59
|
+
# Absence is represented as unavailable, never as false/zero, so the pack
|
|
60
|
+
# cannot manufacture proof that an engine did not invoke AI.
|
|
61
|
+
binding_attempt = binding && binding["attempt"]
|
|
62
|
+
event_name = binding_attempt.nil? ? "#{step}.engine-events.jsonl" : "#{step}.attempt-#{binding_attempt}.engine-events.jsonl"
|
|
63
|
+
event_path = File.join(".planning", "evidence", feature, event_name)
|
|
64
|
+
event_absolute, = DevflowControlIO.resolve_control_path(root, event_path, :artifact)
|
|
65
|
+
events = if File.file?(event_absolute)
|
|
66
|
+
File.foreach(event_absolute).first(10_000).each_with_object([]) do |line, parsed|
|
|
67
|
+
next if line.strip.empty?
|
|
68
|
+
|
|
69
|
+
begin
|
|
70
|
+
parsed << JSON.parse(line)
|
|
71
|
+
rescue JSON::ParserError
|
|
72
|
+
# A malformed event is not evidence of a clean execution;
|
|
73
|
+
# retain the valid events and let the collector expose the
|
|
74
|
+
# observed subset without inventing a zero count.
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
else
|
|
78
|
+
nil
|
|
79
|
+
end
|
|
80
|
+
ai_events = events ? events.select { |event| %w[ai_runtime_initialized ai_invocation].include?(event["event"]) } : []
|
|
81
|
+
dispatched_events = events ? events.select { |event| event["event"] == "engine_dispatched" } : []
|
|
82
|
+
ai_measurement_observed = !ai_events.empty?
|
|
83
|
+
|
|
84
|
+
{
|
|
85
|
+
"collector" => "core.machine_evidence/v1",
|
|
86
|
+
"state" => { "revision" => state["revision"], "step" => step },
|
|
87
|
+
"parallel_binding" => binding,
|
|
88
|
+
"input_fingerprints" => inputs.select { |item| item["exists"] }.map { |item| item["sha256"] },
|
|
89
|
+
"output_fingerprints" => files.select { |item| item["exists"] }.map { |item| item["sha256"] },
|
|
90
|
+
"source_authority" => { "status" => authority.empty? ? "unavailable" : "observed", "sources" => authority },
|
|
91
|
+
"candidate_binding" => {
|
|
92
|
+
"status" => "observed",
|
|
93
|
+
"graph_revision" => graph["revision"],
|
|
94
|
+
"test_case_ids" => Array(graph["nodes"]).each_with_object([]) { |node, ids| ids << node["id"] if node.is_a?(Hash) && node["kind"] == "test_case" }
|
|
95
|
+
},
|
|
96
|
+
"structured_execution" => {
|
|
97
|
+
"status" => "observed",
|
|
98
|
+
"required_files" => files,
|
|
99
|
+
"checker_ids" => checker_results.map { |item| item["id"] }
|
|
100
|
+
},
|
|
101
|
+
"engine_evidence" => {
|
|
102
|
+
"collector" => events ? "harness.engine-events/v1" : "unavailable",
|
|
103
|
+
"event_log" => event_path,
|
|
104
|
+
# engine_dispatched proves that the harness dispatched work, but it does
|
|
105
|
+
# not prove how many model calls the child session made. Keep unknown as
|
|
106
|
+
# nil instead of manufacturing false/zero values for branch evidence.
|
|
107
|
+
"ai_runtime_initialized" => ai_measurement_observed ? ai_events.any? { |event| event["event"] == "ai_runtime_initialized" } : nil,
|
|
108
|
+
"ai_invocation_count" => ai_measurement_observed ? ai_events.count { |event| event["event"] == "ai_invocation" } : nil,
|
|
109
|
+
"engine_dispatched_count" => events ? dispatched_events.length : nil,
|
|
110
|
+
"ai_capable_engine_dispatched" => events ? dispatched_events.any? { |event| event["ai_capable"] == true } : nil,
|
|
111
|
+
"evidence_status" => events ? "observed" : "unavailable"
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
end
|
|
115
|
+
|
|
36
116
|
def configured_checkers(workflow, step_id)
|
|
37
117
|
explicit = workflow.dig("governance", "step_checkers", step_id)
|
|
38
118
|
checkers = if explicit
|
|
@@ -92,13 +172,24 @@ rescue JSON::ParserError => error
|
|
|
92
172
|
end
|
|
93
173
|
|
|
94
174
|
root = Dir.pwd
|
|
95
|
-
lock = DevflowControlIO.acquire_project_lock(root)
|
|
175
|
+
lock = options[:lock_held] ? nil : DevflowControlIO.acquire_project_lock(root)
|
|
96
176
|
begin
|
|
97
177
|
state = state_front_matter(root)
|
|
98
|
-
|
|
178
|
+
binding = nil
|
|
179
|
+
branch = nil
|
|
180
|
+
if options[:branch_id]
|
|
181
|
+
require_relative "parallel_run"
|
|
182
|
+
record = ParallelRun.load(root, options[:feature])
|
|
183
|
+
branch = Array(record && record["branches"]).find { |item| item["branch_id"].to_s == options[:branch_id].to_s }
|
|
184
|
+
raise "parallel branch not found" unless record && branch
|
|
185
|
+
raise "parallel branch attempt mismatch" unless options[:attempt].nil? || Integer(branch.fetch("attempt", 0)) == options[:attempt]
|
|
186
|
+
raise "parallel branch revision mismatch" unless options[:branch_revision].nil? || Integer(branch.fetch("branch_revision", 0)) == options[:branch_revision]
|
|
187
|
+
binding = { "run_id" => record["run_id"], "branch_id" => branch["branch_id"], "attempt" => branch["attempt"], "branch_revision" => branch["branch_revision"], "state_revision" => state["revision"] }
|
|
188
|
+
end
|
|
189
|
+
unless state["current_feature"] == options[:feature] && (state["current_step"] == options[:step] || branch)
|
|
99
190
|
result = { "ok" => false, "reason" => "state_step_mismatch", "state_feature" => state["current_feature"], "state_step" => state["current_step"] }
|
|
100
191
|
puts JSON.pretty_generate(result)
|
|
101
|
-
exit
|
|
192
|
+
exit 1
|
|
102
193
|
end
|
|
103
194
|
workflow_path, = DevflowControlIO.resolve_control_path(root, state.fetch("workflow_config"), :workflow)
|
|
104
195
|
workflow_raw = File.read(workflow_path)
|
|
@@ -117,10 +208,12 @@ begin
|
|
|
117
208
|
{ "path" => relative, "required" => true, "exists" => exists, "bytes" => exists ? File.size(absolute) : 0,
|
|
118
209
|
"sha256" => exists ? Digest::SHA256.file(absolute).hexdigest : nil }
|
|
119
210
|
end.compact
|
|
211
|
+
input_files = Array(step["inputs"]).map { |input| file_evidence(root, interpolate(input.to_s, state)) }
|
|
120
212
|
|
|
121
213
|
graph, = RequirementGraph.load_graph(root, options[:feature])
|
|
122
214
|
checkers = RequirementGraph.checker_results(graph)
|
|
123
215
|
selected = configured_checkers(workflow, options[:step])
|
|
216
|
+
CheckerRegistry.validate!(selected)
|
|
124
217
|
gate = workflow.dig("governance", "requirement_graph_gate").to_s
|
|
125
218
|
enforced = gate == "enforced"
|
|
126
219
|
checker_results = selected.map do |id|
|
|
@@ -133,6 +226,7 @@ begin
|
|
|
133
226
|
end
|
|
134
227
|
graph_missing = graph["revision"].zero? && !File.exist?(RequirementGraph.graph_path(root, options[:feature]).first)
|
|
135
228
|
checker_passed = !enforced || (!graph_missing && checker_results.all? { |item| item["passed"] })
|
|
229
|
+
engine_evidence_gate = (step["engine_evidence_gate"] || workflow.dig("governance", "engine_evidence_gate")).to_s
|
|
136
230
|
contract_hash = nil
|
|
137
231
|
if options[:contract]
|
|
138
232
|
contract_path = File.expand_path(options[:contract], root)
|
|
@@ -140,22 +234,31 @@ begin
|
|
|
140
234
|
contract_hash = Digest::SHA256.file(contract_path).hexdigest
|
|
141
235
|
end
|
|
142
236
|
node_counts = Array(graph["nodes"]).group_by { |node| node["kind"] }.transform_values(&:length)
|
|
237
|
+
independent = independent_evidence(root, options[:feature], options[:step], state, workflow, graph, files, checker_results, binding, input_files)
|
|
238
|
+
engine_evidence_complete = independent.dig("engine_evidence", "evidence_status") == "observed"
|
|
239
|
+
evidence_complete = engine_evidence_gate != "enforced" || engine_evidence_complete
|
|
143
240
|
pack = {
|
|
144
241
|
"schema_version" => 2, "origin" => "core_machine", "feature" => options[:feature], "step" => options[:step],
|
|
145
242
|
"state_revision" => state["revision"], "graph_revision" => graph["revision"], "workflow_fingerprint" => Digest::SHA256.hexdigest(workflow_raw),
|
|
146
|
-
"
|
|
243
|
+
"run_id" => binding && binding["run_id"], "branch_id" => binding && binding["branch_id"],
|
|
244
|
+
"attempt" => binding && binding["attempt"], "branch_revision" => binding && binding["branch_revision"],
|
|
245
|
+
"input_fingerprints" => input_files.select { |item| item["exists"] }.map { |item| item["sha256"] },
|
|
246
|
+
"output_fingerprints" => files.select { |item| item["exists"] }.map { |item| item["sha256"] },
|
|
247
|
+
"contract_hash" => contract_hash, "files" => files, "input_files" => input_files, "checkers" => checker_results, "evidence" => independent,
|
|
147
248
|
"counters" => { "required_file_count" => files.length, "existing_file_count" => files.count { |item| item["exists"] }, "nodes_by_kind" => node_counts,
|
|
148
249
|
"stale_node_count" => Array(graph["stale"]).length, "stale_view_count" => Hash(graph["views"]).count { |_name, view| view["needs_recheck"] } },
|
|
149
250
|
"view_fingerprints" => Hash(graph["views"]).transform_values { |view| view.select { |key, _value| %w[fingerprint graph_revision state needs_recheck stale_fingerprint].include?(key) } },
|
|
150
|
-
"complete" => files.all? { |item| item["exists"] } && checker_passed,
|
|
251
|
+
"complete" => files.all? { |item| item["exists"] } && checker_passed && evidence_complete,
|
|
252
|
+
"evidence_complete" => evidence_complete,
|
|
151
253
|
"gate_mode" => gate.empty? ? "observe" : gate
|
|
152
254
|
}.compact
|
|
153
|
-
|
|
255
|
+
suffix = binding ? ".attempt-#{binding["attempt"]}.branch-#{Digest::SHA256.hexdigest(binding["branch_id"].to_s)[0, 12]}" : ""
|
|
256
|
+
destination = File.join(root, ".planning/evidence", options[:feature], "#{options[:step]}#{suffix}.machine.json")
|
|
154
257
|
DevflowControlIO.atomic_write(destination, JSON.pretty_generate(pack) + "\n")
|
|
155
|
-
result = { "ok" => true, "path" => destination.sub("#{root}/", ""), "complete" => pack["complete"], "state_revision" => pack["state_revision"], "graph_revision" => pack["graph_revision"], "checkers" => checker_results }
|
|
258
|
+
result = { "ok" => true, "path" => destination.sub("#{root}/", ""), "complete" => pack["complete"], "state_revision" => pack["state_revision"], "graph_revision" => pack["graph_revision"], "checkers" => checker_results, "parallel_binding" => binding }
|
|
156
259
|
rescue StandardError => error
|
|
157
260
|
result = { "ok" => false, "reason" => "machine_evidence_failed", "message" => error.message }
|
|
158
261
|
ensure
|
|
159
|
-
DevflowControlIO.release_lock(lock)
|
|
262
|
+
DevflowControlIO.release_lock(lock) if lock
|
|
160
263
|
end
|
|
161
264
|
puts JSON.pretty_generate(result)
|