@guanlin4924/devflow-pi 0.1.12 → 0.1.14
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -4
- package/package.json +1 -1
- package/plugins/devflow-core/extensions/devflow-core.ts +235 -148
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/advance_state.rb +47 -15
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/control_io.rb +42 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/init_state.rb +21 -2
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/phase_ownership.rb +154 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/repair_control_plane.rb +35 -26
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/sync_artifacts.rb +18 -7
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/validate_state.rb +33 -8
|
@@ -8,6 +8,7 @@ require "optparse"
|
|
|
8
8
|
require "yaml"
|
|
9
9
|
require_relative "control_io"
|
|
10
10
|
require_relative "validate_state"
|
|
11
|
+
require_relative "phase_ownership"
|
|
11
12
|
|
|
12
13
|
options = {
|
|
13
14
|
state: nil,
|
|
@@ -35,7 +36,7 @@ OptionParser.new do |opts|
|
|
|
35
36
|
end.parse!
|
|
36
37
|
|
|
37
38
|
root = Dir.pwd
|
|
38
|
-
state_path =
|
|
39
|
+
state_path, = DevflowControlIO.resolve_control_path(root, options[:state] || ".planning/STATE.md", :state)
|
|
39
40
|
|
|
40
41
|
def front_matter(markdown)
|
|
41
42
|
match = markdown.match(/\A---\n(.*?)\n---\n/m)
|
|
@@ -51,8 +52,9 @@ def workflow_path_for(root, state)
|
|
|
51
52
|
end
|
|
52
53
|
|
|
53
54
|
def load_workflow(root, state)
|
|
54
|
-
|
|
55
|
-
|
|
55
|
+
source = workflow_path_for(root, state)
|
|
56
|
+
path, relative = DevflowControlIO.resolve_control_path(root, source, :workflow)
|
|
57
|
+
[relative, load_yaml_file(path)]
|
|
56
58
|
end
|
|
57
59
|
|
|
58
60
|
def interpolate_path(path, state)
|
|
@@ -65,7 +67,7 @@ def load_artifact_registry(root, workflow)
|
|
|
65
67
|
registry_path = workflow.dig("artifact_registry", "source")
|
|
66
68
|
return {} unless registry_path
|
|
67
69
|
|
|
68
|
-
abs =
|
|
70
|
+
abs, = DevflowControlIO.resolve_control_path(root, registry_path, :registry)
|
|
69
71
|
return {} unless File.exist?(abs)
|
|
70
72
|
|
|
71
73
|
registry = load_yaml_file(abs)
|
|
@@ -79,7 +81,7 @@ def collect_outputs(workflow)
|
|
|
79
81
|
end
|
|
80
82
|
|
|
81
83
|
def content_validity(root, path, checks)
|
|
82
|
-
abs =
|
|
84
|
+
abs, = DevflowControlIO.resolve_control_path(root, path, :artifact)
|
|
83
85
|
return { "valid" => false, "issues" => ["file_missing"] } unless File.exist?(abs)
|
|
84
86
|
|
|
85
87
|
issues = []
|
|
@@ -102,7 +104,7 @@ def build_artifact_registry(root, workflow, state)
|
|
|
102
104
|
registry_path = workflow.dig("artifact_registry", "source")
|
|
103
105
|
return [nil, nil] unless registry_path
|
|
104
106
|
|
|
105
|
-
abs =
|
|
107
|
+
abs, = DevflowControlIO.resolve_control_path(root, registry_path, :registry)
|
|
106
108
|
existing_by_path = if File.exist?(abs)
|
|
107
109
|
Array(load_yaml_file(abs)["artifacts"]).to_h { |artifact| [artifact.fetch("path"), artifact] }
|
|
108
110
|
else
|
|
@@ -127,7 +129,8 @@ def build_artifact_registry(root, workflow, state)
|
|
|
127
129
|
end
|
|
128
130
|
|
|
129
131
|
validity = content_validity(root, path, content_checks)
|
|
130
|
-
|
|
132
|
+
artifact_path, = DevflowControlIO.resolve_control_path(root, path, :artifact)
|
|
133
|
+
file_exists = File.exist?(artifact_path)
|
|
131
134
|
status = if preservable_statuses.include?(existing["status"])
|
|
132
135
|
existing["status"]
|
|
133
136
|
else
|
|
@@ -169,7 +172,7 @@ def sync_artifact_registry(root, workflow, state, registry: nil)
|
|
|
169
172
|
return nil unless registry_path
|
|
170
173
|
|
|
171
174
|
registry ||= build_artifact_registry(root, workflow, state).last
|
|
172
|
-
abs =
|
|
175
|
+
abs, = DevflowControlIO.resolve_control_path(root, registry_path, :registry)
|
|
173
176
|
DevflowControlIO.atomic_write(abs, registry.to_yaml)
|
|
174
177
|
registry_path
|
|
175
178
|
end
|
|
@@ -348,7 +351,7 @@ def apply_return_to!(root, state, branch_workflow)
|
|
|
348
351
|
source_workflow_config = state["source_workflow_config"]
|
|
349
352
|
return nil unless return_to && source_flow && source_workflow_config
|
|
350
353
|
|
|
351
|
-
main_workflow_path =
|
|
354
|
+
main_workflow_path, = DevflowControlIO.resolve_control_path(root, source_workflow_config, :workflow)
|
|
352
355
|
return nil unless File.exist?(main_workflow_path)
|
|
353
356
|
|
|
354
357
|
main_workflow = load_yaml_file(main_workflow_path)
|
|
@@ -512,7 +515,8 @@ result =
|
|
|
512
515
|
result = fail_result("missing_failure_branch", "current_step" => state["current_step"])
|
|
513
516
|
else
|
|
514
517
|
branch_path = branch.fetch("workflow_config")
|
|
515
|
-
|
|
518
|
+
branch_workflow_path, branch_path = DevflowControlIO.resolve_control_path(root, branch_path, :workflow)
|
|
519
|
+
branch_workflow = load_yaml_file(branch_workflow_path)
|
|
516
520
|
transition = branch_workflow.dig("state_contract", "suggested_transition") || {}
|
|
517
521
|
state["source_flow"] = state["current_flow"]
|
|
518
522
|
state["source_workflow_config"] = workflow_path
|
|
@@ -553,6 +557,13 @@ result =
|
|
|
553
557
|
state["revision"] = current_revision + 1
|
|
554
558
|
workflow_path, workflow = load_workflow(root, state)
|
|
555
559
|
end
|
|
560
|
+
prospective_owner = if changed
|
|
561
|
+
{
|
|
562
|
+
"phase" => state["current_phase"], "feature" => state["current_feature"],
|
|
563
|
+
"flow" => state["current_flow"], "state_path" => ".planning/STATE.md",
|
|
564
|
+
"scope" => "global", "status" => "active"
|
|
565
|
+
}
|
|
566
|
+
end
|
|
556
567
|
# Build the registry from the candidate state before validating it. A
|
|
557
568
|
# step's output is normally written before this transition, so the
|
|
558
569
|
# persisted registry may still describe the previous state revision.
|
|
@@ -564,16 +575,37 @@ result =
|
|
|
564
575
|
state_override: state,
|
|
565
576
|
artifact_registry_override: candidate_registry,
|
|
566
577
|
strict_artifact_status: false,
|
|
567
|
-
strict_registry_revision: false
|
|
578
|
+
strict_registry_revision: false,
|
|
579
|
+
phase_owner_override: prospective_owner
|
|
568
580
|
)
|
|
569
581
|
|
|
570
582
|
if changed && !validation["ok"]
|
|
571
583
|
result = fail_result("state_validation_failed", "validation" => validation)
|
|
572
584
|
elsif changed && options[:write]
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
585
|
+
ownership = PhaseOwnership.claim(
|
|
586
|
+
root: root, feature: state["current_feature"], phase: state["current_phase"],
|
|
587
|
+
flow: state["current_flow"], write: true
|
|
588
|
+
)
|
|
589
|
+
if !ownership["ok"]
|
|
590
|
+
result = fail_result("phase_ownership_claim_failed", "ownership" => ownership)
|
|
591
|
+
else
|
|
592
|
+
sync_artifact_registry(root, workflow, state, registry: candidate_registry)
|
|
593
|
+
artifact_by_path = load_artifact_registry(root, workflow)
|
|
594
|
+
write_state_file(root, state_path, state, workflow, artifact_by_path, action_description)
|
|
595
|
+
# STATE.md is project-global. Once its top-level workflow is
|
|
596
|
+
# terminal, retain the historical owner but release its active
|
|
597
|
+
# reservation so the next feature can claim a distinct phase.
|
|
598
|
+
if %w[completed failed cancelled].include?(state["status"]) && Array(state["pending_steps"]).empty? && state["source_flow"].nil?
|
|
599
|
+
completed_owner = PhaseOwnership.complete(
|
|
600
|
+
root: root, feature: state["current_feature"], phase: state["current_phase"],
|
|
601
|
+
flow: state["current_flow"], write: true
|
|
602
|
+
)
|
|
603
|
+
unless completed_owner["ok"]
|
|
604
|
+
result = fail_result("phase_ownership_completion_failed", "ownership" => completed_owner)
|
|
605
|
+
end
|
|
606
|
+
end
|
|
607
|
+
next_intent_written = write_next_intent(root, workflow) if next_intent_requested
|
|
608
|
+
end
|
|
577
609
|
end
|
|
578
610
|
|
|
579
611
|
result ||= {
|
|
@@ -2,14 +2,56 @@
|
|
|
2
2
|
# frozen_string_literal: true
|
|
3
3
|
|
|
4
4
|
require "fileutils"
|
|
5
|
+
require "pathname"
|
|
5
6
|
require "securerandom"
|
|
6
7
|
|
|
7
8
|
# Shared durability primitives for every writer in the Devflow control plane.
|
|
8
9
|
# The project lock serializes state, workflow, registry, and next-intent updates;
|
|
9
10
|
# atomic rename prevents readers from observing partially written documents.
|
|
10
11
|
module DevflowControlIO
|
|
12
|
+
class InvalidControlPath < StandardError; end
|
|
13
|
+
|
|
11
14
|
module_function
|
|
12
15
|
|
|
16
|
+
# Control-plane files are not general-purpose CLI file arguments. Keeping
|
|
17
|
+
# their location fixed prevents a crafted STATE/workflow/registry reference
|
|
18
|
+
# from turning recovery or synchronization into an out-of-project writer.
|
|
19
|
+
def resolve_control_path(root, raw, kind)
|
|
20
|
+
root_path = File.realpath(root)
|
|
21
|
+
value = raw.to_s
|
|
22
|
+
raise InvalidControlPath, "#{kind} path is required" if value.empty?
|
|
23
|
+
raise InvalidControlPath, "#{kind} path must be project-relative" if Pathname.new(value).absolute?
|
|
24
|
+
raise InvalidControlPath, "#{kind} path contains control characters" if value.match?(/[\x00-\x1F\x7F]/)
|
|
25
|
+
|
|
26
|
+
normalized = Pathname.new(value).cleanpath.to_s.tr("\\", "/")
|
|
27
|
+
raise InvalidControlPath, "#{kind} path cannot traverse parent directories" if normalized == ".." || normalized.start_with?("../")
|
|
28
|
+
allowed = case kind
|
|
29
|
+
when :state then normalized == ".planning/STATE.md"
|
|
30
|
+
when :workflow then normalized.match?(%r{\A\.planning/workflows/[A-Za-z0-9][A-Za-z0-9_-]*\.yaml\z})
|
|
31
|
+
when :registry then normalized == ".planning/artifacts/index.yaml"
|
|
32
|
+
when :artifact then normalized.start_with?(".planning/") || normalized.start_with?(".context/")
|
|
33
|
+
else false
|
|
34
|
+
end
|
|
35
|
+
raise InvalidControlPath, "invalid #{kind} control path" unless allowed
|
|
36
|
+
|
|
37
|
+
absolute = File.expand_path(normalized, root_path)
|
|
38
|
+
raise InvalidControlPath, "#{kind} path escapes project root" unless absolute.start_with?("#{root_path}#{File::SEPARATOR}")
|
|
39
|
+
# Existing symlinks in any parent component are equally unsafe: a write
|
|
40
|
+
# through one could escape after lexical normalization.
|
|
41
|
+
cursor = root_path
|
|
42
|
+
normalized.split("/").each do |segment|
|
|
43
|
+
cursor = File.join(cursor, segment)
|
|
44
|
+
next unless File.exist?(cursor) || File.symlink?(cursor)
|
|
45
|
+
next unless File.symlink?(cursor)
|
|
46
|
+
|
|
47
|
+
resolved = File.realpath(cursor)
|
|
48
|
+
unless resolved == root_path || resolved.start_with?("#{root_path}#{File::SEPARATOR}")
|
|
49
|
+
raise InvalidControlPath, "#{kind} path escapes project through a symlink"
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
[absolute, normalized]
|
|
53
|
+
end
|
|
54
|
+
|
|
13
55
|
def acquire_project_lock(root)
|
|
14
56
|
planning_dir = File.join(root, ".planning")
|
|
15
57
|
FileUtils.mkdir_p(planning_dir)
|
|
@@ -2,11 +2,13 @@
|
|
|
2
2
|
# frozen_string_literal: true
|
|
3
3
|
|
|
4
4
|
require "date"
|
|
5
|
+
require "digest"
|
|
5
6
|
require "fileutils"
|
|
6
7
|
require "json"
|
|
7
8
|
require "optparse"
|
|
8
9
|
require "yaml"
|
|
9
10
|
require_relative "control_io"
|
|
11
|
+
require_relative "phase_ownership"
|
|
10
12
|
|
|
11
13
|
options = {
|
|
12
14
|
feature: nil,
|
|
@@ -36,7 +38,12 @@ OptionParser.new do |opts|
|
|
|
36
38
|
end.parse!
|
|
37
39
|
|
|
38
40
|
root = Dir.pwd
|
|
39
|
-
|
|
41
|
+
begin
|
|
42
|
+
state_path, = DevflowControlIO.resolve_control_path(root, options[:state], :state)
|
|
43
|
+
rescue DevflowControlIO::InvalidControlPath => error
|
|
44
|
+
puts JSON.pretty_generate({ "ok" => false, "reason" => "invalid_control_path", "message" => error.message })
|
|
45
|
+
exit 0
|
|
46
|
+
end
|
|
40
47
|
|
|
41
48
|
def front_matter(markdown)
|
|
42
49
|
match = markdown.match(/\A---\n(.*?)\n---\n/m)
|
|
@@ -44,7 +51,13 @@ def front_matter(markdown)
|
|
|
44
51
|
end
|
|
45
52
|
|
|
46
53
|
feature = options[:feature] || File.basename(root)
|
|
47
|
-
|
|
54
|
+
# CLI callers get the same safe invariant as the extension entrypoint: phase
|
|
55
|
+
# identifiers never derive from a user-facing feature label or a shared name.
|
|
56
|
+
phase = options[:phase] || "feature-#{Digest::SHA256.hexdigest(feature)[0, 12]}"
|
|
57
|
+
unless PhaseOwnership.valid_slug?(phase)
|
|
58
|
+
puts JSON.pretty_generate({ "ok" => false, "reason" => "invalid_phase", "message" => "phase must be a safe non-empty slug" })
|
|
59
|
+
exit 0
|
|
60
|
+
end
|
|
48
61
|
workflow_config = ".planning/workflows/#{options[:flow]}.yaml"
|
|
49
62
|
workflow_path = File.expand_path(workflow_config, root)
|
|
50
63
|
fallback_pending_steps = [
|
|
@@ -104,6 +117,11 @@ state["active_track"] = options[:track] if %w[A B].include?(options[:track].to_s
|
|
|
104
117
|
project_lock = DevflowControlIO.acquire_project_lock(root) if options[:write]
|
|
105
118
|
|
|
106
119
|
begin
|
|
120
|
+
ownership = PhaseOwnership.claim(root: root, feature: feature, phase: phase, flow: options[:flow], write: options[:write])
|
|
121
|
+
unless ownership["ok"]
|
|
122
|
+
puts JSON.pretty_generate(ownership)
|
|
123
|
+
exit 0
|
|
124
|
+
end
|
|
107
125
|
existing = File.exist?(state_path) ? File.read(state_path) : nil
|
|
108
126
|
existing_front = existing && front_matter(existing)
|
|
109
127
|
action =
|
|
@@ -129,6 +147,7 @@ result = {
|
|
|
129
147
|
"written" => options[:write] && action != "skip_existing_front_matter",
|
|
130
148
|
"path" => options[:state],
|
|
131
149
|
"action" => action,
|
|
150
|
+
"phase_ownership" => ownership,
|
|
132
151
|
"state" => state
|
|
133
152
|
}
|
|
134
153
|
ensure
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# The control plane has one STATE.md, so phases must be claimed serially. This
|
|
5
|
+
# registry deliberately lives beside STATE.md instead of inside a phase folder:
|
|
6
|
+
# it remains visible even when a caller has selected the wrong phase directory.
|
|
7
|
+
require "date"
|
|
8
|
+
require "json"
|
|
9
|
+
require "optparse"
|
|
10
|
+
require "yaml"
|
|
11
|
+
require_relative "control_io"
|
|
12
|
+
|
|
13
|
+
module PhaseOwnership
|
|
14
|
+
REGISTRY_PATH = ".planning/phase-ownership.yaml"
|
|
15
|
+
PHASE_SLUG = /\A[A-Za-z0-9][A-Za-z0-9_-]*\z/
|
|
16
|
+
module_function
|
|
17
|
+
|
|
18
|
+
def valid_slug?(value)
|
|
19
|
+
value.is_a?(String) && value.match?(PHASE_SLUG)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Feature labels are user-facing names and may be Unicode. They are never
|
|
23
|
+
# used as a registry filename here, but reject separators/control characters
|
|
24
|
+
# so a later caller cannot reinterpret them as a path.
|
|
25
|
+
def valid_feature?(value)
|
|
26
|
+
value.is_a?(String) && !value.strip.empty? && !value.match?(/[\\\/\x00-\x1F\x7F]/)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def registry_path(root)
|
|
30
|
+
File.join(root, REGISTRY_PATH)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def load_registry(root)
|
|
34
|
+
path = registry_path(root)
|
|
35
|
+
return [nil, nil] unless File.exist?(path)
|
|
36
|
+
|
|
37
|
+
registry = YAML.safe_load(File.read(path), permitted_classes: [], aliases: false)
|
|
38
|
+
return [nil, "invalid_phase_ownership_registry"] unless registry.is_a?(Hash) && registry["schema_version"] == 1 && registry["owners"].is_a?(Hash)
|
|
39
|
+
|
|
40
|
+
[registry, nil]
|
|
41
|
+
rescue Psych::Exception
|
|
42
|
+
[nil, "invalid_phase_ownership_registry"]
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def valid_owner?(owner, phase: nil)
|
|
46
|
+
owner.is_a?(Hash) && valid_slug?(owner["phase"]) && (phase.nil? || owner["phase"] == phase) &&
|
|
47
|
+
valid_feature?(owner["feature"]) && owner["flow"].is_a?(String) && !owner["flow"].empty? &&
|
|
48
|
+
owner["state_path"] == ".planning/STATE.md" && %w[active completed].include?(owner["status"])
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def nonempty_phase_dirs(root)
|
|
52
|
+
Dir.glob(File.join(root, ".planning", "phases", "*")).select do |path|
|
|
53
|
+
File.directory?(path) && !Dir.children(path).empty?
|
|
54
|
+
end.map { |path| File.basename(path) }
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def claim(root:, feature:, phase:, flow:, write:)
|
|
58
|
+
return failure("invalid_phase", "phase must be a safe non-empty slug") unless valid_slug?(phase)
|
|
59
|
+
return failure("invalid_feature", "feature must be non-empty and cannot contain path separators or control characters") unless valid_feature?(feature)
|
|
60
|
+
return failure("invalid_flow", "flow is required") unless flow.is_a?(String) && !flow.empty?
|
|
61
|
+
|
|
62
|
+
registry, error = load_registry(root)
|
|
63
|
+
return failure(error, "phase ownership registry is malformed") if error
|
|
64
|
+
registry ||= { "schema_version" => 1, "owners" => {} }
|
|
65
|
+
owners = registry["owners"]
|
|
66
|
+
|
|
67
|
+
nonempty_phase_dirs(root).each do |phase_dir|
|
|
68
|
+
owner = owners[phase_dir]
|
|
69
|
+
next if valid_owner?(owner, phase: phase_dir)
|
|
70
|
+
|
|
71
|
+
return failure("legacy_phase_directory_without_owner", "non-empty phase directory has no valid ownership record", phase: phase_dir)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
owners.each do |owner_phase, owner|
|
|
75
|
+
return failure("invalid_phase_owner", "phase ownership record is invalid", phase: owner_phase) unless valid_owner?(owner, phase: owner_phase)
|
|
76
|
+
next unless owner["status"] == "active"
|
|
77
|
+
next if owner["phase"] == phase && owner["feature"] == feature
|
|
78
|
+
|
|
79
|
+
return failure("active_phase_owned_by_another_feature", "an active phase already owns the global state", owner: owner)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
current = owners[phase]
|
|
83
|
+
if current && current["feature"] != feature
|
|
84
|
+
return failure("phase_owned_by_another_feature", "requested phase is already owned by another feature", owner: current)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
owner = current || { "phase" => phase, "feature" => feature, "flow" => flow, "state_path" => ".planning/STATE.md", "scope" => "global", "status" => "active" }
|
|
88
|
+
owner = owner.merge("phase" => phase, "feature" => feature, "flow" => flow, "state_path" => ".planning/STATE.md", "scope" => "global", "status" => "active")
|
|
89
|
+
registry["owners"][phase] = owner
|
|
90
|
+
registry["updated_at"] = Date.today.to_s
|
|
91
|
+
DevflowControlIO.atomic_write(registry_path(root), registry.to_yaml) if write
|
|
92
|
+
{ "ok" => true, "written" => write, "action" => current ? "idempotent_claim" : "claim", "owner" => owner, "path" => REGISTRY_PATH }
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def complete(root:, feature:, phase:, flow:, write:)
|
|
96
|
+
registry, error = load_registry(root)
|
|
97
|
+
return failure(error || "missing_phase_owner", "phase ownership record is required") unless registry
|
|
98
|
+
owner = registry["owners"][phase]
|
|
99
|
+
unless valid_owner?(owner, phase: phase) && owner["feature"] == feature && owner["flow"] == flow
|
|
100
|
+
return failure("phase_owner_mismatch", "cannot complete a phase owned by another feature")
|
|
101
|
+
end
|
|
102
|
+
owner["status"] = "completed"
|
|
103
|
+
registry["updated_at"] = Date.today.to_s
|
|
104
|
+
DevflowControlIO.atomic_write(registry_path(root), registry.to_yaml) if write
|
|
105
|
+
{ "ok" => true, "written" => write, "action" => "complete", "owner" => owner, "path" => REGISTRY_PATH }
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def validate_state(root:, state:, owner_override: nil)
|
|
109
|
+
registry, error = load_registry(root)
|
|
110
|
+
return failure(error || "missing_phase_owner", "STATE.md has no phase ownership record") unless registry
|
|
111
|
+
phase = state["current_phase"]
|
|
112
|
+
feature = state["current_feature"]
|
|
113
|
+
flow = state["current_flow"]
|
|
114
|
+
return failure("invalid_phase", "STATE.md current_phase must be a safe non-empty slug") unless valid_slug?(phase)
|
|
115
|
+
owner = owner_override || registry["owners"][phase]
|
|
116
|
+
return failure("missing_phase_owner", "STATE.md phase has no ownership record", phase: phase) unless valid_owner?(owner, phase: phase)
|
|
117
|
+
unless owner["feature"] == feature && owner["flow"] == flow && owner["state_path"] == ".planning/STATE.md"
|
|
118
|
+
return failure("phase_owner_mismatch", "STATE.md does not match phase ownership record", owner: owner)
|
|
119
|
+
end
|
|
120
|
+
conflicting = registry["owners"].values.find { |entry| valid_owner?(entry) && entry["status"] == "active" && entry["phase"] != phase }
|
|
121
|
+
return failure("active_phase_ownership_conflict", "another active phase owns the global state", owner: conflicting) if conflicting
|
|
122
|
+
{ "ok" => true, "owner" => owner, "path" => REGISTRY_PATH }
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def failure(reason, message, extra = {})
|
|
126
|
+
{ "ok" => false, "reason" => reason, "message" => message }.merge(extra)
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
if __FILE__ == $PROGRAM_NAME
|
|
131
|
+
options = { feature: nil, phase: nil, flow: "feature-development", write: false, json: false }
|
|
132
|
+
command = ARGV.shift
|
|
133
|
+
OptionParser.new do |opts|
|
|
134
|
+
opts.banner = "Usage: phase_ownership.rb claim|inspect|complete --feature NAME --phase ID --flow ID [--write] [--json]"
|
|
135
|
+
opts.on("--feature NAME") { |value| options[:feature] = value }
|
|
136
|
+
opts.on("--phase ID") { |value| options[:phase] = value }
|
|
137
|
+
opts.on("--flow ID") { |value| options[:flow] = value }
|
|
138
|
+
opts.on("--write") { options[:write] = true }
|
|
139
|
+
opts.on("--json") { options[:json] = true }
|
|
140
|
+
end.parse!
|
|
141
|
+
root = Dir.pwd
|
|
142
|
+
lock = options[:write] ? DevflowControlIO.acquire_project_lock(root) : nil
|
|
143
|
+
begin
|
|
144
|
+
result = case command
|
|
145
|
+
when "claim" then PhaseOwnership.claim(root: root, feature: options[:feature], phase: options[:phase], flow: options[:flow], write: options[:write])
|
|
146
|
+
when "complete" then PhaseOwnership.complete(root: root, feature: options[:feature], phase: options[:phase], flow: options[:flow], write: options[:write])
|
|
147
|
+
when "inspect" then PhaseOwnership.validate_state(root: root, state: { "current_feature" => options[:feature], "current_phase" => options[:phase], "current_flow" => options[:flow] })
|
|
148
|
+
else PhaseOwnership.failure("invalid_command", "command must be claim, inspect, or complete")
|
|
149
|
+
end
|
|
150
|
+
ensure
|
|
151
|
+
DevflowControlIO.release_lock(lock)
|
|
152
|
+
end
|
|
153
|
+
puts JSON.pretty_generate(result)
|
|
154
|
+
end
|
|
@@ -7,6 +7,7 @@ require "optparse"
|
|
|
7
7
|
require "yaml"
|
|
8
8
|
require_relative "control_io"
|
|
9
9
|
require_relative "validate_state"
|
|
10
|
+
require_relative "phase_ownership"
|
|
10
11
|
|
|
11
12
|
options = { state: ".planning/STATE.md", expected_revision: nil, confirm: false, write: false, json: false }
|
|
12
13
|
OptionParser.new do |opts|
|
|
@@ -31,7 +32,7 @@ def build_registry(root, workflow, state)
|
|
|
31
32
|
artifacts = Array(workflow["steps"]).flat_map do |step|
|
|
32
33
|
Array(step["outputs"]).map do |output|
|
|
33
34
|
path = interpolate_path(output.fetch("path"), state)
|
|
34
|
-
abs =
|
|
35
|
+
abs, = DevflowControlIO.resolve_control_path(root, path, :artifact)
|
|
35
36
|
exists = File.exist?(abs)
|
|
36
37
|
issues = []
|
|
37
38
|
issues << "file_missing" unless exists
|
|
@@ -53,43 +54,51 @@ def build_registry(root, workflow, state)
|
|
|
53
54
|
end
|
|
54
55
|
|
|
55
56
|
root = Dir.pwd
|
|
56
|
-
state_path = File.expand_path(options[:state], root)
|
|
57
57
|
result = nil
|
|
58
58
|
lock = options[:write] ? DevflowControlIO.acquire_project_lock(root) : nil
|
|
59
59
|
begin
|
|
60
|
+
state_path, = DevflowControlIO.resolve_control_path(root, options[:state], :state)
|
|
60
61
|
unless File.exist?(state_path)
|
|
61
62
|
result = { "ok" => false, "classification" => "unsafe", "reason" => "missing_state_file" }
|
|
62
63
|
else
|
|
63
64
|
state = YAML.safe_load(front_matter(File.read(state_path)), permitted_classes: [], aliases: false)
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
if !registry_source.is_a?(String) || registry_source.empty?
|
|
68
|
-
result = { "ok" => false, "classification" => "unsafe", "reason" => "missing_artifact_registry_config", "plan" => { "state_transition" => "none", "registry_write" => false } }
|
|
65
|
+
ownership = PhaseOwnership.validate_state(root: root, state: state)
|
|
66
|
+
unless ownership["ok"]
|
|
67
|
+
result = { "ok" => false, "classification" => "unsafe", "reason" => ownership["reason"], "issues" => [ownership], "plan" => { "state_transition" => "none", "registry_write" => false } }
|
|
69
68
|
else
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
path = interpolate_path(output.fetch("path"), state)
|
|
76
|
-
path unless File.exist?(File.join(root, path))
|
|
77
|
-
end.compact
|
|
78
|
-
if missing_current_outputs.any?
|
|
79
|
-
result = { "ok" => false, "classification" => "unsafe", "reason" => "missing_required_output", "missing_outputs" => missing_current_outputs, "plan" => { "state_transition" => "none", "registry_write" => false } }
|
|
80
|
-
elsif !validation["ok"]
|
|
81
|
-
result = { "ok" => false, "classification" => "unsafe", "reason" => "ambiguous_or_invalid_state", "issues" => validation["errors"], "plan" => { "state_transition" => "none", "registry_write" => false } }
|
|
82
|
-
elsif !options[:confirm] || !options[:write]
|
|
83
|
-
result = { "ok" => false, "classification" => "safe_repairable", "reason" => "confirmation_required", "revision" => state.fetch("revision", 0), "plan" => { "state_transition" => "none", "registry_write" => true, "registry" => candidate } }
|
|
84
|
-
elsif options[:expected_revision] != state.fetch("revision", 0)
|
|
85
|
-
result = { "ok" => false, "classification" => "unsafe", "reason" => "stale_revision", "expected_revision" => options[:expected_revision], "actual_revision" => state.fetch("revision", 0) }
|
|
69
|
+
workflow_path, = DevflowControlIO.resolve_control_path(root, state.fetch("workflow_config"), :workflow)
|
|
70
|
+
workflow = YAML.safe_load(File.read(workflow_path), permitted_classes: [], aliases: false)
|
|
71
|
+
registry_source = workflow.dig("artifact_registry", "source")
|
|
72
|
+
if !registry_source.is_a?(String) || registry_source.empty?
|
|
73
|
+
result = { "ok" => false, "classification" => "unsafe", "reason" => "missing_artifact_registry_config", "plan" => { "state_transition" => "none", "registry_write" => false } }
|
|
86
74
|
else
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
75
|
+
candidate = build_registry(root, workflow, state)
|
|
76
|
+
validation = WorkflowStateValidator.validate(root: root, state_path: state_path, state_override: state, workflow_override: workflow, artifact_registry_override: candidate, strict_artifact_status: true, strict_registry_revision: true)
|
|
77
|
+
current = Array(workflow["steps"]).find { |step| step["id"] == state["current_step"] }
|
|
78
|
+
missing_current_outputs = Array(current && current["outputs"]).map do |output|
|
|
79
|
+
next unless output["required"]
|
|
80
|
+
path = interpolate_path(output.fetch("path"), state)
|
|
81
|
+
output_path, = DevflowControlIO.resolve_control_path(root, path, :artifact)
|
|
82
|
+
path unless File.exist?(output_path)
|
|
83
|
+
end.compact
|
|
84
|
+
if missing_current_outputs.any?
|
|
85
|
+
result = { "ok" => false, "classification" => "unsafe", "reason" => "missing_required_output", "missing_outputs" => missing_current_outputs, "plan" => { "state_transition" => "none", "registry_write" => false } }
|
|
86
|
+
elsif !validation["ok"]
|
|
87
|
+
result = { "ok" => false, "classification" => "unsafe", "reason" => "ambiguous_or_invalid_state", "issues" => validation["errors"], "plan" => { "state_transition" => "none", "registry_write" => false } }
|
|
88
|
+
elsif !options[:confirm] || !options[:write]
|
|
89
|
+
result = { "ok" => false, "classification" => "safe_repairable", "reason" => "confirmation_required", "revision" => state.fetch("revision", 0), "plan" => { "state_transition" => "none", "registry_write" => true, "registry" => candidate } }
|
|
90
|
+
elsif options[:expected_revision] != state.fetch("revision", 0)
|
|
91
|
+
result = { "ok" => false, "classification" => "unsafe", "reason" => "stale_revision", "expected_revision" => options[:expected_revision], "actual_revision" => state.fetch("revision", 0) }
|
|
92
|
+
else
|
|
93
|
+
registry_path, = DevflowControlIO.resolve_control_path(root, registry_source, :registry)
|
|
94
|
+
DevflowControlIO.atomic_write(registry_path, candidate.to_yaml)
|
|
95
|
+
result = { "ok" => true, "classification" => "safe_repairable", "written" => true, "revision" => state.fetch("revision", 0), "plan" => { "state_transition" => "none", "registry_write" => true } }
|
|
96
|
+
end
|
|
90
97
|
end
|
|
91
98
|
end
|
|
92
99
|
end
|
|
100
|
+
rescue DevflowControlIO::InvalidControlPath => error
|
|
101
|
+
result = { "ok" => false, "classification" => "unsafe", "reason" => "invalid_control_path", "message" => error.message, "plan" => { "state_transition" => "none", "registry_write" => false } }
|
|
93
102
|
ensure
|
|
94
103
|
DevflowControlIO.release_lock(lock)
|
|
95
104
|
end
|
|
@@ -7,6 +7,7 @@ require "json"
|
|
|
7
7
|
require "optparse"
|
|
8
8
|
require "yaml"
|
|
9
9
|
require_relative "control_io"
|
|
10
|
+
require_relative "phase_ownership"
|
|
10
11
|
|
|
11
12
|
options = {
|
|
12
13
|
state: ".planning/STATE.md",
|
|
@@ -63,11 +64,18 @@ end
|
|
|
63
64
|
project_lock = DevflowControlIO.acquire_project_lock(root) if options[:write]
|
|
64
65
|
|
|
65
66
|
begin
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
67
|
+
state_path, = DevflowControlIO.resolve_control_path(root, options[:state], :state)
|
|
68
|
+
state = load_state(state_path)
|
|
69
|
+
ownership = PhaseOwnership.validate_state(root: root, state: state)
|
|
70
|
+
unless ownership["ok"]
|
|
71
|
+
puts JSON.pretty_generate(ownership.merge("written" => false))
|
|
72
|
+
exit 0
|
|
73
|
+
end
|
|
74
|
+
workflow_source = options[:workflow] || state["workflow_config"] || ".planning/workflows/#{state.fetch("current_flow")}.yaml"
|
|
75
|
+
workflow_path, = DevflowControlIO.resolve_control_path(root, workflow_source, :workflow)
|
|
76
|
+
workflow = load_yaml_file(workflow_path)
|
|
77
|
+
registry_source = options[:output] || workflow.dig("artifact_registry", "source") || ".planning/artifacts/index.yaml"
|
|
78
|
+
existing_registry_path, registry_path = DevflowControlIO.resolve_control_path(root, registry_source, :registry)
|
|
71
79
|
existing_by_path = {}
|
|
72
80
|
if options[:preserve] && File.exist?(existing_registry_path)
|
|
73
81
|
existing = load_yaml_file(existing_registry_path)
|
|
@@ -80,7 +88,7 @@ content_checks = workflow.dig("artifact_registry", "content_checks") || {}
|
|
|
80
88
|
preservable_statuses = %w[approved completed passed pending_rerun stale failed]
|
|
81
89
|
|
|
82
90
|
def content_validity(root, path, checks)
|
|
83
|
-
abs =
|
|
91
|
+
abs, = DevflowControlIO.resolve_control_path(root, path, :artifact)
|
|
84
92
|
return { "valid" => false, "issues" => ["file_missing"] } unless File.exist?(abs)
|
|
85
93
|
|
|
86
94
|
issues = []
|
|
@@ -114,7 +122,8 @@ artifacts = collect_outputs(workflow).map do |step, output|
|
|
|
114
122
|
end
|
|
115
123
|
|
|
116
124
|
validity = content_validity(root, path, content_checks)
|
|
117
|
-
|
|
125
|
+
artifact_path, = DevflowControlIO.resolve_control_path(root, path, :artifact)
|
|
126
|
+
file_exists = File.exist?(artifact_path)
|
|
118
127
|
status = if options[:preserve] && preservable_statuses.include?(existing["status"])
|
|
119
128
|
existing["status"]
|
|
120
129
|
else
|
|
@@ -161,6 +170,8 @@ result = {
|
|
|
161
170
|
"missing_count" => artifacts.count { |artifact| !artifact["exists"] },
|
|
162
171
|
"registry" => registry
|
|
163
172
|
}
|
|
173
|
+
rescue DevflowControlIO::InvalidControlPath => error
|
|
174
|
+
result = { "ok" => false, "written" => false, "reason" => "invalid_control_path", "message" => error.message }
|
|
164
175
|
ensure
|
|
165
176
|
DevflowControlIO.release_lock(project_lock)
|
|
166
177
|
end
|