@guanlin4924/devflow-pi 0.1.40 → 0.1.47
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 +609 -42
- package/plugins/devflow-core/skills/wireframe-proposal/SKILL.md +13 -0
- package/plugins/devflow-core/skills/wireframe-proposal/agents/openai.yaml +4 -0
- package/plugins/devflow-core/skills/wireframe-render/SKILL.md +8 -0
- package/plugins/devflow-core/skills/wireframe-review/SKILL.md +8 -0
- package/plugins/devflow-core/skills/wireframe-validate/SKILL.md +8 -0
- package/plugins/devflow-core/skills/wireframe-validate/agents/openai.yaml +4 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/SKILL.md +16 -1
- package/plugins/devflow-core/skills/workflow-orchestrator/contracts/business-candidate-delta.schema.yaml +41 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/contracts/business-claim.schema.yaml +10 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/contracts/business-model-policy.yaml +51 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/contracts/business-model.schema.yaml +241 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/contracts/business-source-registry.schema.yaml +80 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/contracts/proposal.schema.yaml +44 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/contracts/questions.schema.yaml +14 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/acceptance_test_suite.rb +13 -3
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/advance_state.rb +143 -28
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/business_claim_validate.py +77 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/business_closure_e2e_seed.json +516 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/business_cross_model_check.py +76 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/business_impact.py +51 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/business_invalidation.rb +108 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/business_model_commit.rb +570 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/business_model_gate.rb +89 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/business_model_render.py +131 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/business_model_validate.py +1063 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/business_source_resolver.rb +365 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/checker_registry.rb +2 -1
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/execution_contract.rb +3 -3
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/gate_contract.rb +3 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/gate_runtime.rb +13 -4
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/parallel_recover.rb +386 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/parallel_run.rb +28 -2
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/requirement_graph.rb +139 -14
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/requirement_graph_backfill.rb +143 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/session_rollover.rb +15 -4
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/sync_artifacts.rb +45 -1
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/validate_state.rb +10 -1
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/wireframe_commit.rb +222 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/wireframe_render.py +231 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/wireframe_validate.py +457 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/templates/feature-development.yaml +106 -1
- package/plugins/devflow-implementation/agents/implementation-worker.md +6 -0
- package/plugins/devflow-intel/skills/workflow-code-index/scripts/code_index.py +107 -37
- package/plugins/devflow-intel/skills/workflow-code-index/scripts/core/scanner.py +20 -1
- package/plugins/devflow-quality/skills/workflow-acceptance-test-design/SKILL.md +1 -1
- package/src/constants.ts +1 -1
- package/src/runtime.ts +53 -22
|
@@ -0,0 +1,386 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# Recover a legacy parallel window whose workers produced valid artifacts but
|
|
5
|
+
# whose terminal lifecycle events were lost. This is deliberately a Core-only
|
|
6
|
+
# writer: it validates every candidate branch before changing any branch, uses
|
|
7
|
+
# ParallelRun's locked run-revision CAS for branch transitions, and delegates
|
|
8
|
+
# the workflow/STATE mutation to advance_state.rb's existing join action.
|
|
9
|
+
|
|
10
|
+
require "date"
|
|
11
|
+
require "digest"
|
|
12
|
+
require "json"
|
|
13
|
+
require "open3"
|
|
14
|
+
require "optparse"
|
|
15
|
+
require "time"
|
|
16
|
+
require "yaml"
|
|
17
|
+
require_relative "control_io"
|
|
18
|
+
require_relative "parallel_run"
|
|
19
|
+
|
|
20
|
+
options = {
|
|
21
|
+
feature: nil,
|
|
22
|
+
expected_state_revision: nil,
|
|
23
|
+
expected_run_revision: nil,
|
|
24
|
+
reason: "legacy_parallel_lifecycle_event_lost",
|
|
25
|
+
write: false,
|
|
26
|
+
json: false,
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
OptionParser.new do |opts|
|
|
30
|
+
opts.banner = "Usage: parallel_recover.rb [options]"
|
|
31
|
+
opts.on("--feature NAME", "Active feature whose parallel window is recovered") { |value| options[:feature] = value }
|
|
32
|
+
opts.on("--expected-state-revision REV", Integer, "CAS revision from STATE.md") { |value| options[:expected_state_revision] = value }
|
|
33
|
+
opts.on("--expected-run-revision REV", Integer, "CAS revision from branches.yaml") { |value| options[:expected_run_revision] = value }
|
|
34
|
+
opts.on("--reason TEXT", "Audit reason for the recovery") { |value| options[:reason] = value }
|
|
35
|
+
opts.on("--write", "Apply validated branch transitions and controlled join") { options[:write] = true }
|
|
36
|
+
opts.on("--json", "Print JSON output") { options[:json] = true }
|
|
37
|
+
end.parse!
|
|
38
|
+
|
|
39
|
+
def front_matter(markdown)
|
|
40
|
+
markdown.match(/\A---\n(.*?)\n---\n/m)&.[](1)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def load_yaml_mapping(path, label)
|
|
44
|
+
value = YAML.safe_load(File.read(path), permitted_classes: [Date, Time], aliases: false)
|
|
45
|
+
raise ArgumentError, "#{label} must be a mapping" unless value.is_a?(Hash)
|
|
46
|
+
|
|
47
|
+
value
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def interpolate_path(path, state)
|
|
51
|
+
path.to_s
|
|
52
|
+
.gsub("{feature}", state.fetch("current_feature", "").to_s)
|
|
53
|
+
.gsub("{phase}", state.fetch("current_phase", "").to_s)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def required_output_specs(step)
|
|
57
|
+
outputs = Array(step["outputs"]).select { |item| item.is_a?(Hash) && item["required"] == true }
|
|
58
|
+
outputs << step["meta_output"] if step["meta_output"].is_a?(Hash)
|
|
59
|
+
outputs
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def actual_content_issues(path, checks)
|
|
63
|
+
issues = []
|
|
64
|
+
min_bytes = checks["min_bytes"]
|
|
65
|
+
required_header = checks["required_header"]
|
|
66
|
+
issues << "below_min_bytes(#{File.size(path)} < #{min_bytes})" if min_bytes && File.size(path) < Integer(min_bytes)
|
|
67
|
+
if required_header
|
|
68
|
+
head = File.read(path, [required_header.to_s.length + 50, 200].min)
|
|
69
|
+
issues << "missing_required_header(#{required_header.inspect})" unless head.include?(required_header.to_s)
|
|
70
|
+
end
|
|
71
|
+
issues
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def validate_registered_outputs(root, workflow, state, step, registry_by_path)
|
|
75
|
+
checks = workflow.dig("artifact_registry", "content_checks") || {}
|
|
76
|
+
required_output_specs(step).map do |spec|
|
|
77
|
+
relative = interpolate_path(spec["path"], state)
|
|
78
|
+
errors = []
|
|
79
|
+
errors << "required output path missing from workflow" if relative.empty?
|
|
80
|
+
registry = registry_by_path[relative]
|
|
81
|
+
errors << "artifact registry entry missing" unless registry
|
|
82
|
+
|
|
83
|
+
absolute = nil
|
|
84
|
+
begin
|
|
85
|
+
absolute, = DevflowControlIO.resolve_control_path(root, relative, :artifact) unless relative.empty?
|
|
86
|
+
rescue DevflowControlIO::InvalidControlPath => error
|
|
87
|
+
errors << error.message
|
|
88
|
+
end
|
|
89
|
+
errors << "artifact file missing" unless absolute && File.file?(absolute)
|
|
90
|
+
|
|
91
|
+
if registry
|
|
92
|
+
errors << "registry workflow_step mismatch" unless registry["workflow_step"].to_s == step["id"].to_s
|
|
93
|
+
errors << "registry type mismatch" if spec["type"] && registry["type"].to_s != spec["type"].to_s
|
|
94
|
+
errors << "registry exists metadata is not true" unless registry["exists"] == true
|
|
95
|
+
errors << "registry content_valid metadata is not true" unless registry["content_valid"] == true
|
|
96
|
+
errors << "registry reports content issues" unless Array(registry["content_issues"]).empty?
|
|
97
|
+
end
|
|
98
|
+
errors.concat(actual_content_issues(absolute, checks)) if absolute && File.file?(absolute)
|
|
99
|
+
|
|
100
|
+
{
|
|
101
|
+
"path" => relative,
|
|
102
|
+
"workflow_step" => step["id"],
|
|
103
|
+
"registry_status" => registry && registry["status"],
|
|
104
|
+
"exists" => absolute && File.file?(absolute),
|
|
105
|
+
"sha256" => absolute && File.file?(absolute) ? Digest::SHA256.file(absolute).hexdigest : nil,
|
|
106
|
+
"valid" => errors.empty?,
|
|
107
|
+
"errors" => errors,
|
|
108
|
+
}.compact
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def generate_machine_evidence(root, feature, state_revision, branch, expected_paths)
|
|
113
|
+
script = File.join(__dir__, "machine_evidence.rb")
|
|
114
|
+
stdout, stderr, status = Open3.capture3(
|
|
115
|
+
"ruby", script,
|
|
116
|
+
"--feature", feature,
|
|
117
|
+
"--step", branch.fetch("workflow_step").to_s,
|
|
118
|
+
"--parallel-branch-id", branch.fetch("branch_id").to_s,
|
|
119
|
+
"--parallel-attempt", Integer(branch.fetch("attempt", 0)).to_s,
|
|
120
|
+
"--parallel-branch-revision", Integer(branch.fetch("branch_revision", 0)).to_s,
|
|
121
|
+
"--lock-held", "--write", "--json",
|
|
122
|
+
chdir: root,
|
|
123
|
+
)
|
|
124
|
+
payload = JSON.parse(stdout)
|
|
125
|
+
raise ArgumentError, (stderr.strip.empty? ? payload["message"] || "machine evidence failed" : stderr.strip) unless status.success? && payload.is_a?(Hash) && payload["ok"] == true
|
|
126
|
+
|
|
127
|
+
evidence_path = payload.fetch("path")
|
|
128
|
+
absolute, = DevflowControlIO.resolve_control_path(root, evidence_path, :artifact)
|
|
129
|
+
pack = JSON.parse(File.read(absolute))
|
|
130
|
+
binding = pack.dig("evidence", "parallel_binding")
|
|
131
|
+
expected_binding = {
|
|
132
|
+
"branch_id" => branch.fetch("branch_id").to_s,
|
|
133
|
+
"attempt" => Integer(branch.fetch("attempt", 0)),
|
|
134
|
+
"branch_revision" => Integer(branch.fetch("branch_revision", 0)),
|
|
135
|
+
"state_revision" => state_revision,
|
|
136
|
+
}
|
|
137
|
+
errors = []
|
|
138
|
+
errors << "machine evidence pack is incomplete" unless payload["complete"] == true && pack["complete"] == true
|
|
139
|
+
errors << "machine evidence origin is not Core" unless pack["origin"] == "core_machine"
|
|
140
|
+
errors << "machine evidence feature mismatch" unless pack["feature"].to_s == feature.to_s
|
|
141
|
+
errors << "machine evidence step mismatch" unless pack["step"].to_s == branch["workflow_step"].to_s
|
|
142
|
+
errors << "machine evidence binding mismatch" unless binding.is_a?(Hash) && expected_binding.all? { |key, value| binding[key].to_s == value.to_s }
|
|
143
|
+
packed_paths = Array(pack["files"]).select { |item| item.is_a?(Hash) && item["required"] == true }.map { |item| item["path"].to_s }.sort
|
|
144
|
+
errors << "machine evidence required outputs mismatch" unless packed_paths == expected_paths.sort
|
|
145
|
+
errors << "machine evidence contains a missing required file" unless Array(pack["files"]).all? { |item| item.is_a?(Hash) && item["exists"] == true && item["sha256"].to_s.match?(/\A[0-9a-f]{64}\z/) }
|
|
146
|
+
raise ArgumentError, errors.join("; ") unless errors.empty?
|
|
147
|
+
|
|
148
|
+
output_fingerprint = Digest::SHA256.hexdigest(
|
|
149
|
+
Array(pack["files"]).map { |item| [item["path"].to_s, item["sha256"].to_s] }.sort.to_json,
|
|
150
|
+
)
|
|
151
|
+
{
|
|
152
|
+
"evidence_path" => evidence_path,
|
|
153
|
+
"evidence_sha256" => Digest::SHA256.file(absolute).hexdigest,
|
|
154
|
+
"output_fingerprint" => output_fingerprint,
|
|
155
|
+
"checkers" => Array(pack["checkers"]).map { |item| item.slice("id", "passed", "exit_code") },
|
|
156
|
+
}
|
|
157
|
+
rescue JSON::ParserError => error
|
|
158
|
+
raise ArgumentError, "machine evidence is not valid JSON: #{error.message}"
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def audit_path(root, feature)
|
|
162
|
+
DevflowControlIO.resolve_control_path(root, ".planning/evidence/#{feature}/parallel-recovery.jsonl", :artifact).first
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def append_audit(root, feature, payload, lock: nil)
|
|
166
|
+
owned_lock = lock.nil?
|
|
167
|
+
lock ||= DevflowControlIO.acquire_project_lock(root)
|
|
168
|
+
DevflowControlIO.append_audit(audit_path(root, feature), payload.merge("timestamp" => Time.now.utc.iso8601))
|
|
169
|
+
ensure
|
|
170
|
+
DevflowControlIO.release_lock(lock) if owned_lock
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def recovery_id(run, expected_state_revision, expected_run_revision)
|
|
174
|
+
material = [run["run_id"], expected_state_revision, expected_run_revision].join(":")
|
|
175
|
+
"REC-#{Digest::SHA256.hexdigest(material)[0, 20]}"
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def prior_validation(root, feature, recovery, run)
|
|
179
|
+
path = audit_path(root, feature)
|
|
180
|
+
return nil unless File.file?(path)
|
|
181
|
+
|
|
182
|
+
events = File.foreach(path).each_with_object([]) do |line, parsed|
|
|
183
|
+
next if line.strip.empty?
|
|
184
|
+
begin
|
|
185
|
+
parsed << JSON.parse(line)
|
|
186
|
+
rescue JSON::ParserError
|
|
187
|
+
# A malformed historical line cannot authorize a resumed recovery.
|
|
188
|
+
end
|
|
189
|
+
end
|
|
190
|
+
validated = events.reverse.find do |event|
|
|
191
|
+
event["origin"] == "core.parallel_recovery/v1" && event["event"] == "validated" &&
|
|
192
|
+
event["recovery_id"] == recovery && event["run_id"].to_s == run["run_id"].to_s
|
|
193
|
+
end
|
|
194
|
+
return nil unless validated
|
|
195
|
+
|
|
196
|
+
logged = Array(validated["branches"]).to_h { |branch| [branch["branch_id"].to_s, branch] }
|
|
197
|
+
Array(run["branches"]).each do |branch|
|
|
198
|
+
next unless branch["status"] == "succeeded" && logged.key?(branch["branch_id"].to_s)
|
|
199
|
+
|
|
200
|
+
machine = logged.fetch(branch["branch_id"].to_s)["machine_evidence"] || {}
|
|
201
|
+
return nil unless branch["evidence_path"].to_s == machine["evidence_path"].to_s
|
|
202
|
+
return nil unless branch["output_fingerprint"].to_s == machine["output_fingerprint"].to_s
|
|
203
|
+
end
|
|
204
|
+
validated
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def parse_json_result(stdout, stderr, status)
|
|
208
|
+
payload = JSON.parse(stdout)
|
|
209
|
+
return payload if payload.is_a?(Hash)
|
|
210
|
+
|
|
211
|
+
{ "ok" => false, "reason" => "join_returned_invalid_payload", "message" => stderr.strip, "exit_status" => status.exitstatus }
|
|
212
|
+
rescue JSON::ParserError => error
|
|
213
|
+
{ "ok" => false, "reason" => "join_returned_invalid_json", "message" => error.message, "stderr" => stderr.strip, "exit_status" => status.exitstatus }
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
root = Dir.pwd
|
|
217
|
+
feature = options[:feature].to_s
|
|
218
|
+
result = nil
|
|
219
|
+
exit_status = 0
|
|
220
|
+
|
|
221
|
+
begin
|
|
222
|
+
raise ArgumentError, "--feature is required" if feature.empty?
|
|
223
|
+
ParallelRun.safe_feature(feature)
|
|
224
|
+
raise ArgumentError, "--expected-state-revision is required" if options[:expected_state_revision].nil?
|
|
225
|
+
raise ArgumentError, "--expected-run-revision is required" if options[:expected_run_revision].nil?
|
|
226
|
+
raise ArgumentError, "--write is required" unless options[:write]
|
|
227
|
+
raise ArgumentError, "--reason must not be empty" if options[:reason].to_s.strip.empty?
|
|
228
|
+
|
|
229
|
+
state_revision = nil
|
|
230
|
+
current_run_revision = nil
|
|
231
|
+
recovery = nil
|
|
232
|
+
recovered_branches = []
|
|
233
|
+
validation_records = []
|
|
234
|
+
project_lock = DevflowControlIO.acquire_project_lock(root)
|
|
235
|
+
begin
|
|
236
|
+
state_path, = DevflowControlIO.resolve_control_path(root, ".planning/STATE.md", :state)
|
|
237
|
+
state_yaml = front_matter(File.read(state_path))
|
|
238
|
+
raise ArgumentError, "STATE.md has no YAML front matter" unless state_yaml
|
|
239
|
+
state = YAML.safe_load(state_yaml, permitted_classes: [Date, Time], aliases: false)
|
|
240
|
+
raise ArgumentError, "STATE.md must contain a mapping" unless state.is_a?(Hash)
|
|
241
|
+
raise ArgumentError, "feature is not active: requested=#{feature}, current=#{state['current_feature']}" unless state["current_feature"].to_s == feature
|
|
242
|
+
|
|
243
|
+
run = ParallelRun.load(root, feature)
|
|
244
|
+
raise ArgumentError, "parallel run not found" unless run
|
|
245
|
+
recovery = recovery_id(run, options[:expected_state_revision], options[:expected_run_revision])
|
|
246
|
+
branches = Array(run["branches"])
|
|
247
|
+
already_joined = state["parallel_run_ref"].nil? && run["status"] == "completed" && branches.any? && branches.all? { |branch| branch["status"] == "succeeded" }
|
|
248
|
+
if already_joined
|
|
249
|
+
append_audit(root, feature, {
|
|
250
|
+
"schema_version" => 1, "origin" => "core.parallel_recovery/v1", "event" => "idempotent_noop",
|
|
251
|
+
"recovery_id" => recovery, "reason" => options[:reason], "run_id" => run["run_id"],
|
|
252
|
+
"state_revision" => state["revision"], "run_revision" => run["run_revision"],
|
|
253
|
+
}, lock: project_lock)
|
|
254
|
+
result = {
|
|
255
|
+
"ok" => true, "written" => false, "idempotent" => true, "recovery_id" => recovery,
|
|
256
|
+
"actions" => ["parallel_recovery_already_joined"], "state_revision" => state["revision"],
|
|
257
|
+
"run_revision" => run["run_revision"], "audit_path" => audit_path(root, feature).sub("#{root}/", ""),
|
|
258
|
+
}
|
|
259
|
+
else
|
|
260
|
+
state_revision = Integer(state.fetch("revision", 0))
|
|
261
|
+
current_run_revision = Integer(run.fetch("run_revision", 0))
|
|
262
|
+
raise ArgumentError, "stale STATE revision: expected=#{options[:expected_state_revision]}, actual=#{state_revision}" unless state_revision == options[:expected_state_revision]
|
|
263
|
+
raise ArgumentError, "STATE parallel_run_ref does not match run" unless state["parallel_run_ref"].to_s == run["run_id"].to_s
|
|
264
|
+
raise ArgumentError, "parallel run is bound to stale STATE revision" unless Integer(run.fetch("bound_state_revision")) == state_revision
|
|
265
|
+
raise ArgumentError, "parallel run has no branches" if branches.empty?
|
|
266
|
+
ineligible = branches.select { |branch| %w[failed aborted].include?(branch["status"].to_s) }
|
|
267
|
+
raise ArgumentError, "failed/aborted branches require explicit retry: #{ineligible.map { |branch| branch['branch_id'] }.join(', ')}" unless ineligible.empty?
|
|
268
|
+
previous_validation = prior_validation(root, feature, recovery, run)
|
|
269
|
+
unless current_run_revision == options[:expected_run_revision] || previous_validation
|
|
270
|
+
raise ArgumentError, "stale parallel run revision: expected=#{options[:expected_run_revision]}, actual=#{current_run_revision}"
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
workflow_path, = DevflowControlIO.resolve_control_path(root, state.fetch("workflow_config"), :workflow)
|
|
274
|
+
workflow = load_yaml_mapping(workflow_path, "workflow")
|
|
275
|
+
registry_source = workflow.dig("artifact_registry", "source").to_s
|
|
276
|
+
raise ArgumentError, "workflow artifact_registry.source is required for recovery" if registry_source.empty?
|
|
277
|
+
registry_path, = DevflowControlIO.resolve_control_path(root, registry_source, :registry)
|
|
278
|
+
registry = load_yaml_mapping(registry_path, "artifact registry")
|
|
279
|
+
raise ArgumentError, "artifact registry revision mismatch" unless Integer(registry.fetch("state_revision")) == state_revision
|
|
280
|
+
raise ArgumentError, "artifact registry feature mismatch" unless registry.dig("registry_scope", "feature").to_s == feature
|
|
281
|
+
registry_by_path = Array(registry["artifacts"]).each_with_object({}) do |artifact, index|
|
|
282
|
+
raise ArgumentError, "artifact registry entry must be a mapping" unless artifact.is_a?(Hash)
|
|
283
|
+
path = artifact["path"].to_s
|
|
284
|
+
raise ArgumentError, "artifact registry contains duplicate path: #{path}" if index.key?(path)
|
|
285
|
+
index[path] = artifact
|
|
286
|
+
end
|
|
287
|
+
steps = Array(workflow["steps"]).each_with_object({}) { |step, index| index[step["id"].to_s] = step if step.is_a?(Hash) }
|
|
288
|
+
|
|
289
|
+
candidates = branches.select { |branch| %w[pending running].include?(branch["status"].to_s) }
|
|
290
|
+
candidates.each do |branch|
|
|
291
|
+
step = steps[branch["workflow_step"].to_s]
|
|
292
|
+
raise ArgumentError, "parallel branch workflow step is unknown: #{branch['workflow_step']}" unless step
|
|
293
|
+
artifacts = validate_registered_outputs(root, workflow, state, step, registry_by_path)
|
|
294
|
+
invalid = artifacts.reject { |artifact| artifact["valid"] }
|
|
295
|
+
raise ArgumentError, "branch #{branch['branch_id']} artifact validation failed: #{invalid.to_json}" unless invalid.empty?
|
|
296
|
+
machine = generate_machine_evidence(root, feature, state_revision, branch, artifacts.map { |artifact| artifact["path"] })
|
|
297
|
+
validation_records << {
|
|
298
|
+
"branch_id" => branch["branch_id"], "workflow_step" => branch["workflow_step"],
|
|
299
|
+
"attempt" => branch.fetch("attempt", 0), "branch_revision" => branch.fetch("branch_revision", 0),
|
|
300
|
+
"artifacts" => artifacts, "machine_evidence" => machine,
|
|
301
|
+
}
|
|
302
|
+
end
|
|
303
|
+
|
|
304
|
+
append_audit(root, feature, {
|
|
305
|
+
"schema_version" => 1, "origin" => "core.parallel_recovery/v1", "event" => previous_validation ? "validated_resume" : "validated",
|
|
306
|
+
"recovery_id" => recovery, "reason" => options[:reason], "run_id" => run["run_id"],
|
|
307
|
+
"state_revision" => state_revision, "run_revision" => current_run_revision,
|
|
308
|
+
"branches" => validation_records,
|
|
309
|
+
}, lock: project_lock)
|
|
310
|
+
|
|
311
|
+
validation_records.each do |validated|
|
|
312
|
+
machine = validated.fetch("machine_evidence")
|
|
313
|
+
updated = ParallelRun.update_branch(
|
|
314
|
+
root: root, feature: feature, branch_id: validated.fetch("branch_id"),
|
|
315
|
+
expected_revision: current_run_revision, status: "succeeded",
|
|
316
|
+
evidence_path: machine.fetch("evidence_path"), output_fingerprint: machine.fetch("output_fingerprint"),
|
|
317
|
+
lock: project_lock,
|
|
318
|
+
)
|
|
319
|
+
current_run_revision = Integer(updated.fetch("run").fetch("run_revision"))
|
|
320
|
+
recovered_branches << validated.fetch("branch_id")
|
|
321
|
+
end
|
|
322
|
+
final_run = ParallelRun.load(root, feature)
|
|
323
|
+
unless Array(final_run["branches"]).all? { |branch| branch["status"] == "succeeded" }
|
|
324
|
+
raise ArgumentError, "recovery did not produce an all-succeeded run"
|
|
325
|
+
end
|
|
326
|
+
append_audit(root, feature, {
|
|
327
|
+
"schema_version" => 1, "origin" => "core.parallel_recovery/v1", "event" => "branches_recovered",
|
|
328
|
+
"recovery_id" => recovery, "reason" => options[:reason], "run_id" => final_run["run_id"],
|
|
329
|
+
"state_revision" => state_revision, "run_revision" => final_run["run_revision"],
|
|
330
|
+
"recovered_branch_ids" => recovered_branches,
|
|
331
|
+
}, lock: project_lock)
|
|
332
|
+
end
|
|
333
|
+
ensure
|
|
334
|
+
DevflowControlIO.release_lock(project_lock)
|
|
335
|
+
end
|
|
336
|
+
|
|
337
|
+
unless result
|
|
338
|
+
advance = File.join(__dir__, "advance_state.rb")
|
|
339
|
+
stdout, stderr, status = Open3.capture3(
|
|
340
|
+
"ruby", advance, "--write", "--parallel-action", "join",
|
|
341
|
+
"--expected-revision", state_revision.to_s, "--json", chdir: root,
|
|
342
|
+
)
|
|
343
|
+
joined = parse_json_result(stdout, stderr, status)
|
|
344
|
+
if status.success? && joined["ok"] == true
|
|
345
|
+
joined_run = ParallelRun.load(root, feature)
|
|
346
|
+
append_audit(root, feature, {
|
|
347
|
+
"schema_version" => 1, "origin" => "core.parallel_recovery/v1", "event" => "joined",
|
|
348
|
+
"recovery_id" => recovery, "reason" => options[:reason], "run_id" => joined_run && joined_run["run_id"],
|
|
349
|
+
"state_revision_before" => state_revision, "state_revision_after" => joined.dig("state", "revision"),
|
|
350
|
+
"run_revision" => joined_run && joined_run["run_revision"], "recovered_branch_ids" => recovered_branches,
|
|
351
|
+
})
|
|
352
|
+
result = {
|
|
353
|
+
"ok" => true, "written" => true, "idempotent" => false, "recovery_id" => recovery,
|
|
354
|
+
"actions" => ["parallel_branches_recovered", "parallel_window_joined"],
|
|
355
|
+
"recovered_branch_ids" => recovered_branches, "state" => joined["state"], "run" => joined_run,
|
|
356
|
+
"audit_path" => audit_path(root, feature).sub("#{root}/", ""),
|
|
357
|
+
}
|
|
358
|
+
else
|
|
359
|
+
append_audit(root, feature, {
|
|
360
|
+
"schema_version" => 1, "origin" => "core.parallel_recovery/v1", "event" => "join_refused",
|
|
361
|
+
"recovery_id" => recovery, "reason" => options[:reason], "state_revision" => state_revision,
|
|
362
|
+
"run_revision" => current_run_revision, "join_result" => joined,
|
|
363
|
+
})
|
|
364
|
+
result = { "ok" => false, "reason" => "controlled_join_failed", "recovery_id" => recovery, "details" => joined }
|
|
365
|
+
exit_status = 1
|
|
366
|
+
end
|
|
367
|
+
end
|
|
368
|
+
rescue StandardError => error
|
|
369
|
+
exit_status = 1
|
|
370
|
+
begin
|
|
371
|
+
run = feature.empty? ? nil : ParallelRun.load(root, feature)
|
|
372
|
+
recovery ||= recovery_id(run, options[:expected_state_revision], options[:expected_run_revision]) if run
|
|
373
|
+
append_audit(root, feature, {
|
|
374
|
+
"schema_version" => 1, "origin" => "core.parallel_recovery/v1", "event" => "refused",
|
|
375
|
+
"recovery_id" => recovery, "reason" => options[:reason], "error_class" => error.class.name,
|
|
376
|
+
"message" => error.message, "expected_state_revision" => options[:expected_state_revision],
|
|
377
|
+
"expected_run_revision" => options[:expected_run_revision],
|
|
378
|
+
}) unless feature.empty?
|
|
379
|
+
rescue StandardError
|
|
380
|
+
# Preserve the original refusal when even the audit path is unavailable.
|
|
381
|
+
end
|
|
382
|
+
result = { "ok" => false, "reason" => "parallel_recovery_refused", "message" => error.message, "recovery_id" => recovery }
|
|
383
|
+
end
|
|
384
|
+
|
|
385
|
+
puts(options[:json] ? JSON.generate(result) : JSON.pretty_generate(result))
|
|
386
|
+
exit exit_status
|
|
@@ -48,6 +48,28 @@ module ParallelRun
|
|
|
48
48
|
record
|
|
49
49
|
end
|
|
50
50
|
|
|
51
|
+
# STATE.md is the source of truth for the workflow revision. A parallel
|
|
52
|
+
# window remains valid across ordinary state writes, so its optimistic
|
|
53
|
+
# revision bindings must move forward atomically with STATE. Callers must
|
|
54
|
+
# hold the project lock when passing one; the helper acquires it otherwise.
|
|
55
|
+
def rebase_state_revision(root:, feature:, state_revision:, lock: nil)
|
|
56
|
+
owned_lock = lock.nil?
|
|
57
|
+
lock ||= DevflowControlIO.acquire_project_lock(root)
|
|
58
|
+
begin
|
|
59
|
+
record = load(root, feature)
|
|
60
|
+
return nil unless record
|
|
61
|
+
|
|
62
|
+
revision = Integer(state_revision)
|
|
63
|
+
record["bound_state_revision"] = revision
|
|
64
|
+
Array(record["branches"]).each { |branch| branch["expected_state_revision"] = revision }
|
|
65
|
+
record["updated_at"] = Time.now.utc.iso8601
|
|
66
|
+
write(root, feature, record)
|
|
67
|
+
record
|
|
68
|
+
ensure
|
|
69
|
+
DevflowControlIO.release_lock(lock) if owned_lock
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
51
73
|
def state_front_matter(markdown)
|
|
52
74
|
match = markdown.match(/\A---\n(.*?)\n---\n/m)
|
|
53
75
|
match && match[1]
|
|
@@ -184,7 +206,7 @@ if $PROGRAM_NAME == __FILE__
|
|
|
184
206
|
begin
|
|
185
207
|
options = { action: nil, feature: nil, state_revision: nil, run_revision: nil, branch_id: nil, status: nil, workflow_window: nil, branches: [], state_path: ".planning/STATE.md", json: false }
|
|
186
208
|
OptionParser.new do |opts|
|
|
187
|
-
opts.on("--action ACTION", %w[inspect prepare update retry]) { |value| options[:action] = value }
|
|
209
|
+
opts.on("--action ACTION", %w[inspect prepare update retry rebase]) { |value| options[:action] = value }
|
|
188
210
|
opts.on("--feature FEATURE") { |value| options[:feature] = value }
|
|
189
211
|
opts.on("--state-revision REV", Integer) { |value| options[:state_revision] = value }
|
|
190
212
|
opts.on("--run-revision REV", Integer) { |value| options[:run_revision] = value }
|
|
@@ -211,8 +233,12 @@ if $PROGRAM_NAME == __FILE__
|
|
|
211
233
|
ParallelRun.update_branch(root: Dir.pwd, feature: options[:feature], branch_id: options[:branch_id], expected_revision: options[:run_revision], status: options[:status])
|
|
212
234
|
when "retry"
|
|
213
235
|
ParallelRun.retry_branch(root: Dir.pwd, feature: options[:feature], branch_id: options[:branch_id], expected_revision: options[:run_revision])
|
|
236
|
+
when "rebase"
|
|
237
|
+
raise ArgumentError, "--state-revision is required" if options[:state_revision].nil?
|
|
238
|
+
ParallelRun.rebase_state_revision(root: Dir.pwd, feature: options[:feature], state_revision: options[:state_revision])
|
|
239
|
+
.then { |run| run ? { "ok" => true, "run" => run } : { "ok" => false, "reason" => "parallel_run_not_found" } }
|
|
214
240
|
else
|
|
215
|
-
raise ArgumentError, "--action must be inspect, prepare, update, or
|
|
241
|
+
raise ArgumentError, "--action must be inspect, prepare, update, retry, or rebase"
|
|
216
242
|
end
|
|
217
243
|
puts(options[:json] ? JSON.generate(result) : YAML.dump(result))
|
|
218
244
|
rescue StandardError => e
|