ace-overseer 0.17.0 → 0.18.2
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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +34 -10
- data/docs/usage.md +41 -5
- data/handbook/workflow-instructions/overseer.wf.md +33 -48
- data/lib/ace/overseer/cli/commands/prune.rb +140 -22
- data/lib/ace/overseer/models/assignment_prune_candidate.rb +7 -3
- data/lib/ace/overseer/models/preservation_proof.rb +37 -0
- data/lib/ace/overseer/models/prune_candidate.rb +11 -3
- data/lib/ace/overseer/molecules/assignment_prune_safety_checker.rb +94 -1
- data/lib/ace/overseer/molecules/git_preservation_checker.rb +314 -0
- data/lib/ace/overseer/molecules/lab_client.rb +21 -0
- data/lib/ace/overseer/molecules/lab_prune_safety_checker.rb +69 -0
- data/lib/ace/overseer/molecules/preservation_manifest.rb +231 -0
- data/lib/ace/overseer/molecules/prune_safety_checker.rb +159 -16
- data/lib/ace/overseer/organisms/prune_orchestrator.rb +269 -67
- data/lib/ace/overseer/organisms/work_on_orchestrator.rb +64 -46
- data/lib/ace/overseer/version.rb +1 -1
- data/lib/ace/overseer.rb +10 -2
- metadata +10 -6
|
@@ -4,19 +4,25 @@ module Ace
|
|
|
4
4
|
module Overseer
|
|
5
5
|
module Models
|
|
6
6
|
class PruneCandidate
|
|
7
|
-
attr_reader :task_id, :worktree_path, :assignment_complete, :task_done, :git_clean,
|
|
7
|
+
attr_reader :task_id, :worktree_path, :assignment_complete, :task_done, :git_clean,
|
|
8
|
+
:attempts_terminal, :preserved, :verified_head, :verified_branch, :reasons
|
|
8
9
|
|
|
9
|
-
def initialize(task_id:, worktree_path:, assignment_complete:, task_done:, git_clean:,
|
|
10
|
+
def initialize(task_id:, worktree_path:, assignment_complete:, task_done:, git_clean:,
|
|
11
|
+
attempts_terminal: true, preserved: true, verified_head: nil, verified_branch: nil, reasons: [])
|
|
10
12
|
@task_id = task_id.to_s.freeze
|
|
11
13
|
@worktree_path = worktree_path.to_s.freeze
|
|
12
14
|
@assignment_complete = assignment_complete
|
|
13
15
|
@task_done = task_done
|
|
14
16
|
@git_clean = git_clean
|
|
17
|
+
@attempts_terminal = attempts_terminal
|
|
18
|
+
@preserved = preserved
|
|
19
|
+
@verified_head = verified_head
|
|
20
|
+
@verified_branch = verified_branch
|
|
15
21
|
@reasons = reasons.map(&:to_s).freeze
|
|
16
22
|
end
|
|
17
23
|
|
|
18
24
|
def safe_to_prune?
|
|
19
|
-
assignment_complete && task_done && git_clean
|
|
25
|
+
assignment_complete && task_done && git_clean && attempts_terminal && preserved
|
|
20
26
|
end
|
|
21
27
|
|
|
22
28
|
def to_h
|
|
@@ -26,6 +32,8 @@ module Ace
|
|
|
26
32
|
assignment_complete: assignment_complete,
|
|
27
33
|
task_done: task_done,
|
|
28
34
|
git_clean: git_clean,
|
|
35
|
+
attempts_terminal: attempts_terminal,
|
|
36
|
+
preserved: preserved,
|
|
29
37
|
reasons: reasons,
|
|
30
38
|
safe_to_prune: safe_to_prune?
|
|
31
39
|
}
|
|
@@ -3,10 +3,32 @@
|
|
|
3
3
|
module Ace
|
|
4
4
|
module Overseer
|
|
5
5
|
module Molecules
|
|
6
|
+
# Fail-closed safety classification for assignment cache pruning.
|
|
7
|
+
#
|
|
8
|
+
# The cache directory of an assignment is disposable only when its
|
|
9
|
+
# durable evidence survives outside the deletion target and every
|
|
10
|
+
# attempt is provably terminal:
|
|
11
|
+
#
|
|
12
|
+
# - the queue label must read completed (never proof by itself),
|
|
13
|
+
# - recorded attempts must be terminal — active or uncertain attempts
|
|
14
|
+
# block, and unreadable attempt state blocks,
|
|
15
|
+
# - managed assignments must carry their durable evidence in
|
|
16
|
+
# refs/ace/execution; a missing or unreadable evidence ref blocks,
|
|
17
|
+
# and journal-derived active/uncertain attempts block even when the
|
|
18
|
+
# local records were lost.
|
|
19
|
+
#
|
|
20
|
+
# The journal checkout and the evidence ref are never deletion targets:
|
|
21
|
+
# cleanup removes only the assignment cache directory.
|
|
6
22
|
class AssignmentPruneSafetyChecker
|
|
7
|
-
def initialize(assignment_discoverer_factory: nil, assignment_manager_factory: nil
|
|
23
|
+
def initialize(assignment_discoverer_factory: nil, assignment_manager_factory: nil,
|
|
24
|
+
journal_factory: nil)
|
|
8
25
|
@assignment_discoverer_factory = assignment_discoverer_factory || -> { Ace::Assign::Molecules::AssignmentDiscoverer.new }
|
|
9
26
|
@assignment_manager_factory = assignment_manager_factory || -> { Ace::Assign::Molecules::AssignmentManager.new }
|
|
27
|
+
@journal_factory = journal_factory || -> {
|
|
28
|
+
Ace::Assign::Molecules::EvidenceJournal.new(
|
|
29
|
+
repo_root: Ace::Support::Fs::Molecules::ProjectRootFinder.find_or_current
|
|
30
|
+
)
|
|
31
|
+
}
|
|
10
32
|
end
|
|
11
33
|
|
|
12
34
|
def check(assignment_id:)
|
|
@@ -20,6 +42,7 @@ module Ace
|
|
|
20
42
|
assignment_name: "",
|
|
21
43
|
assignment_state: "not_found",
|
|
22
44
|
location_path: "",
|
|
45
|
+
attempts_terminal: false,
|
|
23
46
|
reasons: ["assignment not found"]
|
|
24
47
|
)
|
|
25
48
|
end
|
|
@@ -28,14 +51,84 @@ module Ace
|
|
|
28
51
|
reasons = []
|
|
29
52
|
reasons << "assignment still #{state}" unless state == "completed"
|
|
30
53
|
|
|
54
|
+
attempts_terminal, attempt_reasons = attempts_evidence(info.assignment, assignment_id)
|
|
55
|
+
reasons.concat(attempt_reasons)
|
|
56
|
+
|
|
31
57
|
Models::AssignmentPruneCandidate.new(
|
|
32
58
|
assignment_id: assignment_id,
|
|
33
59
|
assignment_name: info.assignment.name,
|
|
34
60
|
assignment_state: state,
|
|
35
61
|
location_path: info.assignment.cache_dir,
|
|
62
|
+
attempts_terminal: attempts_terminal,
|
|
36
63
|
reasons: reasons
|
|
37
64
|
)
|
|
38
65
|
end
|
|
66
|
+
|
|
67
|
+
private
|
|
68
|
+
|
|
69
|
+
def attempts_evidence(assignment, assignment_id)
|
|
70
|
+
managed = begin
|
|
71
|
+
assignment.managed?
|
|
72
|
+
rescue => e
|
|
73
|
+
return [false, ["assignment lifecycle state unreadable: #{e.message}"]]
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
reasons = []
|
|
77
|
+
local_terminal, local_reasons = local_attempts(assignment_id)
|
|
78
|
+
reasons.concat(local_reasons)
|
|
79
|
+
|
|
80
|
+
if managed
|
|
81
|
+
journal_terminal, journal_reasons = journal_evidence(assignment_id)
|
|
82
|
+
reasons.concat(journal_reasons)
|
|
83
|
+
[local_terminal && journal_terminal, reasons]
|
|
84
|
+
else
|
|
85
|
+
[local_terminal, reasons]
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def local_attempts(assignment_id)
|
|
90
|
+
attempts = @assignment_manager_factory.call.attempts(assignment_id)
|
|
91
|
+
terminal = true
|
|
92
|
+
reasons = []
|
|
93
|
+
attempts.each do |attempt|
|
|
94
|
+
next unless attempt.active? || attempt.uncertain?
|
|
95
|
+
|
|
96
|
+
terminal = false
|
|
97
|
+
reasons << "attempt #{attempt.attempt_id} is #{attempt.state}"
|
|
98
|
+
end
|
|
99
|
+
[terminal, reasons]
|
|
100
|
+
rescue => e
|
|
101
|
+
[false, ["attempt state unreadable: #{e.message}"]]
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# The journal is the durable evidence outside the deletion target:
|
|
105
|
+
# managed cleanup requires THIS assignment's journaled attempt
|
|
106
|
+
# history to exist and to agree that no attempt is active or
|
|
107
|
+
# uncertain (an empty journal carries no evidence at all).
|
|
108
|
+
def journal_evidence(assignment_id)
|
|
109
|
+
journal = @journal_factory.call
|
|
110
|
+
ref_value = journal.ref_value
|
|
111
|
+
if ref_value.nil?
|
|
112
|
+
return [false, ["no durable evidence ref for managed assignment #{assignment_id}"]]
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
derived = journal.derived_attempts(assignment_id)
|
|
116
|
+
if derived.empty?
|
|
117
|
+
return [false, ["no durable attempt evidence for managed assignment #{assignment_id} in the journal"]]
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
terminal = true
|
|
121
|
+
reasons = []
|
|
122
|
+
derived.each do |attempt|
|
|
123
|
+
next unless attempt.active? || attempt.uncertain?
|
|
124
|
+
|
|
125
|
+
terminal = false
|
|
126
|
+
reasons << "journaled attempt #{attempt.attempt_id} is #{attempt.state}"
|
|
127
|
+
end
|
|
128
|
+
[terminal, reasons]
|
|
129
|
+
rescue => e
|
|
130
|
+
[false, ["durable evidence unreadable: #{e.message}"]]
|
|
131
|
+
end
|
|
39
132
|
end
|
|
40
133
|
end
|
|
41
134
|
end
|
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "open3"
|
|
4
|
+
|
|
5
|
+
module Ace
|
|
6
|
+
module Overseer
|
|
7
|
+
module Molecules
|
|
8
|
+
# Executes Git preservation proofs for prune candidates.
|
|
9
|
+
#
|
|
10
|
+
# A candidate is preserved only through an executed identity:
|
|
11
|
+
#
|
|
12
|
+
# 1. Accepted ancestry — the candidate HEAD is contained in the current
|
|
13
|
+
# tip of a distinct surviving accepted base branch.
|
|
14
|
+
# 2. Declared destination — a verified manifest record whose
|
|
15
|
+
# destination branch accepted both the declared base and head, and
|
|
16
|
+
# whose content matches by full-tree equality or by an exact
|
|
17
|
+
# path-by-path transition (modes, symlinks, binary content) between
|
|
18
|
+
# separate source and destination bases. Patch-range proof requires
|
|
19
|
+
# the independently recorded creation baseline (qjl base_head); a
|
|
20
|
+
# missing or mismatching baseline disables it. The baseline itself
|
|
21
|
+
# must be preserved on a surviving accepted ref.
|
|
22
|
+
#
|
|
23
|
+
# Everything else — missing, failed or ambiguous evidence — blocks.
|
|
24
|
+
# All Git access is argv-based; no refs are fetched into any repo.
|
|
25
|
+
class GitPreservationChecker
|
|
26
|
+
DEFAULT_GIT_RUNNER = ->(repo, *args) { Open3.capture3("git", "-C", repo, *args) }
|
|
27
|
+
|
|
28
|
+
attr_reader :git_runner
|
|
29
|
+
|
|
30
|
+
def initialize(git_runner: DEFAULT_GIT_RUNNER)
|
|
31
|
+
@git_runner = git_runner
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# The surviving accepted base of the repository: the main checkout's
|
|
35
|
+
# current branch and its live tip.
|
|
36
|
+
#
|
|
37
|
+
# @param repo_root [String] Common repository root
|
|
38
|
+
# @return [Hash, nil] `{branch:, head:}` or nil when unresolvable
|
|
39
|
+
def accepted_base_for(repo_root)
|
|
40
|
+
branch = rev_parse(repo_root, "--abbrev-ref", "HEAD")
|
|
41
|
+
return nil if branch.nil? || branch.empty? || branch == "HEAD"
|
|
42
|
+
|
|
43
|
+
head = rev_parse(repo_root, "--verify", "HEAD")
|
|
44
|
+
return nil if head.nil?
|
|
45
|
+
|
|
46
|
+
{branch: branch, head: head}
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# The common repository of a worktree candidate.
|
|
50
|
+
#
|
|
51
|
+
# @param worktree_path [String]
|
|
52
|
+
# @return [String, nil] Absolute repository root or nil
|
|
53
|
+
def source_repo_for(worktree_path)
|
|
54
|
+
common_dir = rev_parse(worktree_path, "--path-format=absolute", "--git-common-dir")
|
|
55
|
+
return nil if common_dir.nil? || common_dir.empty?
|
|
56
|
+
|
|
57
|
+
File.dirname(common_dir)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Prove preservation for one candidate.
|
|
61
|
+
#
|
|
62
|
+
# @param worktree_path [String] Candidate worktree
|
|
63
|
+
# @param accepted_base [Hash, nil] `{branch:, head:}` surviving base
|
|
64
|
+
# @param manifest_record [PreservationManifest::Record, nil] Declared
|
|
65
|
+
# destination (a claim; verified here)
|
|
66
|
+
# @param recorded_base [String, nil] Independently recorded creation
|
|
67
|
+
# baseline (attempt base_head), outside the manifest
|
|
68
|
+
# @param candidate_branch [String, nil] The candidate's own branch,
|
|
69
|
+
# scheduled for deletion with the worktree
|
|
70
|
+
# @return [Models::PreservationProof]
|
|
71
|
+
def proof(worktree_path:, accepted_base:, manifest_record: nil, recorded_base: nil, candidate_branch: nil)
|
|
72
|
+
live_head = rev_parse(worktree_path, "--verify", "HEAD")
|
|
73
|
+
return blocked("cannot resolve candidate HEAD") if live_head.nil?
|
|
74
|
+
|
|
75
|
+
if accepted_base && accepted_base[:head] == live_head
|
|
76
|
+
return blocked("candidate HEAD equals the accepted base tip; no distinct work to preserve")
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
if ancestry?(worktree_path, live_head, accepted_base[:head])
|
|
80
|
+
return Models::PreservationProof.preserved(:accepted_ancestor, head: live_head)
|
|
81
|
+
end
|
|
82
|
+
return blocked("HEAD #{live_head[0, 12]} is not contained in accepted base " \
|
|
83
|
+
"#{accepted_base[:branch]} (#{accepted_base[:head][0, 12]}) and no destination is declared") if manifest_record.nil?
|
|
84
|
+
|
|
85
|
+
proof_via_destination(worktree_path, live_head, accepted_base, manifest_record, recorded_base, candidate_branch)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Prove preservation for a static identity that has no local
|
|
89
|
+
# checkout (e.g. a Lab Work documenting repo/head/branch): the head
|
|
90
|
+
# must be contained in the current tip of the surviving accepted
|
|
91
|
+
# base branch.
|
|
92
|
+
#
|
|
93
|
+
# @param repo [String] Repository root
|
|
94
|
+
# @param head [String] Commit whose preservation is in question
|
|
95
|
+
# @param accepted_base [Hash] `{branch:, head:}` surviving base
|
|
96
|
+
# @return [Models::PreservationProof]
|
|
97
|
+
def ancestry_proof(repo:, head:, accepted_base:)
|
|
98
|
+
if accepted_base.nil? || head.to_s.empty?
|
|
99
|
+
return blocked("cannot resolve an accepted surviving base to prove preservation")
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
if ancestry?(repo, head, accepted_base[:head])
|
|
103
|
+
Models::PreservationProof.preserved(:accepted_ancestor)
|
|
104
|
+
else
|
|
105
|
+
blocked("documented head #{head[0, 12]} is not contained in accepted base #{accepted_base[:branch]}")
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
private
|
|
110
|
+
|
|
111
|
+
def proof_via_destination(worktree_path, live_head, accepted_base, record, recorded_base, candidate_branch)
|
|
112
|
+
identity_failure = verify_source_identity(worktree_path, live_head, record)
|
|
113
|
+
return identity_failure if identity_failure
|
|
114
|
+
|
|
115
|
+
destination_failure = verify_destination_survival(record, candidate_branch)
|
|
116
|
+
return destination_failure if destination_failure
|
|
117
|
+
|
|
118
|
+
acceptance_failure = verify_destination_acceptance(record)
|
|
119
|
+
return acceptance_failure if acceptance_failure
|
|
120
|
+
|
|
121
|
+
tree_proof = tree_equality_proof(record)
|
|
122
|
+
return tree_proof if tree_proof&.preserved?
|
|
123
|
+
|
|
124
|
+
unless patch_proof_allowed?(record, recorded_base)
|
|
125
|
+
if recorded_base.nil?
|
|
126
|
+
return blocked("patch-range proof is disabled: no independently recorded creation baseline " \
|
|
127
|
+
"(attempt base_head) is available to authorize the declared source range")
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
return blocked("declared source_base does not match the recorded creation baseline " \
|
|
131
|
+
"#{recorded_base[0, 12]}")
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
range_failure = verify_range(record)
|
|
135
|
+
return range_failure if range_failure
|
|
136
|
+
|
|
137
|
+
unless ancestry?(record.source_repo, record.resolved_source_base, accepted_base[:head])
|
|
138
|
+
return blocked("declared source baseline #{record.source_base[0, 12]} itself is not preserved on " \
|
|
139
|
+
"accepted base #{accepted_base[:branch]}; patch-range proof cannot discard it")
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
transition_proof(record)
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# The manifest is only a claim: the declared source identity must
|
|
146
|
+
# match the candidate as it exists right now.
|
|
147
|
+
def verify_source_identity(worktree_path, live_head, record)
|
|
148
|
+
actual_repo = source_repo_for(worktree_path)
|
|
149
|
+
if actual_repo.nil? || !same_path?(actual_repo, record.source_repo)
|
|
150
|
+
return blocked("declared source_repo #{record.source_repo} is not the candidate's common repository")
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
if record.resolved_source_head != live_head
|
|
154
|
+
return blocked("declared source_head #{record.source_head[0, 12]} does not match the candidate's " \
|
|
155
|
+
"current HEAD #{live_head[0, 12]}")
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
nil
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
# @return [Models::PreservationProof, nil] Proof when trees match
|
|
162
|
+
def tree_equality_proof(record)
|
|
163
|
+
source_tree = rev_parse(record.source_repo, "--verify", "#{record.resolved_source_head}^{tree}")
|
|
164
|
+
destination_tree = rev_parse(record.destination_repo, "--verify", "#{record.resolved_destination_head}^{tree}")
|
|
165
|
+
if source_tree.nil? || destination_tree.nil?
|
|
166
|
+
return blocked("cannot resolve source/destination trees for tree comparison")
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
if source_tree == destination_tree
|
|
170
|
+
Models::PreservationProof.preserved(:tree_equality, head: record.resolved_source_head)
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def patch_proof_allowed?(record, recorded_base)
|
|
175
|
+
return false if recorded_base.nil?
|
|
176
|
+
|
|
177
|
+
recorded_base == record.resolved_source_base
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
# A destination that IS the candidate's own branch ref is scheduled
|
|
181
|
+
# for deletion with the worktree and cannot survive as the place the
|
|
182
|
+
# work was preserved. Identity is by ref name, not tip: two distinct
|
|
183
|
+
# refs may legitimately point at the same commit, and deleting the
|
|
184
|
+
# candidate ref leaves the other intact. A destination must name a
|
|
185
|
+
# branch ref — a raw SHA can masquerade as a destination while prune
|
|
186
|
+
# deletes the only real branch.
|
|
187
|
+
def verify_destination_survival(record, candidate_branch)
|
|
188
|
+
unless record.destination_branch.to_s.start_with?("refs/heads/")
|
|
189
|
+
return blocked("declared destination_branch #{record.destination_branch} must name a branch ref " \
|
|
190
|
+
"(refs/heads/...)")
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
return nil unless same_path?(record.source_repo, record.destination_repo)
|
|
194
|
+
|
|
195
|
+
if !candidate_branch.to_s.empty? && record.destination_branch_name == candidate_branch
|
|
196
|
+
return blocked("declared destination branch #{record.destination_branch} is the candidate's own " \
|
|
197
|
+
"branch scheduled for deletion and cannot be its surviving destination")
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
nil
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
def verify_destination_acceptance(record)
|
|
204
|
+
branch_tip = rev_parse(record.destination_repo, "--verify", record.destination_branch)
|
|
205
|
+
if branch_tip.nil?
|
|
206
|
+
return blocked("declared destination branch #{record.destination_branch} does not exist in " \
|
|
207
|
+
"#{record.destination_repo}")
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
unless ancestry?(record.destination_repo, record.resolved_destination_head, branch_tip)
|
|
211
|
+
return blocked("declared destination_head #{record.destination_head[0, 12]} is not accepted on " \
|
|
212
|
+
"#{record.destination_branch}")
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
unless ancestry?(record.destination_repo, record.resolved_destination_base, branch_tip)
|
|
216
|
+
return blocked("declared destination_base #{record.destination_base[0, 12]} is not accepted on " \
|
|
217
|
+
"#{record.destination_branch}")
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
nil
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
# The declared ranges must be non-empty: a caller selected empty or
|
|
224
|
+
# truncated range is not evidence. Content correspondence is proven
|
|
225
|
+
# by the exact transition comparison itself — a squash that lands
|
|
226
|
+
# the same content in fewer commits compares equal, a different
|
|
227
|
+
# content never does.
|
|
228
|
+
def verify_range(record)
|
|
229
|
+
source_range = commit_count(record.source_repo, record.resolved_source_base, record.resolved_source_head)
|
|
230
|
+
destination_range = commit_count(
|
|
231
|
+
record.destination_repo, record.resolved_destination_base, record.resolved_destination_head
|
|
232
|
+
)
|
|
233
|
+
if source_range.nil? || destination_range.nil?
|
|
234
|
+
return blocked("cannot count the declared source/destination ranges")
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
if source_range.zero? || destination_range.zero?
|
|
238
|
+
blocked("empty-range identity is not proof; full-tree equality or accepted ancestry required")
|
|
239
|
+
end
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
# Exact path-by-path comparison of the base..head transition on both
|
|
243
|
+
# sides: identical changed paths, modes and blob SHAs. Blob SHAs are
|
|
244
|
+
# content addresses, so equality covers binary content and symlinks;
|
|
245
|
+
# renames compare as delete plus add (--no-renames).
|
|
246
|
+
def transition_proof(record)
|
|
247
|
+
source_transition = transition_map(record.source_repo, record.resolved_source_base, record.resolved_source_head)
|
|
248
|
+
destination_transition = transition_map(
|
|
249
|
+
record.destination_repo, record.resolved_destination_base, record.resolved_destination_head
|
|
250
|
+
)
|
|
251
|
+
if source_transition.nil? || destination_transition.nil?
|
|
252
|
+
return blocked("cannot compare source/destination transitions")
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
if source_transition == destination_transition
|
|
256
|
+
Models::PreservationProof.preserved(:content_transition, head: record.resolved_source_head)
|
|
257
|
+
else
|
|
258
|
+
blocked("declared destination content differs from the source transition")
|
|
259
|
+
end
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
# @return [Hash, nil] `{path => [src_mode, dst_mode, src_blob, dst_blob]}`
|
|
263
|
+
def transition_map(repo, base, head)
|
|
264
|
+
stdout, _stderr, status = git_runner.call(
|
|
265
|
+
repo, "diff-tree", "-r", "--no-renames", "--raw", "-z", base, head
|
|
266
|
+
)
|
|
267
|
+
return nil unless status.success?
|
|
268
|
+
|
|
269
|
+
entries = stdout.to_s.split("\0")
|
|
270
|
+
map = {}
|
|
271
|
+
index = 0
|
|
272
|
+
while index < entries.length
|
|
273
|
+
meta = entries[index]
|
|
274
|
+
index += 1
|
|
275
|
+
break if meta.nil? || meta.empty?
|
|
276
|
+
|
|
277
|
+
path = entries[index]
|
|
278
|
+
index += 1
|
|
279
|
+
next if path.nil?
|
|
280
|
+
|
|
281
|
+
_colon, src_mode, dst_mode, src_blob, dst_blob = meta.split(" ")
|
|
282
|
+
map[path] = [src_mode, dst_mode, src_blob, dst_blob]
|
|
283
|
+
end
|
|
284
|
+
map
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
def commit_count(repo, base, head)
|
|
288
|
+
stdout, _stderr, status = git_runner.call(repo, "rev-list", "--count", "#{base}..#{head}")
|
|
289
|
+
status.success? ? stdout.to_s.strip.to_i : nil
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
def ancestry?(repo, revision, base)
|
|
293
|
+
_stdout, _stderr, status = git_runner.call(repo, "merge-base", "--is-ancestor", revision, base)
|
|
294
|
+
status.success?
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
def rev_parse(repo, *args)
|
|
298
|
+
stdout, _stderr, status = git_runner.call(repo, "rev-parse", *args)
|
|
299
|
+
status.success? ? stdout.to_s.strip : nil
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
def same_path?(left, right)
|
|
303
|
+
File.realpath(left) == File.realpath(right)
|
|
304
|
+
rescue Errno::ENOENT, Errno::EACCES
|
|
305
|
+
false
|
|
306
|
+
end
|
|
307
|
+
|
|
308
|
+
def blocked(reason)
|
|
309
|
+
Models::PreservationProof.blocked(reason)
|
|
310
|
+
end
|
|
311
|
+
end
|
|
312
|
+
end
|
|
313
|
+
end
|
|
314
|
+
end
|
|
@@ -29,6 +29,27 @@ module Ace
|
|
|
29
29
|
rescue JSON::ParserError => error
|
|
30
30
|
raise Error, "Lab returned invalid JSON: #{error.message}"
|
|
31
31
|
end
|
|
32
|
+
|
|
33
|
+
# Whether this surface can make the no-writer state check and the
|
|
34
|
+
# destruction one atomic operation. The raw CLI cannot: between
|
|
35
|
+
# `work status` and `work destroy` another writer may start. Prune
|
|
36
|
+
# treats the path as unsupported (preserve) unless the adapter
|
|
37
|
+
# guarantees atomicity.
|
|
38
|
+
def supports_atomic_destroy?
|
|
39
|
+
false
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# The authoritative status entry for one Work, or nil when the Work
|
|
43
|
+
# is absent from the surface. Missing state is a blocking condition
|
|
44
|
+
# for prune — never treated as safe.
|
|
45
|
+
#
|
|
46
|
+
# @param work_id [String] Exact Lab Work ID
|
|
47
|
+
# @return [Hash, nil] Status entry or nil
|
|
48
|
+
def work_entry(work_id)
|
|
49
|
+
data = call("work", "status", "--json")
|
|
50
|
+
works = data.is_a?(Hash) ? Array(data["works"]) : []
|
|
51
|
+
works.find { |entry| entry.is_a?(Hash) && entry["id"].to_s == work_id.to_s }
|
|
52
|
+
end
|
|
32
53
|
end
|
|
33
54
|
end
|
|
34
55
|
end
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ace
|
|
4
|
+
module Overseer
|
|
5
|
+
module Molecules
|
|
6
|
+
# Fail-closed Lab Work classification for prune.
|
|
7
|
+
#
|
|
8
|
+
# A Lab Work may only be destroyed when the authoritative Lab surface
|
|
9
|
+
# reports a documented terminal state AND preservation evidence is
|
|
10
|
+
# available (declared destination proof). Active work, unknown states,
|
|
11
|
+
# unreadable state, or missing preservation evidence block the
|
|
12
|
+
# candidate — including under --yes. When the Lab surface cannot supply
|
|
13
|
+
# the state, the path reports blocked rather than assuming safety.
|
|
14
|
+
class LabPruneSafetyChecker
|
|
15
|
+
STATES_KEY = %w[state status].freeze
|
|
16
|
+
TERMINAL_STATES = %w[completed done archived cancelled].freeze
|
|
17
|
+
ACTIVE_STATES = %w[
|
|
18
|
+
running in_flight in-flight in_progress in-progress
|
|
19
|
+
active pending reserved claimed started queued
|
|
20
|
+
].freeze
|
|
21
|
+
|
|
22
|
+
Classification = Struct.new(:safe?, :reason, keyword_init: true)
|
|
23
|
+
|
|
24
|
+
# @param lab_client [LabClient] Lab surface adapter
|
|
25
|
+
# @param work_id [String] Exact Lab Work ID
|
|
26
|
+
# @param preservation_proof [Models::PreservationProof, nil] Proof
|
|
27
|
+
# built from the Work's documented preservation data
|
|
28
|
+
# @return [Classification]
|
|
29
|
+
def check(lab_client:, work_id:, preservation_proof: nil)
|
|
30
|
+
entry = read_entry(lab_client, work_id)
|
|
31
|
+
return classify(false, entry) if entry.is_a?(String)
|
|
32
|
+
|
|
33
|
+
value = STATES_KEY.filter_map { |key| entry[key] }.find { |state| state.is_a?(String) }
|
|
34
|
+
return classify(false, "lab state for #{work_id} is unreadable") if value.nil?
|
|
35
|
+
|
|
36
|
+
normalized = value.strip.downcase
|
|
37
|
+
return classify(false, "lab work #{work_id} is #{normalized} (active writer)") if ACTIVE_STATES.include?(normalized)
|
|
38
|
+
|
|
39
|
+
unless TERMINAL_STATES.include?(normalized)
|
|
40
|
+
return classify(false, "lab work #{work_id} state #{normalized.inspect} is not a documented terminal state")
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
if preservation_proof.nil?
|
|
44
|
+
return classify(false, "no preservation evidence for lab work #{work_id}; " \
|
|
45
|
+
"the Lab surface does not document surviving repo/head/branch data")
|
|
46
|
+
end
|
|
47
|
+
unless preservation_proof.preserved?
|
|
48
|
+
return classify(false, preservation_proof.reason)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
Classification.new(safe?: true, reason: nil)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
private
|
|
55
|
+
|
|
56
|
+
def read_entry(lab_client, work_id)
|
|
57
|
+
entry = lab_client.work_entry(work_id)
|
|
58
|
+
entry.nil? ? "lab work #{work_id} not found in authoritative status (missing state)" : entry
|
|
59
|
+
rescue Ace::Overseer::Error => e
|
|
60
|
+
"lab state for #{work_id} is unavailable: #{e.message}"
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def classify(safe, reason)
|
|
64
|
+
Classification.new(safe?: safe, reason: reason)
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|