ace-assign 0.57.1 → 0.60.0
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/.ace-defaults/assign/config.yml +13 -0
- data/CHANGELOG.md +46 -12
- data/docs/exit-codes.md +10 -0
- data/docs/usage.md +49 -0
- data/handbook/workflow-instructions/assign/drive.wf.md +64 -8
- data/lib/ace/assign/atoms/assignment_scope.rb +53 -0
- data/lib/ace/assign/atoms/attempt_state_machine.rb +76 -0
- data/lib/ace/assign/atoms/evidence_digest.rb +78 -0
- data/lib/ace/assign/cli/commands/attempt/base.rb +35 -0
- data/lib/ace/assign/cli/commands/attempt/evidence.rb +28 -0
- data/lib/ace/assign/cli/commands/attempt/finish.rb +35 -0
- data/lib/ace/assign/cli/commands/attempt/reconcile.rb +38 -0
- data/lib/ace/assign/cli/commands/attempt/start.rb +45 -0
- data/lib/ace/assign/cli/commands/attempt/status.rb +54 -0
- data/lib/ace/assign/cli/commands/status.rb +24 -1
- data/lib/ace/assign/cli.rb +34 -2
- data/lib/ace/assign/models/assignment.rb +21 -4
- data/lib/ace/assign/models/attempt.rb +178 -0
- data/lib/ace/assign/models/attempt_binding.rb +116 -0
- data/lib/ace/assign/models/evidence_event.rb +82 -0
- data/lib/ace/assign/models/execution_receipt.rb +139 -0
- data/lib/ace/assign/molecules/assignment_manager.rb +35 -3
- data/lib/ace/assign/molecules/attempt_reconciler.rb +88 -0
- data/lib/ace/assign/molecules/attempt_store.rb +201 -0
- data/lib/ace/assign/molecules/evidence_calculator.rb +144 -91
- data/lib/ace/assign/molecules/evidence_journal.rb +423 -0
- data/lib/ace/assign/molecules/execution_identity_resolver.rb +97 -0
- data/lib/ace/assign/molecules/fork_session_launcher.rb +38 -19
- data/lib/ace/assign/molecules/lifecycle_exclusion.rb +209 -0
- data/lib/ace/assign/molecules/receipt_verifier.rb +272 -0
- data/lib/ace/assign/organisms/assignment_executor.rb +12 -4
- data/lib/ace/assign/organisms/attempt_coordinator.rb +618 -0
- data/lib/ace/assign/organisms/task_assignment_creator.rb +1 -1
- data/lib/ace/assign/version.rb +1 -1
- data/lib/ace/assign.rb +56 -0
- metadata +36 -2
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
require "fileutils"
|
|
5
|
+
require "json"
|
|
6
|
+
require "open3"
|
|
7
|
+
require "time"
|
|
8
|
+
|
|
9
|
+
module Ace
|
|
10
|
+
module Assign
|
|
11
|
+
module Molecules
|
|
12
|
+
# Durable start/prune exclusion for assignment and worktree identities.
|
|
13
|
+
#
|
|
14
|
+
# Lock files live OUTSIDE every deletion target — under the shared Git
|
|
15
|
+
# common dir — so they survive removal of the assignment cache, the
|
|
16
|
+
# worktree, or a recreated lock pathname. Prune holds the exclusive
|
|
17
|
+
# side from its final evidence reads through completion of removal;
|
|
18
|
+
# every supported start path (attempt registration, fork/driver
|
|
19
|
+
# session launch, worktree provisioning) holds the shared side while
|
|
20
|
+
# registering a writer.
|
|
21
|
+
#
|
|
22
|
+
# A `removed` marker recorded by prune makes post-deletion starts fail
|
|
23
|
+
# closed instead of recreating the deleted identity: acquiring the
|
|
24
|
+
# shared side raises {AttemptErrors::Conflict} when the marker is
|
|
25
|
+
# present. Legitimate fresh provisioning (a new worktree for a task)
|
|
26
|
+
# clears the marker explicitly via `reset_removed`.
|
|
27
|
+
class LifecycleExclusion
|
|
28
|
+
# @param repo_root [String, nil] Repository used to resolve the
|
|
29
|
+
# shared Git common dir (default: project root)
|
|
30
|
+
# @param root [String, nil] Explicit exclusion root (tests)
|
|
31
|
+
def initialize(repo_root: nil, root: nil)
|
|
32
|
+
@root = root || self.class.default_root(repo_root)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# The exclusion root shared by every writer and prune for this repo.
|
|
36
|
+
# Sandboxed environments (CACHE_BASE) scope it under the cache base,
|
|
37
|
+
# which itself is never a deletion target; otherwise it lives under
|
|
38
|
+
# the shared Git common dir, which survives worktree removal.
|
|
39
|
+
def self.default_root(_repo_root = nil)
|
|
40
|
+
cache_base = ENV["CACHE_BASE"]
|
|
41
|
+
if cache_base && !cache_base.empty?
|
|
42
|
+
return File.join(File.expand_path(cache_base), ".exclusion")
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
repo_root = Ace::Support::Fs::Molecules::ProjectRootFinder.find_or_current
|
|
46
|
+
common_dir, _stderr, status = Open3.capture3(
|
|
47
|
+
"git", "-C", repo_root, "rev-parse", "--path-format=absolute", "--git-common-dir"
|
|
48
|
+
)
|
|
49
|
+
base = status.success? && !common_dir.to_s.strip.empty? ? common_dir.strip : File.join(repo_root, ".git")
|
|
50
|
+
File.join(base, "ace", "lifecycle-exclusion")
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def assignment_key(assignment_id)
|
|
54
|
+
"assignment:#{assignment_id}"
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Task identity is the canonical worktree identity: work-on derives
|
|
58
|
+
# the worktree path from the task ref, and prune candidates carry
|
|
59
|
+
# the same task id.
|
|
60
|
+
def task_key(task_id)
|
|
61
|
+
"task:#{task_id}"
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def worktree_key(worktree_path)
|
|
65
|
+
canonical = begin
|
|
66
|
+
File.realpath(worktree_path.to_s)
|
|
67
|
+
rescue Errno::ENOENT, Errno::EACCES
|
|
68
|
+
File.expand_path(worktree_path.to_s)
|
|
69
|
+
end
|
|
70
|
+
"worktree:#{canonical}"
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Hold the exclusive side while recomputing proofs and removing the
|
|
74
|
+
# target. Start paths block for the whole block.
|
|
75
|
+
#
|
|
76
|
+
# @param key [String] Identity key
|
|
77
|
+
# @yield Required block executed under exclusive exclusion
|
|
78
|
+
def with_exclusive(key)
|
|
79
|
+
raise ArgumentError, "exclusion key is required" if key.to_s.empty?
|
|
80
|
+
|
|
81
|
+
lock = open_lock(key)
|
|
82
|
+
lock.flock(File::LOCK_EX)
|
|
83
|
+
begin
|
|
84
|
+
yield
|
|
85
|
+
ensure
|
|
86
|
+
lock.flock(File::LOCK_UN)
|
|
87
|
+
lock.close
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Hold the shared side while registering a writer or creating a
|
|
92
|
+
# resource. Fails closed when the identity was pruned.
|
|
93
|
+
#
|
|
94
|
+
# @param key [String] Identity key
|
|
95
|
+
# @param reset_removed [Boolean] Clear a stale removed marker instead
|
|
96
|
+
# of raising (legitimate fresh provisioning of the same pathname)
|
|
97
|
+
def with_shared(key, reset_removed: false)
|
|
98
|
+
raise ArgumentError, "exclusion key is required" if key.to_s.empty?
|
|
99
|
+
|
|
100
|
+
lock = open_lock(key)
|
|
101
|
+
lock.flock(File::LOCK_SH)
|
|
102
|
+
begin
|
|
103
|
+
if removed?(key)
|
|
104
|
+
unless reset_removed
|
|
105
|
+
raise AttemptErrors::Conflict, removed_message(key)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
clear_removed!(key)
|
|
109
|
+
end
|
|
110
|
+
yield
|
|
111
|
+
ensure
|
|
112
|
+
lock.flock(File::LOCK_UN)
|
|
113
|
+
lock.close
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# Hold the shared side on several identities at once (acquired in the
|
|
118
|
+
# given order, released in reverse — callers must use one consistent
|
|
119
|
+
# global order, task before assignment, to avoid deadlock). Fails
|
|
120
|
+
# closed when any identity was pruned.
|
|
121
|
+
def with_shared_multi(keys, reset_removed: false)
|
|
122
|
+
ordered = Array(keys).compact.reject { |key| key.to_s.empty? }.uniq
|
|
123
|
+
raise ArgumentError, "exclusion keys are required" if ordered.empty?
|
|
124
|
+
|
|
125
|
+
locks = ordered.map { |key| open_lock(key) }
|
|
126
|
+
begin
|
|
127
|
+
locks.each { |lock| lock.flock(File::LOCK_SH) }
|
|
128
|
+
ordered.each do |key|
|
|
129
|
+
if removed?(key)
|
|
130
|
+
unless reset_removed
|
|
131
|
+
raise AttemptErrors::Conflict, removed_message(key)
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
clear_removed!(key)
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
yield
|
|
138
|
+
ensure
|
|
139
|
+
locks.reverse_each do |lock|
|
|
140
|
+
lock.flock(File::LOCK_UN)
|
|
141
|
+
lock.close
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# Record that the identity was removed. Call while holding the
|
|
147
|
+
# exclusive side, BEFORE deleting the target.
|
|
148
|
+
def record_removed!(key)
|
|
149
|
+
atomic_write(
|
|
150
|
+
state_path(key),
|
|
151
|
+
JSON.generate({"removed" => true, "removed_at" => Time.now.utc.iso8601, "key" => key})
|
|
152
|
+
)
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def removed?(key)
|
|
156
|
+
state = JSON.parse(File.read(state_path(key)))
|
|
157
|
+
state["removed"] == true
|
|
158
|
+
rescue Errno::ENOENT, JSON::ParserError, TypeError
|
|
159
|
+
false
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def clear_removed!(key)
|
|
163
|
+
File.delete(state_path(key))
|
|
164
|
+
rescue Errno::ENOENT
|
|
165
|
+
nil
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
private
|
|
169
|
+
|
|
170
|
+
attr_reader :root
|
|
171
|
+
|
|
172
|
+
def open_lock(key)
|
|
173
|
+
FileUtils.mkdir_p(root)
|
|
174
|
+
File.open(lock_path(key), File::RDWR | File::CREAT)
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def lock_path(key)
|
|
178
|
+
File.join(root, "#{Digest::SHA256.hexdigest(key)}.lock")
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def state_path(key)
|
|
182
|
+
File.join(root, "#{Digest::SHA256.hexdigest(key)}.state.json")
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def removed_message(key)
|
|
186
|
+
identity = key.to_s.sub(/\A[^:]+:/, "")
|
|
187
|
+
case key
|
|
188
|
+
when /\Aassignment:/
|
|
189
|
+
"Assignment #{identity} was pruned; refusing to start a writer on a removed identity"
|
|
190
|
+
when /\Atask:/
|
|
191
|
+
"Task #{identity} worktree was pruned; refusing to start a writer on a removed identity"
|
|
192
|
+
else
|
|
193
|
+
"Worktree #{identity} was pruned; refusing to start a writer on a removed identity"
|
|
194
|
+
end
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
def atomic_write(path, content)
|
|
198
|
+
dir = File.dirname(path)
|
|
199
|
+
FileUtils.mkdir_p(dir)
|
|
200
|
+
temp = File.join(dir, ".tmp-#{Process.pid}-#{rand(1_000_000)}")
|
|
201
|
+
File.write(temp, content)
|
|
202
|
+
File.rename(temp, path)
|
|
203
|
+
ensure
|
|
204
|
+
File.delete(temp) if temp && File.exist?(temp)
|
|
205
|
+
end
|
|
206
|
+
end
|
|
207
|
+
end
|
|
208
|
+
end
|
|
209
|
+
end
|
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
require "json"
|
|
5
|
+
|
|
6
|
+
module Ace
|
|
7
|
+
module Assign
|
|
8
|
+
module Molecules
|
|
9
|
+
# Canonical receipt validation for accepted execution evidence.
|
|
10
|
+
#
|
|
11
|
+
# Verifies schema and field hygiene, attempt binding, producer
|
|
12
|
+
# attribution, transition authority, current-head candidate binding,
|
|
13
|
+
# artifact digests, executed checks, and independent review verdicts.
|
|
14
|
+
# Every failure is a rejection with an actionable reason; a receipt is
|
|
15
|
+
# never partially accepted.
|
|
16
|
+
class ReceiptVerifier
|
|
17
|
+
# Operations that act outside the local repository once executed.
|
|
18
|
+
EXTERNAL_EFFECT_OPERATIONS = %w[merge publish deploy release].freeze
|
|
19
|
+
|
|
20
|
+
REVIEW_OPERATION = "review"
|
|
21
|
+
HEAD_PATTERN = /\A[0-9a-f]{7,64}\z/.freeze
|
|
22
|
+
APPROVED_VERDICT = "approved"
|
|
23
|
+
|
|
24
|
+
# @param identity_resolver [ExecutionIdentityResolver] Trust boundary
|
|
25
|
+
def initialize(identity_resolver: nil)
|
|
26
|
+
@identity_resolver = identity_resolver || ExecutionIdentityResolver.new
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Validate a submitted receipt against an attempt.
|
|
30
|
+
#
|
|
31
|
+
# @param data [Hash] Parsed receipt payload
|
|
32
|
+
# @param attempt [Models::Attempt] Target attempt (loaded by coordinator)
|
|
33
|
+
# @param identity [ExecutionIdentityResolver::Identity] Finishing boundary identity
|
|
34
|
+
# @param live_head [String] Current candidate revision (git rev-parse HEAD)
|
|
35
|
+
# @param repo_root [String] Project root for artifact resolution
|
|
36
|
+
# @return [Models::ExecutionReceipt] Normalized receipt with digest
|
|
37
|
+
# @raise [AttemptErrors::ReceiptRejected] on any validation failure
|
|
38
|
+
def verify!(data, attempt:, identity:, live_head:, repo_root:)
|
|
39
|
+
reject_unless(data.is_a?(Hash), "receipt must be a JSON object")
|
|
40
|
+
|
|
41
|
+
if Models::ExecutionReceipt.forbidden_field?(data)
|
|
42
|
+
reject("receipt carries forbidden fields (credentials, environment, or terminal output)")
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
required = %w[attempt_id assignment_id project_id scope operation producer head verdict]
|
|
46
|
+
missing = required.select { |key| data[key].nil? || data[key].to_s.strip.empty? }
|
|
47
|
+
reject("missing required fields: #{missing.join(', ')}") unless missing.empty?
|
|
48
|
+
unless Models::ExecutionReceipt::VERDICTS.include?(data["verdict"].to_s)
|
|
49
|
+
reject("unsupported verdict '#{data['verdict']}': must be one of #{Models::ExecutionReceipt::VERDICTS.join(', ')}")
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
verify_binding(data, attempt)
|
|
53
|
+
verify_producer(data)
|
|
54
|
+
verify_authority(data, identity)
|
|
55
|
+
verify_head(data, live_head)
|
|
56
|
+
verify_artifacts(data, repo_root) if data["verdict"] == "succeeded"
|
|
57
|
+
verify_checks(data)
|
|
58
|
+
verify_review(data)
|
|
59
|
+
verify_campaign(data, repo_root: repo_root, live_head: live_head)
|
|
60
|
+
|
|
61
|
+
receipt = Models::ExecutionReceipt.from_h(data.merge("recorded_at" => Time.now.utc))
|
|
62
|
+
verify_digest(receipt)
|
|
63
|
+
|
|
64
|
+
receipt
|
|
65
|
+
end
|
|
66
|
+
# Recheck the source artifacts/checks of an already accepted receipt.
|
|
67
|
+
def verify_accepted_evidence!(data, live_head:, repo_root:)
|
|
68
|
+
verify_head(data, live_head)
|
|
69
|
+
verify_artifacts(data, repo_root)
|
|
70
|
+
verify_checks(data)
|
|
71
|
+
verify_review(data)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# @param operation [String] Receipt operation
|
|
75
|
+
# @return [Boolean] True when the operation acts outside the repository
|
|
76
|
+
def external_effect?(operation)
|
|
77
|
+
EXTERNAL_EFFECT_OPERATIONS.include?(operation.to_s)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
private
|
|
81
|
+
|
|
82
|
+
def verify_binding(data, attempt)
|
|
83
|
+
binding = attempt.binding
|
|
84
|
+
unless data["attempt_id"] == attempt.attempt_id
|
|
85
|
+
reject("receipt attempt_id #{data['attempt_id']} does not match attempt #{attempt.attempt_id}")
|
|
86
|
+
end
|
|
87
|
+
unless data["assignment_id"] == binding.assignment_id
|
|
88
|
+
reject("receipt assignment #{data['assignment_id']} does not match attempt assignment #{binding.assignment_id}")
|
|
89
|
+
end
|
|
90
|
+
unless data["project_id"] == binding.project_id
|
|
91
|
+
reject("receipt project #{data['project_id']} does not match attempt project #{binding.project_id}")
|
|
92
|
+
end
|
|
93
|
+
unless Atoms::AssignmentScope.equal?(data["scope"], binding.scope)
|
|
94
|
+
reject("receipt scope #{data['scope']} does not match attempt scope #{binding.scope}")
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def verify_producer(data)
|
|
99
|
+
producer = data["producer"]
|
|
100
|
+
reject_unless(producer.is_a?(Hash), "producer must be an object with actor, role, runtime")
|
|
101
|
+
|
|
102
|
+
actor = producer["actor"].to_s.strip
|
|
103
|
+
role = producer["role"].to_s.strip
|
|
104
|
+
runtime = producer["runtime"].to_s.strip
|
|
105
|
+
if actor.empty? || runtime.empty? || !ExecutionIdentityResolver::ROLES.include?(role)
|
|
106
|
+
reject("producer attribution incomplete: actor, runtime, and a known role are required")
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# Workers submit attributable results but cannot self-approve
|
|
111
|
+
# succeeded outcomes; only coordinator/service authority accepts them.
|
|
112
|
+
def verify_authority(data, identity)
|
|
113
|
+
return if data["verdict"] != "succeeded"
|
|
114
|
+
return if @identity_resolver.trusted?(identity)
|
|
115
|
+
|
|
116
|
+
reject("identity #{identity.actor}/#{identity.role} may not accept a succeeded verdict")
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# The tested/reviewed head must be the live candidate revision; stale
|
|
120
|
+
# heads are rejected (candidate invalidation is recorded by the
|
|
121
|
+
# coordinator).
|
|
122
|
+
def verify_head(data, live_head)
|
|
123
|
+
reject("live candidate head unavailable") if live_head.nil? || live_head.to_s.strip.empty?
|
|
124
|
+
reject("receipt head '#{data['head']}' is not a git SHA") unless data["head"].match?(HEAD_PATTERN)
|
|
125
|
+
|
|
126
|
+
unless data["head"] == live_head
|
|
127
|
+
reject("receipt head #{data['head']} is stale; current candidate is #{live_head}")
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# Succeeded outcomes need at least one artifact whose recorded digest
|
|
132
|
+
# matches the file on disk. Paths stay below the project root; symlink
|
|
133
|
+
# or traversal escapes are rejected.
|
|
134
|
+
def verify_artifacts(data, repo_root)
|
|
135
|
+
artifacts = data["artifacts"]
|
|
136
|
+
reject_unless(artifacts.is_a?(Array) && !artifacts.empty?, "succeeded verdict requires at least one artifact")
|
|
137
|
+
|
|
138
|
+
artifacts.each do |artifact|
|
|
139
|
+
reject_unless(artifact.is_a?(Hash), "artifact entries must be objects")
|
|
140
|
+
path = artifact["path"].to_s
|
|
141
|
+
recorded = artifact["sha256"].to_s
|
|
142
|
+
reject("artifact missing path or sha256") if path.empty? || recorded.empty?
|
|
143
|
+
|
|
144
|
+
expanded = begin
|
|
145
|
+
File.expand_path(path, File.realpath(repo_root))
|
|
146
|
+
rescue Errno::ENOENT, Errno::EACCES
|
|
147
|
+
nil
|
|
148
|
+
end
|
|
149
|
+
reject("artifact file not found: #{path}") if expanded.nil? || !File.exist?(expanded)
|
|
150
|
+
|
|
151
|
+
resolved = safe_resolve(repo_root, path)
|
|
152
|
+
reject("artifact path escapes project root: #{path}") if resolved.nil?
|
|
153
|
+
|
|
154
|
+
actual = Digest::SHA256.hexdigest(File.read(resolved))
|
|
155
|
+
reject("artifact digest mismatch for #{path}") unless actual == recorded
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def verify_checks(data)
|
|
160
|
+
checks = data["checks"] || []
|
|
161
|
+
reject_unless(checks.is_a?(Array), "checks must be an array")
|
|
162
|
+
if data["verdict"] == "succeeded" && checks.empty?
|
|
163
|
+
reject("succeeded verdict requires at least one executed check")
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
checks.each do |check|
|
|
167
|
+
reject_unless(check.is_a?(Hash), "check entries must be objects")
|
|
168
|
+
reject("check missing name") if check["name"].to_s.strip.empty?
|
|
169
|
+
reject("check #{check['name']} did not pass") if check["verdict"].to_s != "passed" && data["verdict"] == "succeeded"
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
# A caller-supplied digest must equal the canonical digest of the
|
|
174
|
+
# receipt payload; arbitrary or all-zero digests are rejected.
|
|
175
|
+
def verify_digest(receipt)
|
|
176
|
+
return if receipt.digest == Atoms::EvidenceDigest.digest(receipt.digest_payload)
|
|
177
|
+
|
|
178
|
+
reject("receipt digest mismatch: supplied digest does not match the canonical payload")
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
# Review receipts need an executed, independent reviewer verdict for
|
|
182
|
+
# exactly the tested head — a report existing is not approval, and a
|
|
183
|
+
# review receipt without a reviewer verdict is not evidence.
|
|
184
|
+
def verify_review(data)
|
|
185
|
+
review = data["review"]
|
|
186
|
+
if review.nil?
|
|
187
|
+
if data["operation"] == REVIEW_OPERATION && data["verdict"] == "succeeded"
|
|
188
|
+
reject("review receipt requires an executed independent reviewer verdict")
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
return
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
reject_unless(review.is_a?(Hash), "review must be an object")
|
|
195
|
+
reviewer = review["reviewer"]
|
|
196
|
+
reject_unless(reviewer.is_a?(Hash) && !reviewer["actor"].to_s.strip.empty?, "review must name an executed reviewer")
|
|
197
|
+
|
|
198
|
+
producer_actor = data.dig("producer", "actor").to_s.strip
|
|
199
|
+
if reviewer["actor"].to_s.strip == producer_actor
|
|
200
|
+
reject("reviewer and producer must differ (self-approval rejected)")
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
reject("review verdict must be '#{APPROVED_VERDICT}' for a succeeded receipt") if review["verdict"] != APPROVED_VERDICT && data["verdict"] == "succeeded"
|
|
204
|
+
unless review["head"] == data["head"]
|
|
205
|
+
reject("review head #{review['head']} does not match receipt head #{data['head']}")
|
|
206
|
+
end
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
# Consume ace-review's single campaign authority through the existing
|
|
210
|
+
# receipt boundary; a worker-supplied local result grants no authority.
|
|
211
|
+
def verify_campaign(data, repo_root:, live_head:)
|
|
212
|
+
campaign = data["campaign"]
|
|
213
|
+
return unless campaign
|
|
214
|
+
reject_unless(campaign.is_a?(Hash), "campaign must be an object")
|
|
215
|
+
reject("campaign result requires a succeeded review receipt") unless
|
|
216
|
+
data["operation"] == REVIEW_OPERATION && data["verdict"] == "succeeded"
|
|
217
|
+
reference = campaign["result"]
|
|
218
|
+
reject_unless(reference.is_a?(Hash), "campaign requires a result artifact reference")
|
|
219
|
+
unless Array(data["artifacts"]).include?(reference)
|
|
220
|
+
reject("campaign result must be included in verified receipt artifacts")
|
|
221
|
+
end
|
|
222
|
+
resolved = safe_resolve(repo_root, reference["path"].to_s)
|
|
223
|
+
reject("campaign result path escapes repository or is missing") unless resolved
|
|
224
|
+
result = JSON.parse(File.read(resolved))
|
|
225
|
+
reject_unless(result.is_a?(Hash), "campaign result must be an object")
|
|
226
|
+
require "ace/review"
|
|
227
|
+
current = Ace::Review::Organisms::CampaignManager.new(repo_root: repo_root).status(campaign["id"])
|
|
228
|
+
unless result["campaign_id"] == campaign["id"] && result["accepted"] == true &&
|
|
229
|
+
result["dry_run"] == false && current["accepted"] == true &&
|
|
230
|
+
result["result_identity"] == current["result_identity"] &&
|
|
231
|
+
result.dig("evidence", "current_head") == live_head && current.dig("evidence", "current_head") == live_head
|
|
232
|
+
reject("campaign result is stale, incomplete, blocked or does not match live authority")
|
|
233
|
+
end
|
|
234
|
+
approval = current["rounds"].last["approval"]
|
|
235
|
+
unless approval["producer"] == data.dig("producer", "actor") &&
|
|
236
|
+
approval["reviewer"] == data.dig("review", "reviewer", "actor") &&
|
|
237
|
+
approval["head"] == data["head"]
|
|
238
|
+
reject("campaign producer/reviewer attribution does not match independently validated receipt")
|
|
239
|
+
end
|
|
240
|
+
rescue JSON::ParserError, ArgumentError, TypeError, KeyError => e
|
|
241
|
+
reject("invalid campaign result: #{e.message}")
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
# Resolve a project-relative artifact path to its real location,
|
|
245
|
+
# rejecting traversal and symlink escapes (an in-repo symlink must
|
|
246
|
+
# not smuggle in an external file); returns nil when the path leaves
|
|
247
|
+
# the root.
|
|
248
|
+
def safe_resolve(repo_root, path)
|
|
249
|
+
root = File.realpath(repo_root)
|
|
250
|
+
candidate = File.expand_path(path, root)
|
|
251
|
+
return nil unless candidate == root || candidate.start_with?(root + File::SEPARATOR)
|
|
252
|
+
return nil unless File.exist?(candidate)
|
|
253
|
+
|
|
254
|
+
resolved = File.realpath(candidate)
|
|
255
|
+
return nil unless resolved == root || resolved.start_with?(root + File::SEPARATOR)
|
|
256
|
+
|
|
257
|
+
resolved
|
|
258
|
+
rescue Errno::ENOENT, Errno::EACCES
|
|
259
|
+
nil
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
def reject(message)
|
|
263
|
+
raise AttemptErrors::ReceiptRejected, message
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
def reject_unless(condition, message)
|
|
267
|
+
reject(message) unless condition
|
|
268
|
+
end
|
|
269
|
+
end
|
|
270
|
+
end
|
|
271
|
+
end
|
|
272
|
+
end
|
|
@@ -56,8 +56,12 @@ module Ace
|
|
|
56
56
|
#
|
|
57
57
|
# @param config_path [String] Path to job.yaml config
|
|
58
58
|
# @param parent_id [String, nil] Parent assignment ID for hierarchy linking
|
|
59
|
+
# @param task_id [String, nil] Source task ID attaching this assignment
|
|
60
|
+
# (also read from the job's assignment.task_id); nil = taskless
|
|
61
|
+
# @param project_id [String, nil] Project ID (also read from the job's
|
|
62
|
+
# assignment.project_id)
|
|
59
63
|
# @return [Hash] Result with assignment and first step
|
|
60
|
-
def start(config_path, parent_id: nil)
|
|
64
|
+
def start(config_path, parent_id: nil, task_id: nil, project_id: nil)
|
|
61
65
|
raise ConfigErrors::NotFound, "Config file not found: #{config_path}" unless File.exist?(config_path)
|
|
62
66
|
|
|
63
67
|
config = YAML.safe_load_file(config_path, permitted_classes: [Time, Date])
|
|
@@ -74,12 +78,14 @@ module Ace
|
|
|
74
78
|
steps_config = expand_sub_steps(steps_config)
|
|
75
79
|
steps_config = materialize_skill_backed_steps(steps_config)
|
|
76
80
|
|
|
77
|
-
# Create assignment
|
|
81
|
+
# Create assignment; explicit args win over job config keys
|
|
78
82
|
assignment = assignment_manager.create(
|
|
79
83
|
name: assignment_config["name"] || File.basename(config_path, ".yaml"),
|
|
80
84
|
description: assignment_config["description"],
|
|
81
85
|
source_config: config_path,
|
|
82
|
-
parent: parent_id
|
|
86
|
+
parent: parent_id,
|
|
87
|
+
task_id: task_id || assignment_config["task_id"],
|
|
88
|
+
project_id: project_id || assignment_config["project_id"]
|
|
83
89
|
)
|
|
84
90
|
|
|
85
91
|
# Create initial step files
|
|
@@ -108,7 +114,9 @@ module Ace
|
|
|
108
114
|
updated_at: assignment.updated_at,
|
|
109
115
|
source_config: archived_path,
|
|
110
116
|
cache_dir: assignment.cache_dir,
|
|
111
|
-
parent: assignment.parent
|
|
117
|
+
parent: assignment.parent,
|
|
118
|
+
task_id: assignment.task_id,
|
|
119
|
+
project_id: assignment.project_id
|
|
112
120
|
)
|
|
113
121
|
assignment_manager.update(assignment)
|
|
114
122
|
|