ace-lab 0.7.0 → 0.8.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.
@@ -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?
@@ -149,7 +156,7 @@ module Ace
149
156
  rescue OrdinaryMergeRefusal => e
150
157
  OrdinaryMergeRequest.refusal_envelope(e.code, e.detail,
151
158
  request_id: e.request_id, input_digest: e.input_digest)
152
- rescue Ace::Assign::AttemptErrors::AuthorizationConflict => e
159
+ rescue Ace::Assign::AttemptErrors::AuthorizationConflict, Ace::Assign::AttemptErrors::ReceiptRejected => e
153
160
  OrdinaryMergeRequest.refusal_envelope("ordinary_merge_authorization_invalid", e.message,
154
161
  request_id: submission.is_a?(Hash) ? submission.fetch("request_id", "unknown") : "unknown",
155
162
  input_digest: nil)
@@ -157,7 +164,7 @@ module Ace
157
164
  OrdinaryMergeRequest.refusal_envelope("ordinary_merge_request_conflict", e.message,
158
165
  request_id: submission.is_a?(Hash) ? submission.fetch("request_id", "unknown") : "unknown",
159
166
  input_digest: nil)
160
- rescue Molecules::OrdinaryFfMerge::GitUnavailable, Ace::Assign::AttemptErrors::EvidenceUnavailable,
167
+ rescue Molecules::OrdinaryMergeRead::GitUnavailable, Ace::Assign::Error,
161
168
  Ace::Lab::InvalidConfigurationError => e
162
169
  OrdinaryMergeRequest.refusal_envelope("ordinary_merge_unavailable", e.message,
163
170
  request_id: submission.is_a?(Hash) ? submission.fetch("request_id", "unknown") : "unknown",
@@ -173,13 +180,20 @@ module Ace
173
180
  # real forge readback. Any crash between begin and settle leaves the
174
181
  # canonical record uncertain with the original reference.
175
182
  def execute(request_id)
183
+ @owner_guard&.call
176
184
  record = @coordinator.fixed_merge_record(request_id)
177
185
  return :unknown unless record
178
186
  return :skipped_terminal unless record.fetch("state") == "pending"
179
- @coordinator.begin_fixed_merge_effect(request_id)
187
+ begin
188
+ @coordinator.begin_fixed_merge_effect(request_id)
189
+ rescue Ace::Assign::AttemptErrors::ReceiptRejected, Ace::Assign::AttemptErrors::AuthorizationConflict
190
+ settle_no_effect(request_id, record, "canonical proposal is no longer authorized before effect")
191
+ return :refused
192
+ end
180
193
  execute_effect(record)
181
194
  :executed
182
- rescue Ace::Assign::AttemptErrors::InvalidState, Ace::Assign::AttemptErrors::NotFound
195
+ rescue Ace::Assign::AttemptErrors::InvalidState, Ace::Assign::AttemptErrors::NotFound,
196
+ Ace::Assign::AttemptErrors::Conflict
183
197
  # Another lifecycle won the CAS or the record is terminal: never a
184
198
  # second effect, never a resubmission.
185
199
  :skipped_terminal
@@ -190,6 +204,7 @@ module Ace
190
204
  # a terminal state at most once. Already-uncertain records (a lost
191
205
  # lifecycle) are never re-executed — they reconcile by reference.
192
206
  def recover_pending!
207
+ @owner_guard&.call
193
208
  records = @coordinator.fixed_merge_records
194
209
  records.filter_map do |record|
195
210
  next nil unless record.fetch("state") == "pending" &&
@@ -267,6 +282,7 @@ module Ace
267
282
  def admit(submission)
268
283
  record = submission.merge(
269
284
  "principal" => @config.principal,
285
+ "caller_uid" => @config.principal_uid,
270
286
  "execution_identity" => @config.execution_identity
271
287
  )
272
288
  @coordinator.admit_fixed_merge_request(record)
@@ -294,16 +310,18 @@ module Ace
294
310
  candidate = document.fetch("candidate_head")
295
311
  expected = document.fetch("expected_base_head")
296
312
  merge = @merge_factory.call(document)
313
+ refusal = current_authorization_refusal(admitted_record)
314
+ return settle_no_effect(request_id, admitted_record, refusal) if refusal
297
315
  observed = merge.base_head
298
316
  if observed != expected
299
- return settle_no_effect(request_id, admitted_record, merge,
317
+ return settle_no_effect(request_id, admitted_record,
300
318
  "base head moved before the effect: #{observed[0, 12]}")
301
319
  end
302
320
  identity = forge_identity!(document, request_id)
303
321
  forge = @forge_factory.call(document)
304
322
  pr = readback(forge, identity)
305
323
  if pr.nil?
306
- return settle_no_effect(request_id, admitted_record, merge,
324
+ return settle_no_effect(request_id, admitted_record,
307
325
  "pull request is absent from the forge")
308
326
  end
309
327
  if pr["merged"]
@@ -318,40 +336,54 @@ module Ace
318
336
  owner: @forge_config.escalation_owner)
319
337
  end
320
338
  unless pr.dig("head", "sha") == candidate
321
- return settle_no_effect(request_id, admitted_record, merge,
339
+ return settle_no_effect(request_id, admitted_record,
322
340
  "forge pull request head moved from the approved candidate")
323
341
  end
324
342
  unless pr.dig("base", "sha") == expected
325
- return settle_no_effect(request_id, admitted_record, merge,
343
+ return settle_no_effect(request_id, admitted_record,
326
344
  "forge base head moved from the immutable request")
327
345
  end
346
+ refusal = current_authorization_refusal(admitted_record)
347
+ return settle_no_effect(request_id, admitted_record, refusal) if refusal
328
348
  refusal = attach_independent_review!(forge, identity, request_id,
329
- admitted_record, merge, document)
349
+ admitted_record, document)
330
350
  return refusal if refusal
331
351
  # Exact-head contract: the merge POST binds the approved candidate
332
352
  # head server-side — the forge refuses to merge any head other
333
353
  # than the reviewed candidate (race between readback and POST can
334
354
  # never merge an unreviewed head).
355
+ refusal = current_authorization_refusal(admitted_record)
356
+ return settle_no_effect(request_id, admitted_record, refusal) if refusal
335
357
  result = forge.merge_pull_request!(owner: identity.fetch("owner"),
336
358
  repo: identity.fetch("repo"), index: identity.fetch("index"),
337
359
  head_commit_id: candidate)
338
360
  case result.outcome
339
361
  when :invoked
340
- reconcile_invoked(forge, identity, request_id, admitted_record, merge,
362
+ reconcile_invoked(forge, identity, request_id, admitted_record,
341
363
  document, candidate)
342
364
  when :refused
343
- settle_no_effect(request_id, admitted_record, merge,
365
+ settle_no_effect(request_id, admitted_record,
344
366
  "forge refused the merge: #{result.reason}")
345
367
  else
346
- reconcile_ambiguous(forge, identity, request_id, admitted_record, merge,
368
+ reconcile_ambiguous(forge, identity, request_id, admitted_record,
347
369
  document, candidate, result.reason)
348
370
  end
349
- rescue Molecules::OrdinaryFfMerge::GitUnavailable, Molecules::ForgejoMergeAdapter::Unavailable => e
371
+ rescue Molecules::OrdinaryMergeRead::GitUnavailable, Molecules::ForgejoMergeAdapter::Unavailable => e
350
372
  # The effect outcome is unprovable: the canonical record stays
351
373
  # uncertain with the original reference — never a resubmission.
352
374
  leave_uncertain(request_id, e.message)
353
375
  end
354
376
 
377
+ # Recheck pending/recovered authority and again after slow forge calls,
378
+ # immediately before each privileged effect. No cached decision is a lease.
379
+ def current_authorization_refusal(record)
380
+ @owner_guard&.call
381
+ decision = @resolver.decide(record.fetch("authorization"), authorization_binding(record))
382
+ decision.message unless decision.authorized?
383
+ rescue Ace::Lab::InvalidConfigurationError, Ace::Assign::Error => e
384
+ e.message
385
+ end
386
+
355
387
  # The merge invocation was accepted: prove the terminal state by
356
388
  # readback before any settlement. A readback showing a merge at any
357
389
  # head other than the approved candidate is never a success. A 404
@@ -361,7 +393,7 @@ module Ace
361
393
  # absence of effect — the record stays uncertain with the original
362
394
  # reference, the escalation owner named, no no-effect attestation
363
395
  # and no retry, never a re-invocation.
364
- def reconcile_invoked(forge, identity, request_id, admitted_record, merge,
396
+ def reconcile_invoked(forge, identity, request_id, admitted_record,
365
397
  document, candidate)
366
398
  pr = readback(forge, identity)
367
399
  if pr.nil?
@@ -389,7 +421,7 @@ module Ace
389
421
  # never a fabricated no-effect claim while the forge may still be
390
422
  # applying the invocation. A 404 after the invocation stays in the
391
423
  # same uncertain family: absence cannot prove no effect.
392
- def reconcile_ambiguous(forge, identity, request_id, admitted_record, merge,
424
+ def reconcile_ambiguous(forge, identity, request_id, admitted_record,
393
425
  document, candidate, reason)
394
426
  pr = readback(forge, identity)
395
427
  if pr.nil?
@@ -419,7 +451,7 @@ module Ace
419
451
  # refused or ambiguous attachment settles failed_no_effect: the merge
420
452
  # was never invoked, so no effect provably occurred.
421
453
  def attach_independent_review!(forge, identity, request_id, admitted_record,
422
- merge, document)
454
+ document)
423
455
  return nil unless @forge_config.review_attachment_enabled
424
456
  evidence = document.fetch("review_evidence")
425
457
  body = [
@@ -438,10 +470,10 @@ module Ace
438
470
  when :invoked
439
471
  nil
440
472
  when :refused
441
- settle_no_effect(request_id, admitted_record, merge,
473
+ settle_no_effect(request_id, admitted_record,
442
474
  "forge refused the independent review attachment: #{result.reason}")
443
475
  else
444
- settle_no_effect(request_id, admitted_record, merge,
476
+ settle_no_effect(request_id, admitted_record,
445
477
  "independent review attachment outcome ambiguous; merge not invoked: #{result.reason}")
446
478
  end
447
479
  end
@@ -512,11 +544,14 @@ module Ace
512
544
  end
513
545
 
514
546
  # 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)
547
+ # blob committed atomically with the terminal receipt in Assign's
548
+ # canonical evidence ref. No caller checkout path is written.
549
+ def settle_no_effect(request_id, admitted_record, reason)
518
550
  record = @coordinator.fixed_merge_record(request_id)
519
551
  document = record.fetch("request")
552
+ # Provider I/O has its own lifetime. Settlement needs a fresh bounded
553
+ # local observation, never the expired pre-effect read deadline.
554
+ merge = @merge_factory.call(document)
520
555
  attestation = {
521
556
  "request_id" => request_id,
522
557
  "input_digest" => record.fetch("input_digest"),
@@ -524,12 +559,11 @@ module Ace
524
559
  "candidate_head" => document.fetch("candidate_head"),
525
560
  "expected_base_head" => document.fetch("expected_base_head"),
526
561
  "observed_base_head" => merge.base_head,
527
- "strategy" => Molecules::OrdinaryFfMerge::STRATEGY,
562
+ "strategy" => Molecules::OrdinaryMergeRead::STRATEGY,
528
563
  "outcome" => "no_effect",
529
564
  "reason" => reason[0, 512],
530
565
  "recorded_at" => Time.now.utc.iso8601
531
566
  }
532
- evidence = write_no_effect_attestation(request_id, attestation)
533
567
  receipt = {
534
568
  "schema" => Ace::Assign::Atoms::FixedMergeSchemas::RECEIPT_SCHEMA_ID,
535
569
  "reference" => record.fetch("reference"),
@@ -555,16 +589,16 @@ module Ace
555
589
  "finished_at" => Time.now.utc.iso8601,
556
590
  "outcome" => "failed_no_effect",
557
591
  "failure_code" => "merge_failed_no_effect",
558
- "no_effect_evidence" => evidence
592
+ "no_effect_evidence" => nil
559
593
  },
560
594
  "remote_observation" => nil
561
595
  }
562
- settle(request_id, "failed", receipt)
596
+ settle(request_id, "failed", receipt, attestation: attestation)
563
597
  end
564
598
 
565
- def settle(request_id, terminal_status, receipt)
599
+ def settle(request_id, terminal_status, receipt, attestation: nil)
566
600
  @coordinator.settle_fixed_merge_effect(request_id, terminal_status: terminal_status,
567
- receipt: receipt)
601
+ receipt: receipt, attestation: attestation)
568
602
  terminal_status.to_sym
569
603
  rescue Ace::Assign::AttemptErrors::ReceiptRejected
570
604
  # The journal refused the receipt: the record stays uncertain with
@@ -587,15 +621,6 @@ module Ace
587
621
  nil
588
622
  end
589
623
 
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
624
  def target_changed!(message, request_id)
600
625
  raise OrdinaryMergeRefusal.new("ordinary_merge_target_changed", message,
601
626
  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.8.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.8.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,6 +233,7 @@ 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
222
237
  - lib/ace/lab/cli/commands/projects.rb
223
238
  - lib/ace/lab/cli/commands/resolve.rb
224
239
  - lib/ace/lab/cli/commands/route.rb
@@ -239,10 +254,14 @@ files:
239
254
  - lib/ace/lab/molecules/hitl_authorizer.rb
240
255
  - lib/ace/lab/molecules/inventory_query.rb
241
256
  - lib/ace/lab/molecules/ordinary_admission_resolver.rb
242
- - lib/ace/lab/molecules/ordinary_ff_merge.rb
243
257
  - lib/ace/lab/molecules/ordinary_listener.rb
258
+ - lib/ace/lab/molecules/ordinary_merge_read.rb
259
+ - lib/ace/lab/molecules/ordinary_native_read.rb
260
+ - lib/ace/lab/molecules/ordinary_owner_admission.rb
261
+ - lib/ace/lab/molecules/ordinary_owner_config.rb
244
262
  - lib/ace/lab/molecules/ordinary_receiver_config.rb
245
263
  - lib/ace/lab/molecules/ordinary_socket.rb
264
+ - lib/ace/lab/molecules/ordinary_worktree_read.rb
246
265
  - lib/ace/lab/molecules/protected_cleanup_owner_admission.rb
247
266
  - lib/ace/lab/molecules/protected_cleanup_owner_client.rb
248
267
  - lib/ace/lab/molecules/protected_cleanup_owner_identity.rb
@@ -253,6 +272,8 @@ files:
253
272
  - lib/ace/lab/molecules/service_policy.rb
254
273
  - lib/ace/lab/molecules/topology_loader.rb
255
274
  - lib/ace/lab/organisms/authority_composition.rb
275
+ - lib/ace/lab/organisms/ordinary_assignment_client.rb
276
+ - lib/ace/lab/organisms/ordinary_authority_composition.rb
256
277
  - lib/ace/lab/organisms/ordinary_merge_receiver.rb
257
278
  - lib/ace/lab/organisms/ordinary_merge_request.rb
258
279
  - 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