@guanlin4924/devflow-pi 0.1.39 → 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 +728 -39
- 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 +334 -30
- 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 -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 +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 +84 -6
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/inspect_state.rb +3 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/machine_evidence.rb +45 -12
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/parallel_recover.rb +386 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/parallel_run.rb +248 -0
- 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 +86 -2
- 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/scripts/workflow_gate_contract.rb +94 -2
- package/plugins/devflow-core/skills/workflow-orchestrator/templates/feature-development.yaml +111 -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 +61 -24
|
@@ -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
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "date"
|
|
5
|
+
require "digest"
|
|
6
|
+
require "fileutils"
|
|
7
|
+
require "json"
|
|
8
|
+
require "optparse"
|
|
9
|
+
require "securerandom"
|
|
10
|
+
require "time"
|
|
11
|
+
require "yaml"
|
|
12
|
+
require_relative "control_io"
|
|
13
|
+
|
|
14
|
+
# Durable scheduler state for one workflow parallel window. This file owns
|
|
15
|
+
# dispatch state only; artifacts/index.yaml owns artifact content state and
|
|
16
|
+
# STATE.md owns the workflow snapshot/current pointer.
|
|
17
|
+
module ParallelRun
|
|
18
|
+
SCHEMA_VERSION = 1
|
|
19
|
+
TERMINAL_BRANCH_STATUSES = %w[succeeded failed aborted].freeze
|
|
20
|
+
|
|
21
|
+
module_function
|
|
22
|
+
|
|
23
|
+
def safe_feature(feature)
|
|
24
|
+
value = feature.to_s
|
|
25
|
+
raise ArgumentError, "feature is required" if value.empty?
|
|
26
|
+
raise ArgumentError, "feature contains path separators" if value.include?("/") || value.include?("\\") || value == "." || value == ".."
|
|
27
|
+
|
|
28
|
+
value
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def path(root, feature)
|
|
32
|
+
File.join(root, ".planning", "parallel", safe_feature(feature), "branches.yaml")
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def load(root, feature)
|
|
36
|
+
target = path(root, feature)
|
|
37
|
+
return nil unless File.file?(target)
|
|
38
|
+
|
|
39
|
+
value = YAML.safe_load(File.read(target), permitted_classes: [Date, Time], aliases: false)
|
|
40
|
+
raise ArgumentError, "parallel run record must be a mapping" unless value.is_a?(Hash)
|
|
41
|
+
value
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def write(root, feature, record)
|
|
45
|
+
raise ArgumentError, "parallel run record must be a mapping" unless record.is_a?(Hash)
|
|
46
|
+
|
|
47
|
+
DevflowControlIO.atomic_write(path(root, feature), record.to_yaml)
|
|
48
|
+
record
|
|
49
|
+
end
|
|
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
|
+
|
|
73
|
+
def state_front_matter(markdown)
|
|
74
|
+
match = markdown.match(/\A---\n(.*?)\n---\n/m)
|
|
75
|
+
match && match[1]
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def attach_state_ref(root, state_path, run_id, expected_revision)
|
|
79
|
+
absolute, = DevflowControlIO.resolve_control_path(root, state_path, :state)
|
|
80
|
+
original = File.read(absolute)
|
|
81
|
+
front = state_front_matter(original)
|
|
82
|
+
raise ArgumentError, "STATE.md has no YAML front matter" unless front
|
|
83
|
+
|
|
84
|
+
state = YAML.safe_load(front, permitted_classes: [], aliases: false)
|
|
85
|
+
raise ArgumentError, "STATE.md must contain a mapping" unless state.is_a?(Hash)
|
|
86
|
+
actual = Integer(state.fetch("revision", 0))
|
|
87
|
+
raise ArgumentError, "STATE revision conflict" unless actual == Integer(expected_revision)
|
|
88
|
+
raise ArgumentError, "STATE already references a parallel run" if state["parallel_run_ref"]
|
|
89
|
+
|
|
90
|
+
state["parallel_run_ref"] = run_id
|
|
91
|
+
state["revision"] = actual + 1
|
|
92
|
+
body = original.sub(/\A---\n.*?\n---\n/m, "---\n#{state.to_yaml.sub(/\A---\n/, "")}---\n")
|
|
93
|
+
DevflowControlIO.atomic_write(absolute, body)
|
|
94
|
+
state
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def prepare(root:, feature:, workflow_window:, state_revision:, branches:, task_ids: {}, state_path: ".planning/STATE.md")
|
|
98
|
+
lock = DevflowControlIO.acquire_project_lock(root)
|
|
99
|
+
begin
|
|
100
|
+
raise ArgumentError, "parallel run already exists" if load(root, feature)
|
|
101
|
+
|
|
102
|
+
run_id = "PR-#{SecureRandom.uuid}"
|
|
103
|
+
branch_records = Array(branches).map do |branch|
|
|
104
|
+
value = branch.is_a?(Hash) ? branch : { "branch_id" => branch }
|
|
105
|
+
workflow_step = value["workflow_step"].to_s
|
|
106
|
+
track = value["track"].to_s
|
|
107
|
+
raise ArgumentError, "workflow_step is required" if workflow_step.empty?
|
|
108
|
+
raise ArgumentError, "track is required" if track.empty?
|
|
109
|
+
branch_id = value["branch_id"].to_s
|
|
110
|
+
branch_id = "#{run_id}-#{track.sub(/\Atrack_/, "")}-#{workflow_step}" if branch_id.empty?
|
|
111
|
+
|
|
112
|
+
value.merge(
|
|
113
|
+
"branch_id" => branch_id,
|
|
114
|
+
"workflow_step" => workflow_step,
|
|
115
|
+
"track" => track,
|
|
116
|
+
"attempt" => 0,
|
|
117
|
+
"branch_revision" => 0,
|
|
118
|
+
"status" => "pending",
|
|
119
|
+
"task_id" => task_ids[branch_id],
|
|
120
|
+
"previous_branch_id" => nil,
|
|
121
|
+
"history" => [],
|
|
122
|
+
).compact
|
|
123
|
+
end
|
|
124
|
+
record = {
|
|
125
|
+
"schema_version" => SCHEMA_VERSION,
|
|
126
|
+
"run_id" => run_id,
|
|
127
|
+
"workflow_window" => workflow_window,
|
|
128
|
+
"bound_state_revision" => Integer(state_revision),
|
|
129
|
+
"run_revision" => 0,
|
|
130
|
+
"status" => "pending",
|
|
131
|
+
"created_at" => Time.now.utc.iso8601,
|
|
132
|
+
"branches" => branch_records,
|
|
133
|
+
}
|
|
134
|
+
write(root, feature, record)
|
|
135
|
+
updated_state = attach_state_ref(root, state_path, run_id, state_revision)
|
|
136
|
+
record["bound_state_revision"] = Integer(updated_state.fetch("revision"))
|
|
137
|
+
record["branches"].each { |branch| branch["expected_state_revision"] = record["bound_state_revision"] }
|
|
138
|
+
write(root, feature, record)
|
|
139
|
+
{ "ok" => true, "run" => record }
|
|
140
|
+
ensure
|
|
141
|
+
DevflowControlIO.release_lock(lock)
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def update_branch(root:, feature:, branch_id:, expected_revision:, status:, task_id: nil, evidence_path: nil, output_fingerprint: nil, error: nil, lock: nil)
|
|
146
|
+
owned_lock = lock.nil?
|
|
147
|
+
lock ||= DevflowControlIO.acquire_project_lock(root)
|
|
148
|
+
begin
|
|
149
|
+
record = load(root, feature) || raise(ArgumentError, "parallel run not found")
|
|
150
|
+
actual = Integer(record.fetch("run_revision", 0))
|
|
151
|
+
raise ArgumentError, "parallel run revision conflict" unless actual == Integer(expected_revision)
|
|
152
|
+
|
|
153
|
+
branch = Array(record["branches"]).find { |item| item["branch_id"].to_s == branch_id.to_s }
|
|
154
|
+
raise ArgumentError, "parallel branch not found: #{branch_id}" unless branch
|
|
155
|
+
raise ArgumentError, "invalid branch status: #{status}" unless (TERMINAL_BRANCH_STATUSES + %w[pending running]).include?(status)
|
|
156
|
+
raise ArgumentError, "terminal branch must retry before update" if TERMINAL_BRANCH_STATUSES.include?(branch["status"])
|
|
157
|
+
|
|
158
|
+
branch["status"] = status
|
|
159
|
+
branch["branch_revision"] = Integer(branch.fetch("branch_revision", 0)) + 1
|
|
160
|
+
branch["task_id"] = task_id if task_id
|
|
161
|
+
branch["evidence_path"] = evidence_path if evidence_path
|
|
162
|
+
branch["output_fingerprint"] = output_fingerprint if output_fingerprint
|
|
163
|
+
branch["error"] = error if error
|
|
164
|
+
record["run_revision"] = actual + 1
|
|
165
|
+
record["status"] = "failed" if status == "failed"
|
|
166
|
+
record["status"] = "running" if status == "running" && record["status"] == "pending"
|
|
167
|
+
record["status"] = "completed" if Array(record["branches"]).all? { |item| item["status"] == "succeeded" }
|
|
168
|
+
record["updated_at"] = Time.now.utc.iso8601
|
|
169
|
+
write(root, feature, record)
|
|
170
|
+
{ "ok" => true, "run" => record }
|
|
171
|
+
ensure
|
|
172
|
+
DevflowControlIO.release_lock(lock) if owned_lock
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def retry_branch(root:, feature:, branch_id:, expected_revision:, lock: nil)
|
|
177
|
+
owned_lock = lock.nil?
|
|
178
|
+
lock ||= DevflowControlIO.acquire_project_lock(root)
|
|
179
|
+
begin
|
|
180
|
+
record = load(root, feature) || raise(ArgumentError, "parallel run not found")
|
|
181
|
+
raise ArgumentError, "parallel run revision conflict" unless Integer(record.fetch("run_revision", 0)) == Integer(expected_revision)
|
|
182
|
+
branch = Array(record["branches"]).find { |item| item["branch_id"].to_s == branch_id.to_s }
|
|
183
|
+
raise ArgumentError, "parallel branch not found: #{branch_id}" unless branch
|
|
184
|
+
raise ArgumentError, "only failed/aborted branch can retry" unless %w[failed aborted].include?(branch["status"])
|
|
185
|
+
|
|
186
|
+
previous_id = branch["branch_id"]
|
|
187
|
+
branch["history"] ||= []
|
|
188
|
+
branch["history"] << branch.slice("branch_id", "attempt", "branch_revision", "status", "task_id", "evidence_path")
|
|
189
|
+
branch["attempt"] = Integer(branch.fetch("attempt", 0)) + 1
|
|
190
|
+
branch["branch_revision"] = 0
|
|
191
|
+
branch["previous_branch_id"] = previous_id
|
|
192
|
+
branch["status"] = "pending"
|
|
193
|
+
branch.delete("error")
|
|
194
|
+
record["status"] = "running"
|
|
195
|
+
record["run_revision"] = Integer(record.fetch("run_revision", 0)) + 1
|
|
196
|
+
record["updated_at"] = Time.now.utc.iso8601
|
|
197
|
+
write(root, feature, record)
|
|
198
|
+
{ "ok" => true, "run" => record }
|
|
199
|
+
ensure
|
|
200
|
+
DevflowControlIO.release_lock(lock) if owned_lock
|
|
201
|
+
end
|
|
202
|
+
end
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
if $PROGRAM_NAME == __FILE__
|
|
206
|
+
begin
|
|
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 }
|
|
208
|
+
OptionParser.new do |opts|
|
|
209
|
+
opts.on("--action ACTION", %w[inspect prepare update retry rebase]) { |value| options[:action] = value }
|
|
210
|
+
opts.on("--feature FEATURE") { |value| options[:feature] = value }
|
|
211
|
+
opts.on("--state-revision REV", Integer) { |value| options[:state_revision] = value }
|
|
212
|
+
opts.on("--run-revision REV", Integer) { |value| options[:run_revision] = value }
|
|
213
|
+
opts.on("--branch-id ID") { |value| options[:branch_id] = value }
|
|
214
|
+
opts.on("--status STATUS") { |value| options[:status] = value }
|
|
215
|
+
opts.on("--workflow-window NAME") { |value| options[:workflow_window] = value }
|
|
216
|
+
opts.on("--state PATH") { |value| options[:state_path] = value }
|
|
217
|
+
opts.on("--branch STEP:TRACK", "Register a branch as workflow_step:track") do |value|
|
|
218
|
+
workflow_step, track = value.split(":", 2)
|
|
219
|
+
raise ArgumentError, "--branch must be WORKFLOW_STEP:TRACK" if workflow_step.to_s.empty? || track.to_s.empty?
|
|
220
|
+
options[:branches] << { "workflow_step" => workflow_step, "track" => track }
|
|
221
|
+
end
|
|
222
|
+
opts.on("--json") { options[:json] = true }
|
|
223
|
+
end.parse!
|
|
224
|
+
|
|
225
|
+
raise ArgumentError, "--feature is required" unless options[:feature]
|
|
226
|
+
result = case options[:action]
|
|
227
|
+
when "inspect"
|
|
228
|
+
record = ParallelRun.load(Dir.pwd, options[:feature])
|
|
229
|
+
record ? { "ok" => true, "run" => record } : { "ok" => false, "reason" => "parallel_run_not_found" }
|
|
230
|
+
when "prepare"
|
|
231
|
+
ParallelRun.prepare(root: Dir.pwd, feature: options[:feature], workflow_window: options[:workflow_window], state_revision: options[:state_revision], branches: options[:branches], state_path: options[:state_path])
|
|
232
|
+
when "update"
|
|
233
|
+
ParallelRun.update_branch(root: Dir.pwd, feature: options[:feature], branch_id: options[:branch_id], expected_revision: options[:run_revision], status: options[:status])
|
|
234
|
+
when "retry"
|
|
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" } }
|
|
240
|
+
else
|
|
241
|
+
raise ArgumentError, "--action must be inspect, prepare, update, retry, or rebase"
|
|
242
|
+
end
|
|
243
|
+
puts(options[:json] ? JSON.generate(result) : YAML.dump(result))
|
|
244
|
+
rescue StandardError => e
|
|
245
|
+
warn e.message
|
|
246
|
+
exit 1
|
|
247
|
+
end
|
|
248
|
+
end
|