ace-lab 0.7.0 → 0.9.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.
Files changed (32) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +21 -0
  3. data/docs/usage.md +343 -0
  4. data/lib/ace/lab/cli/commands/assignment.rb +92 -0
  5. data/lib/ace/lab/cli/commands/command_execute.rb +28 -0
  6. data/lib/ace/lab/cli/commands/run.rb +65 -0
  7. data/lib/ace/lab/cli/commands/service.rb +12 -28
  8. data/lib/ace/lab/cli.rb +16 -1
  9. data/lib/ace/lab/molecules/command_owner_admission.rb +183 -0
  10. data/lib/ace/lab/molecules/command_policy.rb +158 -0
  11. data/lib/ace/lab/molecules/grant_resolver.rb +15 -0
  12. data/lib/ace/lab/molecules/ordinary_admission_resolver.rb +20 -1
  13. data/lib/ace/lab/molecules/ordinary_listener.rb +41 -17
  14. data/lib/ace/lab/molecules/ordinary_merge_read.rb +45 -0
  15. data/lib/ace/lab/molecules/ordinary_native_read.rb +106 -0
  16. data/lib/ace/lab/molecules/ordinary_owner_admission.rb +152 -0
  17. data/lib/ace/lab/molecules/ordinary_owner_config.rb +232 -0
  18. data/lib/ace/lab/molecules/ordinary_receiver_config.rb +12 -4
  19. data/lib/ace/lab/molecules/ordinary_socket.rb +6 -9
  20. data/lib/ace/lab/molecules/ordinary_worktree_read.rb +216 -0
  21. data/lib/ace/lab/molecules/service_executor.rb +9 -39
  22. data/lib/ace/lab/molecules/service_policy.rb +15 -4
  23. data/lib/ace/lab/organisms/authority_composition.rb +1 -1
  24. data/lib/ace/lab/organisms/command_executor.rb +220 -0
  25. data/lib/ace/lab/organisms/command_run_client.rb +131 -0
  26. data/lib/ace/lab/organisms/ordinary_assignment_client.rb +120 -0
  27. data/lib/ace/lab/organisms/ordinary_authority_composition.rb +153 -0
  28. data/lib/ace/lab/organisms/ordinary_merge_receiver.rb +77 -61
  29. data/lib/ace/lab/organisms/ordinary_merge_request.rb +41 -7
  30. data/lib/ace/lab/version.rb +1 -1
  31. metadata +33 -6
  32. data/lib/ace/lab/molecules/ordinary_ff_merge.rb +0 -86
@@ -7,7 +7,7 @@ require "uri"
7
7
  require_relative "ordinary_merge_request"
8
8
  require_relative "../molecules/ordinary_receiver_config"
9
9
  require_relative "../molecules/ordinary_admission_resolver"
10
- require_relative "../molecules/ordinary_ff_merge"
10
+ require_relative "../molecules/ordinary_merge_read"
11
11
  require_relative "../molecules/forge_merge_config"
12
12
  require_relative "../molecules/forgejo_merge_adapter"
13
13
  require "ace/assign/atoms/fixed_merge_schemas"
@@ -58,31 +58,37 @@ module Ace
58
58
  authorization request_id input_digest request principal receiver_id
59
59
  configuration_sha256].freeze
60
60
  ID = Molecules::ServicePolicy::ID
61
- NO_EFFECT_DIR = ".ace/ordinary-no-effect"
62
61
 
63
62
  def initialize(config:, coordinator:, policy_document:, merge_factory: nil,
64
63
  forge_config: nil, forge_factory: nil, resolver: nil,
65
- readback_attempts: 3, readback_backoff_seconds: 1.0)
64
+ readback_attempts: 3, readback_backoff_seconds: 1.0, owner_guard: nil)
66
65
  unless config.is_a?(Molecules::OrdinaryReceiverConfig)
67
66
  raise ArgumentError, "receiver needs validated OrdinaryReceiverConfig"
68
67
  end
69
68
  @config = config
69
+ unless owner_guard.nil? || owner_guard.respond_to?(:call)
70
+ raise ArgumentError, "ordinary installed owner guard must be callable"
71
+ end
72
+ @owner_guard = owner_guard
70
73
  @coordinator = coordinator
71
74
  @policy_document = policy_document
72
- @configuration_sha256 = Ace::Lab::Atoms::ServiceInput.digest(policy_document)
75
+ @configuration_sha256 = Molecules::ServicePolicy.configuration_digest(policy_document)
73
76
  @merge_factory = merge_factory || ->(document) {
74
- Molecules::OrdinaryFfMerge.new(repo_path: config.repo_path,
77
+ Molecules::OrdinaryMergeRead.new(repo_path: config.repo_path,
75
78
  base_ref: document.fetch("base_ref"))
76
79
  }
77
80
  # The scoped service identity is host-installed; an absent
78
81
  # configuration refuses construction with the actionable path.
79
- @forge_config = forge_config || Molecules::ForgeMergeConfig.load
82
+ @forge_config = forge_config || Molecules::ForgeMergeConfig.load(
83
+ config.forge_config_path || Molecules::ForgeMergeConfig::DEFAULT_PATH
84
+ )
80
85
  @forge_factory = forge_factory || ->(_document) {
81
86
  Molecules::ForgejoMergeAdapter.new(base_url: @forge_config.base_url,
82
87
  token: @forge_config.token, timeout_seconds: @forge_config.merge_timeout_seconds)
83
88
  }
84
89
  @resolver = resolver || Molecules::OrdinaryAdmissionResolver.new(
85
- policy_document, path: config.policy_path
90
+ policy_document, path: config.policy_path,
91
+ proposal_resolver: coordinator.method(:proposal_authorization), caller_uid: config.principal_uid
86
92
  )
87
93
  @readback_attempts = readback_attempts
88
94
  @readback_backoff_seconds = readback_backoff_seconds
@@ -101,6 +107,7 @@ module Ace
101
107
  # Assign record exists, or a closed pre-effect refusal envelope that
102
108
  # proves no effect began. Never a receiver-local record.
103
109
  def handle(submission)
110
+ @owner_guard&.call
104
111
  invalid!("submission is not a mapping") unless submission.is_a?(Hash)
105
112
  invalid!("submission has unknown selectors: #{unknown(submission)}") unless
106
113
  (submission.keys - SUBMISSION_KEYS).empty?
@@ -133,11 +140,9 @@ module Ace
133
140
  unless digest == submission.fetch("input_digest")
134
141
  invalid!("canonical request digest does not re-derive from the submission", request_id)
135
142
  end
136
- # Selection and evidence integrity before authorization: the PR
137
- # must target THIS receiver's configured forge repository, and the
138
- # handed-over review must be independent of the executor.
143
+ # Forge selection precedes authorization. Reviewer independence is
144
+ # resolved by the canonical owner from native starts, not actor names.
139
145
  forge_identity!(document, request_id)
140
- reviewer_independence!(document, request_id)
141
146
  decision = @resolver.decide(submission.fetch("authorization"), authorization_binding(submission))
142
147
  unless decision.authorized?
143
148
  return OrdinaryMergeRequest.refusal_envelope(decision.code, decision.message,
@@ -149,7 +154,7 @@ module Ace
149
154
  rescue OrdinaryMergeRefusal => e
150
155
  OrdinaryMergeRequest.refusal_envelope(e.code, e.detail,
151
156
  request_id: e.request_id, input_digest: e.input_digest)
152
- rescue Ace::Assign::AttemptErrors::AuthorizationConflict => e
157
+ rescue Ace::Assign::AttemptErrors::AuthorizationConflict, Ace::Assign::AttemptErrors::ReceiptRejected => e
153
158
  OrdinaryMergeRequest.refusal_envelope("ordinary_merge_authorization_invalid", e.message,
154
159
  request_id: submission.is_a?(Hash) ? submission.fetch("request_id", "unknown") : "unknown",
155
160
  input_digest: nil)
@@ -157,7 +162,7 @@ module Ace
157
162
  OrdinaryMergeRequest.refusal_envelope("ordinary_merge_request_conflict", e.message,
158
163
  request_id: submission.is_a?(Hash) ? submission.fetch("request_id", "unknown") : "unknown",
159
164
  input_digest: nil)
160
- rescue Molecules::OrdinaryFfMerge::GitUnavailable, Ace::Assign::AttemptErrors::EvidenceUnavailable,
165
+ rescue Molecules::OrdinaryMergeRead::GitUnavailable, Ace::Assign::Error,
161
166
  Ace::Lab::InvalidConfigurationError => e
162
167
  OrdinaryMergeRequest.refusal_envelope("ordinary_merge_unavailable", e.message,
163
168
  request_id: submission.is_a?(Hash) ? submission.fetch("request_id", "unknown") : "unknown",
@@ -173,13 +178,25 @@ module Ace
173
178
  # real forge readback. Any crash between begin and settle leaves the
174
179
  # canonical record uncertain with the original reference.
175
180
  def execute(request_id)
181
+ @owner_guard&.call
176
182
  record = @coordinator.fixed_merge_record(request_id)
177
183
  return :unknown unless record
178
184
  return :skipped_terminal unless record.fetch("state") == "pending"
179
- @coordinator.begin_fixed_merge_effect(request_id)
180
- execute_effect(record)
185
+ begin
186
+ @coordinator.begin_fixed_merge_effect(request_id)
187
+ rescue Ace::Assign::AttemptErrors::ReceiptRejected, Ace::Assign::AttemptErrors::AuthorizationConflict
188
+ settle_no_effect(request_id, record, "canonical proposal is no longer authorized before effect")
189
+ return :refused
190
+ end
191
+ begin
192
+ @coordinator.with_fixed_merge_evidence(record) { execute_effect(record) }
193
+ rescue Ace::Assign::AttemptErrors::ReceiptRejected
194
+ settle_no_effect(request_id, record, "canonical review or executed test proof changed before effect")
195
+ return :refused
196
+ end
181
197
  :executed
182
- rescue Ace::Assign::AttemptErrors::InvalidState, Ace::Assign::AttemptErrors::NotFound
198
+ rescue Ace::Assign::AttemptErrors::InvalidState, Ace::Assign::AttemptErrors::NotFound,
199
+ Ace::Assign::AttemptErrors::Conflict
183
200
  # Another lifecycle won the CAS or the record is terminal: never a
184
201
  # second effect, never a resubmission.
185
202
  :skipped_terminal
@@ -190,6 +207,7 @@ module Ace
190
207
  # a terminal state at most once. Already-uncertain records (a lost
191
208
  # lifecycle) are never re-executed — they reconcile by reference.
192
209
  def recover_pending!
210
+ @owner_guard&.call
193
211
  records = @coordinator.fixed_merge_records
194
212
  records.filter_map do |record|
195
213
  next nil unless record.fetch("state") == "pending" &&
@@ -267,6 +285,7 @@ module Ace
267
285
  def admit(submission)
268
286
  record = submission.merge(
269
287
  "principal" => @config.principal,
288
+ "caller_uid" => @config.principal_uid,
270
289
  "execution_identity" => @config.execution_identity
271
290
  )
272
291
  @coordinator.admit_fixed_merge_request(record)
@@ -294,16 +313,18 @@ module Ace
294
313
  candidate = document.fetch("candidate_head")
295
314
  expected = document.fetch("expected_base_head")
296
315
  merge = @merge_factory.call(document)
316
+ refusal = current_authorization_refusal(admitted_record)
317
+ return settle_no_effect(request_id, admitted_record, refusal) if refusal
297
318
  observed = merge.base_head
298
319
  if observed != expected
299
- return settle_no_effect(request_id, admitted_record, merge,
320
+ return settle_no_effect(request_id, admitted_record,
300
321
  "base head moved before the effect: #{observed[0, 12]}")
301
322
  end
302
323
  identity = forge_identity!(document, request_id)
303
324
  forge = @forge_factory.call(document)
304
325
  pr = readback(forge, identity)
305
326
  if pr.nil?
306
- return settle_no_effect(request_id, admitted_record, merge,
327
+ return settle_no_effect(request_id, admitted_record,
307
328
  "pull request is absent from the forge")
308
329
  end
309
330
  if pr["merged"]
@@ -318,40 +339,55 @@ module Ace
318
339
  owner: @forge_config.escalation_owner)
319
340
  end
320
341
  unless pr.dig("head", "sha") == candidate
321
- return settle_no_effect(request_id, admitted_record, merge,
342
+ return settle_no_effect(request_id, admitted_record,
322
343
  "forge pull request head moved from the approved candidate")
323
344
  end
324
345
  unless pr.dig("base", "sha") == expected
325
- return settle_no_effect(request_id, admitted_record, merge,
346
+ return settle_no_effect(request_id, admitted_record,
326
347
  "forge base head moved from the immutable request")
327
348
  end
349
+ refusal = current_authorization_refusal(admitted_record)
350
+ return settle_no_effect(request_id, admitted_record, refusal) if refusal
328
351
  refusal = attach_independent_review!(forge, identity, request_id,
329
- admitted_record, merge, document)
352
+ admitted_record, document)
330
353
  return refusal if refusal
331
354
  # Exact-head contract: the merge POST binds the approved candidate
332
355
  # head server-side — the forge refuses to merge any head other
333
356
  # than the reviewed candidate (race between readback and POST can
334
357
  # never merge an unreviewed head).
358
+ refusal = current_authorization_refusal(admitted_record)
359
+ return settle_no_effect(request_id, admitted_record, refusal) if refusal
335
360
  result = forge.merge_pull_request!(owner: identity.fetch("owner"),
336
361
  repo: identity.fetch("repo"), index: identity.fetch("index"),
337
362
  head_commit_id: candidate)
338
363
  case result.outcome
339
364
  when :invoked
340
- reconcile_invoked(forge, identity, request_id, admitted_record, merge,
365
+ reconcile_invoked(forge, identity, request_id, admitted_record,
341
366
  document, candidate)
342
367
  when :refused
343
- settle_no_effect(request_id, admitted_record, merge,
368
+ settle_no_effect(request_id, admitted_record,
344
369
  "forge refused the merge: #{result.reason}")
345
370
  else
346
- reconcile_ambiguous(forge, identity, request_id, admitted_record, merge,
371
+ reconcile_ambiguous(forge, identity, request_id, admitted_record,
347
372
  document, candidate, result.reason)
348
373
  end
349
- rescue Molecules::OrdinaryFfMerge::GitUnavailable, Molecules::ForgejoMergeAdapter::Unavailable => e
374
+ rescue Molecules::OrdinaryMergeRead::GitUnavailable, Molecules::ForgejoMergeAdapter::Unavailable => e
350
375
  # The effect outcome is unprovable: the canonical record stays
351
376
  # uncertain with the original reference — never a resubmission.
352
377
  leave_uncertain(request_id, e.message)
353
378
  end
354
379
 
380
+ # Recheck pending/recovered authority and again after slow forge calls,
381
+ # immediately before each privileged effect. No cached decision is a lease.
382
+ def current_authorization_refusal(record)
383
+ @owner_guard&.call
384
+ @coordinator.verify_fixed_merge_evidence!(record)
385
+ decision = @resolver.decide(record.fetch("authorization"), authorization_binding(record))
386
+ decision.message unless decision.authorized?
387
+ rescue Ace::Lab::InvalidConfigurationError, Ace::Assign::Error => e
388
+ e.message
389
+ end
390
+
355
391
  # The merge invocation was accepted: prove the terminal state by
356
392
  # readback before any settlement. A readback showing a merge at any
357
393
  # head other than the approved candidate is never a success. A 404
@@ -361,7 +397,7 @@ module Ace
361
397
  # absence of effect — the record stays uncertain with the original
362
398
  # reference, the escalation owner named, no no-effect attestation
363
399
  # and no retry, never a re-invocation.
364
- def reconcile_invoked(forge, identity, request_id, admitted_record, merge,
400
+ def reconcile_invoked(forge, identity, request_id, admitted_record,
365
401
  document, candidate)
366
402
  pr = readback(forge, identity)
367
403
  if pr.nil?
@@ -389,7 +425,7 @@ module Ace
389
425
  # never a fabricated no-effect claim while the forge may still be
390
426
  # applying the invocation. A 404 after the invocation stays in the
391
427
  # same uncertain family: absence cannot prove no effect.
392
- def reconcile_ambiguous(forge, identity, request_id, admitted_record, merge,
428
+ def reconcile_ambiguous(forge, identity, request_id, admitted_record,
393
429
  document, candidate, reason)
394
430
  pr = readback(forge, identity)
395
431
  if pr.nil?
@@ -419,7 +455,7 @@ module Ace
419
455
  # refused or ambiguous attachment settles failed_no_effect: the merge
420
456
  # was never invoked, so no effect provably occurred.
421
457
  def attach_independent_review!(forge, identity, request_id, admitted_record,
422
- merge, document)
458
+ document)
423
459
  return nil unless @forge_config.review_attachment_enabled
424
460
  evidence = document.fetch("review_evidence")
425
461
  body = [
@@ -438,10 +474,10 @@ module Ace
438
474
  when :invoked
439
475
  nil
440
476
  when :refused
441
- settle_no_effect(request_id, admitted_record, merge,
477
+ settle_no_effect(request_id, admitted_record,
442
478
  "forge refused the independent review attachment: #{result.reason}")
443
479
  else
444
- settle_no_effect(request_id, admitted_record, merge,
480
+ settle_no_effect(request_id, admitted_record,
445
481
  "independent review attachment outcome ambiguous; merge not invoked: #{result.reason}")
446
482
  end
447
483
  end
@@ -512,11 +548,14 @@ module Ace
512
548
  end
513
549
 
514
550
  # Terminal failure requires the immutable no-effect attestation: an
515
- # executor-owned file inside the canonical repository whose SHA-256
516
- # binds the no-merge proof to this exact request.
517
- def settle_no_effect(request_id, admitted_record, merge, reason)
551
+ # blob committed atomically with the terminal receipt in Assign's
552
+ # canonical evidence ref. No caller checkout path is written.
553
+ def settle_no_effect(request_id, admitted_record, reason)
518
554
  record = @coordinator.fixed_merge_record(request_id)
519
555
  document = record.fetch("request")
556
+ # Provider I/O has its own lifetime. Settlement needs a fresh bounded
557
+ # local observation, never the expired pre-effect read deadline.
558
+ merge = @merge_factory.call(document)
520
559
  attestation = {
521
560
  "request_id" => request_id,
522
561
  "input_digest" => record.fetch("input_digest"),
@@ -524,12 +563,11 @@ module Ace
524
563
  "candidate_head" => document.fetch("candidate_head"),
525
564
  "expected_base_head" => document.fetch("expected_base_head"),
526
565
  "observed_base_head" => merge.base_head,
527
- "strategy" => Molecules::OrdinaryFfMerge::STRATEGY,
566
+ "strategy" => Molecules::OrdinaryMergeRead::STRATEGY,
528
567
  "outcome" => "no_effect",
529
568
  "reason" => reason[0, 512],
530
569
  "recorded_at" => Time.now.utc.iso8601
531
570
  }
532
- evidence = write_no_effect_attestation(request_id, attestation)
533
571
  receipt = {
534
572
  "schema" => Ace::Assign::Atoms::FixedMergeSchemas::RECEIPT_SCHEMA_ID,
535
573
  "reference" => record.fetch("reference"),
@@ -555,16 +593,16 @@ module Ace
555
593
  "finished_at" => Time.now.utc.iso8601,
556
594
  "outcome" => "failed_no_effect",
557
595
  "failure_code" => "merge_failed_no_effect",
558
- "no_effect_evidence" => evidence
596
+ "no_effect_evidence" => nil
559
597
  },
560
598
  "remote_observation" => nil
561
599
  }
562
- settle(request_id, "failed", receipt)
600
+ settle(request_id, "failed", receipt, attestation: attestation)
563
601
  end
564
602
 
565
- def settle(request_id, terminal_status, receipt)
603
+ def settle(request_id, terminal_status, receipt, attestation: nil)
566
604
  @coordinator.settle_fixed_merge_effect(request_id, terminal_status: terminal_status,
567
- receipt: receipt)
605
+ receipt: receipt, attestation: attestation)
568
606
  terminal_status.to_sym
569
607
  rescue Ace::Assign::AttemptErrors::ReceiptRejected
570
608
  # The journal refused the receipt: the record stays uncertain with
@@ -587,15 +625,6 @@ module Ace
587
625
  nil
588
626
  end
589
627
 
590
- def write_no_effect_attestation(request_id, attestation)
591
- dir = File.join(@config.repo_path, NO_EFFECT_DIR)
592
- FileUtils.mkdir_p(dir)
593
- path = File.join(dir, "#{request_id}.json")
594
- File.write(path, JSON.generate(attestation))
595
- {"reference" => "#{NO_EFFECT_DIR}/#{request_id}.json",
596
- "sha256" => Digest::SHA256.file(path).hexdigest}
597
- end
598
-
599
628
  def target_changed!(message, request_id)
600
629
  raise OrdinaryMergeRefusal.new("ordinary_merge_target_changed", message,
601
630
  request_id: request_id)
@@ -620,19 +649,6 @@ module Ace
620
649
  {"owner" => match[1], "repo" => match[2], "index" => match[3].to_i}
621
650
  end
622
651
 
623
- # The independent final-head review is handed over in the request;
624
- # the executor's own identity is never an independent reviewer.
625
- # Evidence naming the receiver or its principal refuses pre-effect —
626
- # it can never be attached as independent review and never grounds
627
- # the merge.
628
- def reviewer_independence!(document, request_id)
629
- reviewer = document.dig("review_evidence", "reviewer")
630
- return unless [config.receiver_id, config.principal].include?(reviewer)
631
- raise OrdinaryMergeRefusal.new("ordinary_merge_evidence_invalid",
632
- "review evidence names the receiver executor, not an independent reviewer",
633
- request_id: request_id)
634
- end
635
-
636
652
  def selection_refusal!(message, request_id)
637
653
  raise OrdinaryMergeRefusal.new("ordinary_merge_selection_mismatch", message,
638
654
  request_id: request_id)
@@ -6,6 +6,7 @@ require "open3"
6
6
  require_relative "protected_service_request"
7
7
  require_relative "../molecules/ordinary_socket"
8
8
  require "ace/assign/atoms/fixed_merge_schemas"
9
+ require "ace/assign/molecules/installed_journal_read"
9
10
 
10
11
  module Ace
11
12
  module Lab
@@ -45,8 +46,7 @@ module Ace
45
46
  def initialize(topology: nil, policy: nil, coordinator: nil, repo_root: nil, ingress: nil)
46
47
  @topology = topology || TopologyService.from_config
47
48
  @repo_root = repo_root || git_toplevel
48
- @coordinator = coordinator ||
49
- Ace::Assign::Organisms::AttemptCoordinator.new(repo_root: @repo_root)
49
+ @coordinator = coordinator
50
50
  @policy = policy || Molecules::ServicePolicy.new(
51
51
  Molecules::GrantResolver.trusted_document(Ace::Lab.authorization_path)
52
52
  )
@@ -71,6 +71,11 @@ module Ace
71
71
  queue_entry: queue_entry, authorization: authorization, request_id: request_id,
72
72
  document: document)
73
73
  principal = authenticate!(project)
74
+ unless @coordinator
75
+ return with_installed_journal(project) { |producer| producer.request(project: project,
76
+ assignment: assignment, attempt: attempt, queue_entry: queue_entry, operation: operation,
77
+ input: input, authorization: authorization, request_id: request_id, **options) }
78
+ end
74
79
  existing = @coordinator.fixed_merge_record(request_id)
75
80
  if existing
76
81
  return ack_envelope(existing) if exact_replay?(existing, project: project,
@@ -137,6 +142,11 @@ module Ace
137
142
  invalid!("invalid input digest", request)
138
143
  end
139
144
  authenticate!(project)
145
+ unless @coordinator
146
+ return with_installed_journal(project) { |producer| producer.status(project: project,
147
+ assignment: assignment, attempt: attempt, queue_entry: queue_entry, reference: reference,
148
+ request: request, input_digest: input_digest, **options) }
149
+ end
140
150
  record = @coordinator.fixed_merge_status(reference: reference, request_id: request,
141
151
  project_id: project, assignment_id: assignment, attempt_id: attempt,
142
152
  queue_entry: queue_entry, input_digest: input_digest)
@@ -198,6 +208,23 @@ module Ace
198
208
 
199
209
  private
200
210
 
211
+ def with_installed_journal(project)
212
+ receiver = @policy.ordinary_receiver!(project)
213
+ unless receiver && %w[journal_repository journal_ref].all? { |key| receiver.key?(key) }
214
+ raise Ace::Lab::InvalidConfigurationError, "installed ordinary journal selection is missing"
215
+ end
216
+ repository, ref = receiver.values_at("journal_repository", "journal_ref")
217
+ boundary = Ace::Assign::Molecules::InstalledJournalRead.new(repo_root: repository,
218
+ ref: ref, owner_uid: receiver.fetch("receiver_uid"))
219
+ boundary.with(deadline: Process.clock_gettime(Process::CLOCK_MONOTONIC) + 30) do |view|
220
+ journal = Ace::Assign::Molecules::EvidenceJournal.new(repo_root: repository,
221
+ ref: ref, checkout_root: File.join(repository, ".unused-reader"), read_boundary: view)
222
+ coordinator = Ace::Assign::Organisms::AttemptCoordinator.new(repo_root: @repo_root, journal: journal)
223
+ yield self.class.new(topology: @topology, policy: @policy, repo_root: @repo_root,
224
+ coordinator: coordinator, ingress: @ingress)
225
+ end
226
+ end
227
+
201
228
  # The immutable binding digest — spec ruling r4: lowercase hex SHA-256
202
229
  # over the canonical JSON (recursively key-sorted, compact, UTF-8) of
203
230
  # the full eight-field binding. Computed once here at admission; the
@@ -242,7 +269,7 @@ module Ace
242
269
  raise Molecules::OrdinarySocket::OrdinaryUnavailable,
243
270
  "ordinary merge admission is not available in the installed configuration"
244
271
  end
245
- receiver.merge("configuration_sha256" => Atoms::ServiceInput.digest(@policy.document))
272
+ receiver.merge("configuration_sha256" => Molecules::ServicePolicy.configuration_digest(@policy.document))
246
273
  end
247
274
 
248
275
  def resolve_selection!(project:, assignment:, attempt:, queue_entry:)
@@ -261,8 +288,15 @@ module Ace
261
288
  "head_ref" => document.fetch("head_ref"), "candidate_head" => document.fetch("candidate_head"),
262
289
  "base_repository" => document.fetch("base_repository"), "base_ref" => document.fetch("base_ref"),
263
290
  "expected_base_head" => document.fetch("expected_base_head")}
264
- @policy.authorize_ordinary!(authorization, binding)
265
- rescue SecurityError => e
291
+ if authorization.start_with?("proposal-")
292
+ @coordinator.proposal_authorization(authorization, binding.slice(
293
+ "operation", "project_id", "assignment_id", "attempt_id", "input_digest", "candidate_head").merge(
294
+ "target" => {"resource" => document.fetch("pr_url"), "artifact_digest" => nil},
295
+ "caller_uid" => Process.uid))
296
+ else
297
+ @policy.authorize_ordinary!(authorization, binding)
298
+ end
299
+ rescue SecurityError, Ace::Assign::AttemptErrors::ReceiptRejected => e
266
300
  # Spec table (ruling r4): the authorization decision or reference is
267
301
  # invalid for this request — missing, expired, revoked, out of scope,
268
302
  # bound to another operation/project/PR/head, or not an ordinary
@@ -306,7 +340,7 @@ module Ace
306
340
  def normalize_ack(response, request_id:, input_digest:)
307
341
  data = response.fetch("data") { {} }
308
342
  expected = %w[schema type request_id input_digest reference]
309
- digest = data["input_digest"]
343
+ digest = data.is_a?(Hash) && data["input_digest"]
310
344
  unless data.is_a?(Hash) && (data.keys - expected).empty? &&
311
345
  expected.all? { |key| data[key].is_a?(String) && !data[key].empty? } &&
312
346
  data.fetch("schema") == Ace::Assign::Atoms::FixedMergeSchemas::ACK_SCHEMA_ID &&
@@ -322,7 +356,7 @@ module Ace
322
356
  def normalize_refusal(response, request_id:, input_digest:)
323
357
  error = response.fetch("error") { {} }
324
358
  expected = %w[schema code message request_id input_digest]
325
- valid = error.is_a?(Hash) && (error.keys - expected).empty? &&
359
+ valid = error.is_a?(Hash) && error.keys.sort == expected.sort &&
326
360
  error.fetch("schema") == Ace::Assign::Atoms::FixedMergeSchemas::REFUSAL_SCHEMA_ID &&
327
361
  CODES.include?(error.fetch("code")) &&
328
362
  error.fetch("message").is_a?(String) && !error.fetch("message").empty? &&
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Ace
4
4
  module Lab
5
- VERSION = "0.7.0"
5
+ VERSION = "0.9.0"
6
6
  end
7
7
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ace-lab
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.7.0
4
+ version: 0.9.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Michal Czyz
@@ -71,28 +71,42 @@ dependencies:
71
71
  requirements:
72
72
  - - "~>"
73
73
  - !ruby/object:Gem::Version
74
- version: '0.69'
74
+ version: '0.70'
75
75
  type: :runtime
76
76
  prerelease: false
77
77
  version_requirements: !ruby/object:Gem::Requirement
78
78
  requirements:
79
79
  - - "~>"
80
80
  - !ruby/object:Gem::Version
81
- version: '0.69'
81
+ version: '0.70'
82
+ - !ruby/object:Gem::Dependency
83
+ name: ace-herdr
84
+ requirement: !ruby/object:Gem::Requirement
85
+ requirements:
86
+ - - "~>"
87
+ - !ruby/object:Gem::Version
88
+ version: '0.6'
89
+ type: :runtime
90
+ prerelease: false
91
+ version_requirements: !ruby/object:Gem::Requirement
92
+ requirements:
93
+ - - "~>"
94
+ - !ruby/object:Gem::Version
95
+ version: '0.6'
82
96
  - !ruby/object:Gem::Dependency
83
97
  name: ace-hitl
84
98
  requirement: !ruby/object:Gem::Requirement
85
99
  requirements:
86
100
  - - "~>"
87
101
  - !ruby/object:Gem::Version
88
- version: '0.15'
102
+ version: '0.16'
89
103
  type: :runtime
90
104
  prerelease: false
91
105
  version_requirements: !ruby/object:Gem::Requirement
92
106
  requirements:
93
107
  - - "~>"
94
108
  - !ruby/object:Gem::Version
95
- version: '0.15'
109
+ version: '0.16'
96
110
  - !ruby/object:Gem::Dependency
97
111
  name: ace-git-forgejo
98
112
  requirement: !ruby/object:Gem::Requirement
@@ -219,9 +233,12 @@ files:
219
233
  - lib/ace/lab/authorization_path.rb
220
234
  - lib/ace/lab/cli.rb
221
235
  - lib/ace/lab/cli/commands/agents.rb
236
+ - lib/ace/lab/cli/commands/assignment.rb
237
+ - lib/ace/lab/cli/commands/command_execute.rb
222
238
  - lib/ace/lab/cli/commands/projects.rb
223
239
  - lib/ace/lab/cli/commands/resolve.rb
224
240
  - lib/ace/lab/cli/commands/route.rb
241
+ - lib/ace/lab/cli/commands/run.rb
225
242
  - lib/ace/lab/cli/commands/service.rb
226
243
  - lib/ace/lab/cli/commands/services.rb
227
244
  - lib/ace/lab/cli/commands/support.rb
@@ -232,6 +249,8 @@ files:
232
249
  - lib/ace/lab/models/topology_entry.rb
233
250
  - lib/ace/lab/molecules/caller_authorizer.rb
234
251
  - lib/ace/lab/molecules/capability_router.rb
252
+ - lib/ace/lab/molecules/command_owner_admission.rb
253
+ - lib/ace/lab/molecules/command_policy.rb
235
254
  - lib/ace/lab/molecules/exact_resolver.rb
236
255
  - lib/ace/lab/molecules/forge_merge_config.rb
237
256
  - lib/ace/lab/molecules/forgejo_merge_adapter.rb
@@ -239,10 +258,14 @@ files:
239
258
  - lib/ace/lab/molecules/hitl_authorizer.rb
240
259
  - lib/ace/lab/molecules/inventory_query.rb
241
260
  - lib/ace/lab/molecules/ordinary_admission_resolver.rb
242
- - lib/ace/lab/molecules/ordinary_ff_merge.rb
243
261
  - lib/ace/lab/molecules/ordinary_listener.rb
262
+ - lib/ace/lab/molecules/ordinary_merge_read.rb
263
+ - lib/ace/lab/molecules/ordinary_native_read.rb
264
+ - lib/ace/lab/molecules/ordinary_owner_admission.rb
265
+ - lib/ace/lab/molecules/ordinary_owner_config.rb
244
266
  - lib/ace/lab/molecules/ordinary_receiver_config.rb
245
267
  - lib/ace/lab/molecules/ordinary_socket.rb
268
+ - lib/ace/lab/molecules/ordinary_worktree_read.rb
246
269
  - lib/ace/lab/molecules/protected_cleanup_owner_admission.rb
247
270
  - lib/ace/lab/molecules/protected_cleanup_owner_client.rb
248
271
  - lib/ace/lab/molecules/protected_cleanup_owner_identity.rb
@@ -253,6 +276,10 @@ files:
253
276
  - lib/ace/lab/molecules/service_policy.rb
254
277
  - lib/ace/lab/molecules/topology_loader.rb
255
278
  - lib/ace/lab/organisms/authority_composition.rb
279
+ - lib/ace/lab/organisms/command_executor.rb
280
+ - lib/ace/lab/organisms/command_run_client.rb
281
+ - lib/ace/lab/organisms/ordinary_assignment_client.rb
282
+ - lib/ace/lab/organisms/ordinary_authority_composition.rb
256
283
  - lib/ace/lab/organisms/ordinary_merge_receiver.rb
257
284
  - lib/ace/lab/organisms/ordinary_merge_request.rb
258
285
  - lib/ace/lab/organisms/protected_cleanup_owner.rb
@@ -1,86 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "open3"
4
-
5
- module Ace
6
- module Lab
7
- module Molecules
8
- # The fixed merge method executor (spec 8x3.t.xz9.4, merge-method
9
- # contract). The closed request schema admits literal `method=merge`;
10
- # the installed executor gives that method FAST-FORWARD-ONLY semantics:
11
- # the candidate head must be a descendant of the current base head.
12
- # A non-fast-forwardable candidate is NOT merged silently and never
13
- # falls back to a merge commit — it reports `:non_fast_forward` so the
14
- # receiver answers in the moved-target/needs-rebase refusal family.
15
- # The mutation itself is one atomic compare-and-swap on the base ref
16
- # (`git update-ref <base> <candidate> <expected>`), so concurrent
17
- # writers can never both move the ref: the loser provably performed
18
- # no effect. Under this strategy the resulting base head equals the
19
- # candidate head, and the receipt joins `merge_commit ==
20
- # resulting_target_head == merged_candidate_head` — that join is the
21
- # receipt's fast-forward proof. Any future non-ff strategy is a
22
- # separate reviewed contract extension, never implicit.
23
- class OrdinaryFfMerge
24
- STRATEGY = "ff"
25
-
26
- Result = Struct.new(:outcome, :resulting_head, :previous_head, :reason, keyword_init: true)
27
-
28
- def initialize(repo_path:, base_ref:)
29
- unless repo_path.is_a?(String) && repo_path.start_with?("/") &&
30
- base_ref.is_a?(String) && base_ref.start_with?("refs/")
31
- raise ArgumentError, "ordinary ff merge needs an absolute repo path and a full base ref"
32
- end
33
- @repo_path = repo_path
34
- @base_ref = base_ref
35
- end
36
-
37
- def base_head
38
- out, stderr, status = git("rev-parse", @base_ref)
39
- return out.strip if status.success?
40
- raise GitUnavailable, "base ref #{@base_ref} is unreadable: #{stderr.strip[0, 200]}"
41
- end
42
-
43
- def candidate_commit?(sha)
44
- git("cat-file", "-e", "#{sha}^{commit}").last.success?
45
- end
46
-
47
- # True only when the candidate is a descendant of (or equal to) the
48
- # current base head — the fast-forward precondition.
49
- def fast_forwardable?(candidate_head)
50
- git("merge-base", "--is-ancestor", @base_ref, candidate_head).last.success?
51
- end
52
-
53
- # The single effect: one compare-and-swap ref update. Returns a
54
- # Result with outcome `:merged`, `:already_merged`, or `:race_lost`
55
- # (the expected head moved before the swap — provably no effect from
56
- # this invocation). Raises GitUnavailable when git itself fails.
57
- def merge!(candidate_head, expected_head)
58
- current = base_head
59
- return Result.new(outcome: :already_merged, resulting_head: current,
60
- previous_head: current, reason: nil) if current == candidate_head
61
- unless current == expected_head
62
- return Result.new(outcome: :race_lost, resulting_head: current,
63
- previous_head: current, reason: "base head moved before the swap")
64
- end
65
- out, stderr, status = git("update-ref", @base_ref, candidate_head, expected_head)
66
- unless status.success?
67
- return Result.new(outcome: :race_lost, resulting_head: base_head,
68
- previous_head: base_head, reason: "compare-and-swap lost: #{stderr.strip[0, 200]}")
69
- end
70
- Result.new(outcome: :merged, resulting_head: candidate_head,
71
- previous_head: expected_head, reason: nil)
72
- end
73
-
74
- class GitUnavailable < ::StandardError; end
75
-
76
- private
77
-
78
- def git(*argv)
79
- Open3.capture3("git", *argv, chdir: @repo_path)
80
- rescue Errno::ENOENT => e
81
- raise GitUnavailable, "git is unavailable: #{e.class}"
82
- end
83
- end
84
- end
85
- end
86
- end