ace-assign 0.57.1 → 0.58.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 +13 -0
- data/docs/exit-codes.md +10 -0
- data/docs/usage.md +32 -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/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 +30 -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 +135 -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 +424 -0
- data/lib/ace/assign/molecules/execution_identity_resolver.rb +97 -0
- data/lib/ace/assign/molecules/receipt_verifier.rb +227 -0
- data/lib/ace/assign/organisms/assignment_executor.rb +9 -3
- data/lib/ace/assign/organisms/attempt_coordinator.rb +544 -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 +20 -2
|
@@ -0,0 +1,424 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "json"
|
|
5
|
+
require "open3"
|
|
6
|
+
|
|
7
|
+
module Ace
|
|
8
|
+
module Assign
|
|
9
|
+
module Molecules
|
|
10
|
+
# Append-only execution evidence journal backed by a dedicated Git ref.
|
|
11
|
+
#
|
|
12
|
+
# Accepted evidence for assignment attempts is committed to a separate
|
|
13
|
+
# configured evidence ref (default `refs/ace/execution`) in canonical,
|
|
14
|
+
# non-secret paths `execution/<assignment-id>/events/`. The ref lives
|
|
15
|
+
# outside the deliverable candidate branch: appending evidence never
|
|
16
|
+
# advances or changes the reviewed candidate. Working state uses an
|
|
17
|
+
# isolated, disposable detached worktree under the configured checkout
|
|
18
|
+
# root.
|
|
19
|
+
#
|
|
20
|
+
# Concurrency: all ref updates serialize on a checkout-root flock, and
|
|
21
|
+
# every update is additionally guarded by an expected-old-value
|
|
22
|
+
# `git update-ref` compare-and-swap. On a CAS conflict the journal
|
|
23
|
+
# reloads the authoritative ref and replays only still-valid,
|
|
24
|
+
# non-duplicate events.
|
|
25
|
+
class EvidenceJournal
|
|
26
|
+
CAS_ATTEMPTS = 3
|
|
27
|
+
|
|
28
|
+
# @param repo_root [String] Git repository root holding the evidence ref
|
|
29
|
+
# @param ref [String, nil] Evidence ref (default from config)
|
|
30
|
+
# @param checkout_root [String, nil] Isolated checkout root (default from config)
|
|
31
|
+
def initialize(repo_root:, ref: nil, checkout_root: nil)
|
|
32
|
+
@repo_root = repo_root
|
|
33
|
+
@ref = ref || default_config("evidence_git_ref") || "refs/ace/execution"
|
|
34
|
+
root = checkout_root || default_config("evidence_checkout_root") || ".ace-local/assign/evidence-checkout"
|
|
35
|
+
@checkout_root = Pathname.new(root).absolute? ? root : File.join(repo_root, root)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
attr_reader :repo_root, :ref, :checkout_root
|
|
39
|
+
|
|
40
|
+
# @return [Boolean] True when the repo can hold the evidence ref
|
|
41
|
+
def available?
|
|
42
|
+
git_ok?("rev-parse", "--git-dir")
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Current value of the evidence ref.
|
|
46
|
+
#
|
|
47
|
+
# @return [String, nil] Commit SHA or nil when the ref does not exist
|
|
48
|
+
def ref_value
|
|
49
|
+
out, stderr, status = git("rev-parse", "--verify", "--quiet", @ref)
|
|
50
|
+
return out if status.success?
|
|
51
|
+
|
|
52
|
+
raise AttemptErrors::EvidenceUnavailable, "Cannot read evidence ref #{@ref}: #{stderr}" if git_broken?(stderr)
|
|
53
|
+
|
|
54
|
+
nil
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Append accepted events to the journal under lock + CAS.
|
|
58
|
+
#
|
|
59
|
+
# @param assignment_id [String] Assignment ID (journal path segment)
|
|
60
|
+
# @param attempt_id [String] Attempt ID (commit message + dedupe scope)
|
|
61
|
+
# @param events [Array<Hash>] Event payloads from Models::EvidenceEvent.build
|
|
62
|
+
# @return [String] Journal commit SHA after the append
|
|
63
|
+
# @raise [AttemptErrors::EvidenceUnavailable] when the ref stays conflicting
|
|
64
|
+
def append(assignment_id:, attempt_id:, events:)
|
|
65
|
+
return ref_value if events.empty?
|
|
66
|
+
|
|
67
|
+
with_lock do
|
|
68
|
+
CAS_ATTEMPTS.times do
|
|
69
|
+
# Re-read the authoritative value every attempt. nil means the
|
|
70
|
+
# ref does not exist yet; ensure_checkout! seeds it, so re-read
|
|
71
|
+
# before computing the compare-and-swap baseline.
|
|
72
|
+
old = ref_value
|
|
73
|
+
ensure_checkout!
|
|
74
|
+
old = ref_value if old.nil?
|
|
75
|
+
sync_checkout(old)
|
|
76
|
+
written = write_event_files(assignment_id, events)
|
|
77
|
+
new_commit = written.zero? ? old : commit_events(assignment_id, attempt_id, events)
|
|
78
|
+
return new_commit if update_ref_cas(new_commit, old)
|
|
79
|
+
|
|
80
|
+
# CAS conflict: another writer advanced the ref. The next pass
|
|
81
|
+
# resets the checkout to the authoritative tree and replays
|
|
82
|
+
# only the events that are still missing (duplicates skipped).
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
raise AttemptErrors::EvidenceUnavailable,
|
|
86
|
+
"Evidence ref #{@ref} stayed conflicting after #{CAS_ATTEMPTS} compare-and-swap attempts"
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Read all journal events recorded for an assignment, ordered by the
|
|
91
|
+
# digest chain (never by filename: same-second events sort
|
|
92
|
+
# alphabetically, which can invert lifecycle order).
|
|
93
|
+
#
|
|
94
|
+
# @param assignment_id [String] Assignment ID
|
|
95
|
+
# @return [Array<Hash>] Parsed events in journal order
|
|
96
|
+
def read_events(assignment_id)
|
|
97
|
+
value = ref_value
|
|
98
|
+
return [] if value.nil?
|
|
99
|
+
|
|
100
|
+
with_lock do
|
|
101
|
+
ensure_checkout!
|
|
102
|
+
sync_checkout(value)
|
|
103
|
+
order_by_chain(event_files(assignment_id).map { |path| JSON.parse(File.read(path)) })
|
|
104
|
+
end
|
|
105
|
+
rescue JSON::ParserError => e
|
|
106
|
+
raise AttemptErrors::EvidenceUnavailable, "Corrupt journal event for #{assignment_id}: #{e.message}"
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# Accepted receipt payloads recorded for an assignment.
|
|
110
|
+
#
|
|
111
|
+
# @param assignment_id [String] Assignment ID
|
|
112
|
+
# @return [Array<Hash>] Accepted receipt payloads (digest included)
|
|
113
|
+
def accepted_receipts(assignment_id)
|
|
114
|
+
read_events(assignment_id)
|
|
115
|
+
.select { |event| event["type"] == "receipt_accepted" }
|
|
116
|
+
.map { |event| event.dig("payload", "receipt") }
|
|
117
|
+
.compact
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# All attempts derivable from the journal, reconstructed from intent
|
|
121
|
+
# and process_start facts with journal-authoritative state. The
|
|
122
|
+
# journal is the owner of attempt-state interpretation.
|
|
123
|
+
#
|
|
124
|
+
# @param assignment_id [String] Assignment ID
|
|
125
|
+
# @return [Array<Models::Attempt>] Derived attempts
|
|
126
|
+
def derived_attempts(assignment_id)
|
|
127
|
+
events = read_events(assignment_id)
|
|
128
|
+
by_attempt = events.group_by { |event| event["attempt_id"] }
|
|
129
|
+
by_attempt.delete(nil)
|
|
130
|
+
|
|
131
|
+
by_attempt.filter_map do |attempt_id, attempt_events|
|
|
132
|
+
intent = attempt_events.find { |event| event["type"] == "intent" }
|
|
133
|
+
next unless intent
|
|
134
|
+
|
|
135
|
+
state = derive_state(attempt_events)
|
|
136
|
+
next if state.nil?
|
|
137
|
+
|
|
138
|
+
build_attempt(assignment_id, attempt_id, intent["payload"], attempt_events, state)
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# Non-terminal attempts derived from the journal.
|
|
143
|
+
#
|
|
144
|
+
# @param assignment_id [String] Assignment ID
|
|
145
|
+
# @return [Array<Models::Attempt>] Active attempts
|
|
146
|
+
def active_attempts(assignment_id)
|
|
147
|
+
derived_attempts(assignment_id).reject(&:terminal?)
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# Assignment IDs present in the journal, discovered from the evidence
|
|
151
|
+
# ref itself (works even when the local assignment cache is gone).
|
|
152
|
+
#
|
|
153
|
+
# @return [Array<String>] Assignment IDs with journal evidence
|
|
154
|
+
def assignment_ids
|
|
155
|
+
value = ref_value
|
|
156
|
+
return [] if value.nil?
|
|
157
|
+
|
|
158
|
+
with_lock do
|
|
159
|
+
ensure_checkout!
|
|
160
|
+
sync_checkout(value)
|
|
161
|
+
Dir.glob(File.join(checkout_dir, "execution", "*"))
|
|
162
|
+
.select { |path| File.directory?(path) }
|
|
163
|
+
.map { |path| File.basename(path) }
|
|
164
|
+
.sort
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
private
|
|
169
|
+
|
|
170
|
+
# Latest lifecycle state implied by the events, or nil when the
|
|
171
|
+
# events do not describe a full attempt (intent missing).
|
|
172
|
+
def derive_state(events)
|
|
173
|
+
return nil unless events.any? { |event| event["type"] == "intent" }
|
|
174
|
+
|
|
175
|
+
state = "running"
|
|
176
|
+
events.each do |event|
|
|
177
|
+
case event["type"]
|
|
178
|
+
when "receipt_accepted"
|
|
179
|
+
state = event.dig("payload", "receipt", "verdict") || state
|
|
180
|
+
when "transition"
|
|
181
|
+
state = event.dig("payload", "to") || state
|
|
182
|
+
when "reconciliation"
|
|
183
|
+
state = event.dig("payload", "resolution") || state
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
state
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
def build_attempt(assignment_id, attempt_id, intent_payload, events, state)
|
|
190
|
+
process_start = events.reverse.find do |event|
|
|
191
|
+
event["type"] == "process_start" && event["attempt_id"] == attempt_id
|
|
192
|
+
end
|
|
193
|
+
candidate_head = events.select { |event| event["type"] == "receipt_accepted" }
|
|
194
|
+
.map { |event| event.dig("payload", "receipt", "head") }
|
|
195
|
+
.compact
|
|
196
|
+
.first
|
|
197
|
+
binding = Models::AttemptBinding.new(
|
|
198
|
+
attempt_id: attempt_id,
|
|
199
|
+
assignment_id: assignment_id,
|
|
200
|
+
scope: intent_payload["scope"],
|
|
201
|
+
project_id: intent_payload["project_id"],
|
|
202
|
+
task_id: intent_payload["task_id"],
|
|
203
|
+
actor: process_start&.dig("payload", "actor") || "recovered",
|
|
204
|
+
role: process_start&.dig("payload", "role") || "coordinator",
|
|
205
|
+
runtime: process_start&.dig("payload", "runtime") || "recovered",
|
|
206
|
+
base_head: intent_payload["base_head"],
|
|
207
|
+
evidence_git_ref: ref,
|
|
208
|
+
created_at: parse_event_time(intent_time(events, attempt_id))
|
|
209
|
+
)
|
|
210
|
+
Models::Attempt.new(
|
|
211
|
+
binding: binding,
|
|
212
|
+
state: state,
|
|
213
|
+
candidate_head: candidate_head,
|
|
214
|
+
journal_commit: ref_value
|
|
215
|
+
)
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
def intent_time(events, attempt_id)
|
|
219
|
+
intent = events.find { |event| event["type"] == "intent" && event["attempt_id"] == attempt_id }
|
|
220
|
+
intent&.dig("recorded_at")
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
def parse_event_time(value)
|
|
224
|
+
return Time.now.utc if value.nil?
|
|
225
|
+
|
|
226
|
+
require "time"
|
|
227
|
+
Time.parse(value)
|
|
228
|
+
rescue ArgumentError
|
|
229
|
+
Time.now.utc
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
private
|
|
233
|
+
|
|
234
|
+
def event_files(assignment_id)
|
|
235
|
+
Dir.glob(File.join(checkout_dir, "execution", assignment_id, "events", "*.json")).sort
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
# Order events by following previous_digest links from chain roots;
|
|
239
|
+
# orphaned events (unknown predecessor) keep filename order after the
|
|
240
|
+
# resolved chains.
|
|
241
|
+
def order_by_chain(events)
|
|
242
|
+
by_digest = {}
|
|
243
|
+
events.each { |event| by_digest[event["digest"]] = event }
|
|
244
|
+
|
|
245
|
+
next_of = {}
|
|
246
|
+
events.each do |event|
|
|
247
|
+
previous = event["previous_digest"]
|
|
248
|
+
next_of[previous] = event if previous && by_digest.key?(previous)
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
roots = events.reject do |event|
|
|
252
|
+
previous = event["previous_digest"]
|
|
253
|
+
previous && by_digest.key?(previous)
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
ordered = []
|
|
257
|
+
visited = {}
|
|
258
|
+
roots.each do |root|
|
|
259
|
+
cursor = root
|
|
260
|
+
while cursor && !visited[cursor["digest"]]
|
|
261
|
+
visited[cursor["digest"]] = true
|
|
262
|
+
ordered << cursor
|
|
263
|
+
cursor = next_of[cursor["digest"]]
|
|
264
|
+
end
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
ordered + events.reject { |event| visited[event["digest"]] }
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
def event_filename(event)
|
|
271
|
+
"#{event["recorded_at"].to_s.tr("-:T Z", "")}-#{event["type"]}-#{event["digest"][0, 12]}.json"
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
def write_event_files(assignment_id, events)
|
|
275
|
+
written = 0
|
|
276
|
+
events.each do |event|
|
|
277
|
+
path = File.join(checkout_dir, "execution", assignment_id, "events", event_filename(event))
|
|
278
|
+
next if File.exist?(path)
|
|
279
|
+
|
|
280
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
281
|
+
File.write(path, JSON.pretty_generate(event))
|
|
282
|
+
written += 1
|
|
283
|
+
end
|
|
284
|
+
written
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
def commit_events(assignment_id, attempt_id, events)
|
|
288
|
+
types = events.map { |event| event["type"] }.uniq.join(",")
|
|
289
|
+
git!("-C", checkout_dir, "add", "-A", "execution/#{assignment_id}")
|
|
290
|
+
git!(
|
|
291
|
+
"-C", checkout_dir,
|
|
292
|
+
"-c", "user.name=ace-assign", "-c", "user.email=ace-assign@localhost",
|
|
293
|
+
"commit", "-m", "evidence: #{assignment_id} #{attempt_id} (#{types})"
|
|
294
|
+
)
|
|
295
|
+
git!("-C", checkout_dir, "rev-parse", "HEAD").first
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
# Expected-old-value compare-and-swap; returns false on conflict.
|
|
299
|
+
# A nil expectation is the create case: the ref must not exist.
|
|
300
|
+
def update_ref_cas(new_commit, expected_old)
|
|
301
|
+
return true if !expected_old.nil? && new_commit == expected_old
|
|
302
|
+
|
|
303
|
+
expected = expected_old || ("0" * 40)
|
|
304
|
+
_out, stderr, status = git("update-ref", @ref, new_commit, expected)
|
|
305
|
+
return true if status.success?
|
|
306
|
+
|
|
307
|
+
raise AttemptErrors::EvidenceUnavailable, "Cannot update evidence ref #{@ref}: #{stderr}" if git_broken?(stderr)
|
|
308
|
+
|
|
309
|
+
false
|
|
310
|
+
end
|
|
311
|
+
|
|
312
|
+
def checkout_dir
|
|
313
|
+
File.join(@checkout_root, "journal")
|
|
314
|
+
end
|
|
315
|
+
|
|
316
|
+
def lock_path
|
|
317
|
+
File.join(@checkout_root, ".evidence.lock")
|
|
318
|
+
end
|
|
319
|
+
|
|
320
|
+
def with_lock
|
|
321
|
+
FileUtils.mkdir_p(@checkout_root)
|
|
322
|
+
File.open(lock_path, File::RDWR | File::CREAT) do |lock|
|
|
323
|
+
lock.flock(File::LOCK_EX)
|
|
324
|
+
begin
|
|
325
|
+
yield
|
|
326
|
+
ensure
|
|
327
|
+
lock.flock(File::LOCK_UN)
|
|
328
|
+
end
|
|
329
|
+
end
|
|
330
|
+
end
|
|
331
|
+
|
|
332
|
+
def ensure_checkout!
|
|
333
|
+
return if detached_worktree_at?(checkout_dir)
|
|
334
|
+
|
|
335
|
+
FileUtils.rm_rf(checkout_dir)
|
|
336
|
+
git!("worktree", "prune")
|
|
337
|
+
value = ref_value
|
|
338
|
+
if value.nil?
|
|
339
|
+
seed_ref
|
|
340
|
+
value = ref_value
|
|
341
|
+
end
|
|
342
|
+
git!("worktree", "add", "--detach", checkout_dir, value)
|
|
343
|
+
end
|
|
344
|
+
|
|
345
|
+
# Seed the evidence ref with an empty-tree commit so a worktree can
|
|
346
|
+
# attach before any real evidence exists. The create is a
|
|
347
|
+
# compare-and-swap against the zero SHA: a writer that loses the
|
|
348
|
+
# race keeps the winner's ref instead of resetting it.
|
|
349
|
+
def seed_ref
|
|
350
|
+
empty_tree = git!("mktree").first
|
|
351
|
+
seed = git!("-c", "user.name=ace-assign", "-c", "user.email=ace-assign@localhost",
|
|
352
|
+
"commit-tree", empty_tree, "-m", "seed: ace-assign execution evidence").first
|
|
353
|
+
_out, stderr, status = git("update-ref", @ref, seed, "0" * 40)
|
|
354
|
+
return if status.success?
|
|
355
|
+
|
|
356
|
+
raise AttemptErrors::EvidenceUnavailable, "Cannot seed evidence ref #{@ref}: #{stderr}" if git_broken?(stderr)
|
|
357
|
+
end
|
|
358
|
+
|
|
359
|
+
def detached_worktree_at?(path)
|
|
360
|
+
dot_git = File.join(path, ".git")
|
|
361
|
+
return false unless File.exist?(dot_git)
|
|
362
|
+
|
|
363
|
+
out, _s = git("-C", path, "rev-parse", "--abbrev-ref", "HEAD")
|
|
364
|
+
out.to_s.strip == "HEAD"
|
|
365
|
+
end
|
|
366
|
+
|
|
367
|
+
# Sync the disposable checkout to an authoritative ref value.
|
|
368
|
+
def sync_checkout(target)
|
|
369
|
+
return if target.nil?
|
|
370
|
+
|
|
371
|
+
git!("-C", checkout_dir, "reset", "--hard", target)
|
|
372
|
+
rescue AttemptErrors::EvidenceUnavailable
|
|
373
|
+
# Stale, broken, or unregistered worktree: rebuild from scratch.
|
|
374
|
+
FileUtils.rm_rf(checkout_dir)
|
|
375
|
+
git!("worktree", "prune")
|
|
376
|
+
git!("worktree", "add", "--detach", checkout_dir, target)
|
|
377
|
+
end
|
|
378
|
+
|
|
379
|
+
def git(*argv)
|
|
380
|
+
unless File.directory?(@repo_root)
|
|
381
|
+
return ["", "repository root missing: #{@repo_root}", FAILED_RESULT]
|
|
382
|
+
end
|
|
383
|
+
|
|
384
|
+
out, stderr, status = Open3.capture3("git", *argv, chdir: @repo_root, stdin_data: "")
|
|
385
|
+
[out.to_s.strip, stderr.to_s.strip, status]
|
|
386
|
+
end
|
|
387
|
+
|
|
388
|
+
def git!(*argv)
|
|
389
|
+
out, stderr, status = git(*argv)
|
|
390
|
+
unless status.success?
|
|
391
|
+
raise AttemptErrors::EvidenceUnavailable, "git #{argv.join(' ')} failed: #{stderr}"
|
|
392
|
+
end
|
|
393
|
+
|
|
394
|
+
[out, stderr, status]
|
|
395
|
+
end
|
|
396
|
+
|
|
397
|
+
def git_ok?(*argv)
|
|
398
|
+
git(*argv)[2].success?
|
|
399
|
+
end
|
|
400
|
+
|
|
401
|
+
# Distinguish "ref missing" (normal, quiet, no stderr) and expected
|
|
402
|
+
# compare-and-swap conflicts (retryable) from a broken repository or
|
|
403
|
+
# ref store.
|
|
404
|
+
def git_broken?(stderr)
|
|
405
|
+
message = stderr.to_s.strip
|
|
406
|
+
return false if message.empty?
|
|
407
|
+
return false if message.include?("unknown revision") || message.include?("not a valid ref")
|
|
408
|
+
return false if message.include?("cannot lock ref") || message.include?("but expected")
|
|
409
|
+
|
|
410
|
+
true
|
|
411
|
+
end
|
|
412
|
+
|
|
413
|
+
# Stand-in status for commands that cannot run at all.
|
|
414
|
+
FailedResult = Struct.new(:success?)
|
|
415
|
+
FAILED_RESULT = FailedResult.new(false).freeze
|
|
416
|
+
|
|
417
|
+
def default_config(key)
|
|
418
|
+
section = Ace::Assign.config["attempt"]
|
|
419
|
+
section && section[key]
|
|
420
|
+
end
|
|
421
|
+
end
|
|
422
|
+
end
|
|
423
|
+
end
|
|
424
|
+
end
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "etc"
|
|
4
|
+
require "json"
|
|
5
|
+
require "socket"
|
|
6
|
+
|
|
7
|
+
module Ace
|
|
8
|
+
module Assign
|
|
9
|
+
module Molecules
|
|
10
|
+
# Resolves the trusted execution-boundary identity for attempt
|
|
11
|
+
# transitions.
|
|
12
|
+
#
|
|
13
|
+
# Actor identity is derived from the execution boundary, never granted
|
|
14
|
+
# by command flags or receipt claims. Two adapters ship:
|
|
15
|
+
# - `local` — the interactive operator is the coordinator; identity
|
|
16
|
+
# comes from the OS login boundary.
|
|
17
|
+
# - `service` — a trusted service executor supplies a structured
|
|
18
|
+
# identity through a configured environment variable; the executor
|
|
19
|
+
# owns OS/process enforcement.
|
|
20
|
+
#
|
|
21
|
+
# Unknown adapter, missing actor, or malformed identity fails closed:
|
|
22
|
+
# unknown identity is an error, never permission.
|
|
23
|
+
class ExecutionIdentityResolver
|
|
24
|
+
ROLES = %w[coordinator service worker].freeze
|
|
25
|
+
TRUSTED_ROLES = %w[coordinator service].freeze
|
|
26
|
+
|
|
27
|
+
# Resolved boundary identity.
|
|
28
|
+
Identity = Struct.new(:actor, :role, :runtime, :adapter, keyword_init: true)
|
|
29
|
+
|
|
30
|
+
# @param adapter [String, nil] "local" or "service" (default from config)
|
|
31
|
+
# @param service_env [String, nil] Env var carrying the service identity (default from config)
|
|
32
|
+
def initialize(adapter: nil, service_env: nil)
|
|
33
|
+
section = Ace::Assign.config["attempt"] || {}
|
|
34
|
+
@adapter = adapter || section["identity_adapter"] || "local"
|
|
35
|
+
@service_env = service_env || section["service_identity_env"] || "ACE_ASSIGN_SERVICE_IDENTITY"
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# @return [Identity] Trusted boundary identity
|
|
39
|
+
# @raise [AttemptErrors::UnauthorizedIdentity] when identity cannot be established
|
|
40
|
+
def resolve
|
|
41
|
+
case @adapter
|
|
42
|
+
when "local" then from_local_boundary
|
|
43
|
+
when "service" then from_service_env
|
|
44
|
+
else
|
|
45
|
+
raise AttemptErrors::UnauthorizedIdentity, "Unknown identity adapter '#{@adapter}'"
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# @param identity [Identity] Identity to inspect
|
|
50
|
+
# @return [Boolean] True when the role may accept privileged transitions
|
|
51
|
+
def trusted?(identity)
|
|
52
|
+
TRUSTED_ROLES.include?(identity.role)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
private
|
|
56
|
+
|
|
57
|
+
def from_local_boundary
|
|
58
|
+
actor = Etc.getlogin || ENV["USER"] || ENV["LOGNAME"]
|
|
59
|
+
if actor.nil? || actor.strip.empty?
|
|
60
|
+
raise AttemptErrors::UnauthorizedIdentity, "No OS login identity available at the execution boundary"
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
Identity.new(
|
|
64
|
+
actor: actor.strip,
|
|
65
|
+
role: "coordinator",
|
|
66
|
+
runtime: "local:#{Socket.gethostname}",
|
|
67
|
+
adapter: "local"
|
|
68
|
+
)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def from_service_env
|
|
72
|
+
raw = ENV[@service_env].to_s.strip
|
|
73
|
+
if raw.empty?
|
|
74
|
+
raise AttemptErrors::UnauthorizedIdentity,
|
|
75
|
+
"Service identity env #{@service_env} is empty; the trusted executor must supply it"
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
data = begin
|
|
79
|
+
JSON.parse(raw)
|
|
80
|
+
rescue JSON::ParserError
|
|
81
|
+
raise AttemptErrors::UnauthorizedIdentity, "Service identity env #{@service_env} is not valid JSON"
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
actor = data["actor"].to_s.strip
|
|
85
|
+
role = data["role"].to_s.strip
|
|
86
|
+
runtime = data["runtime"].to_s.strip
|
|
87
|
+
if actor.empty? || runtime.empty? || !ROLES.include?(role)
|
|
88
|
+
raise AttemptErrors::UnauthorizedIdentity,
|
|
89
|
+
"Service identity must supply actor, runtime, and a role in #{ROLES.join('/')}"
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
Identity.new(actor: actor, role: role, runtime: runtime, adapter: "service")
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|