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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +9 -0
- data/docs/usage.md +155 -0
- data/lib/ace/lab/cli/commands/assignment.rb +45 -0
- data/lib/ace/lab/cli/commands/service.rb +12 -28
- data/lib/ace/lab/cli.rb +6 -1
- data/lib/ace/lab/molecules/ordinary_admission_resolver.rb +20 -1
- data/lib/ace/lab/molecules/ordinary_listener.rb +41 -17
- data/lib/ace/lab/molecules/ordinary_merge_read.rb +45 -0
- data/lib/ace/lab/molecules/ordinary_native_read.rb +106 -0
- data/lib/ace/lab/molecules/ordinary_owner_admission.rb +139 -0
- data/lib/ace/lab/molecules/ordinary_owner_config.rb +232 -0
- data/lib/ace/lab/molecules/ordinary_receiver_config.rb +12 -4
- data/lib/ace/lab/molecules/ordinary_socket.rb +6 -9
- data/lib/ace/lab/molecules/ordinary_worktree_read.rb +216 -0
- data/lib/ace/lab/molecules/service_executor.rb +9 -39
- data/lib/ace/lab/molecules/service_policy.rb +15 -4
- data/lib/ace/lab/organisms/authority_composition.rb +1 -1
- data/lib/ace/lab/organisms/ordinary_assignment_client.rb +149 -0
- data/lib/ace/lab/organisms/ordinary_authority_composition.rb +123 -0
- data/lib/ace/lab/organisms/ordinary_merge_receiver.rb +68 -43
- data/lib/ace/lab/organisms/ordinary_merge_request.rb +41 -7
- data/lib/ace/lab/version.rb +1 -1
- metadata +27 -6
- 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/
|
|
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 =
|
|
75
|
+
@configuration_sha256 = Molecules::ServicePolicy.configuration_digest(policy_document)
|
|
73
76
|
@merge_factory = merge_factory || ->(document) {
|
|
74
|
-
Molecules::
|
|
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::
|
|
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
|
-
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
368
|
+
reconcile_ambiguous(forge, identity, request_id, admitted_record,
|
|
347
369
|
document, candidate, result.reason)
|
|
348
370
|
end
|
|
349
|
-
rescue Molecules::
|
|
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,
|
|
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,
|
|
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
|
-
|
|
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,
|
|
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,
|
|
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
|
-
#
|
|
516
|
-
#
|
|
517
|
-
def settle_no_effect(request_id, admitted_record,
|
|
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::
|
|
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" =>
|
|
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" =>
|
|
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
|
-
|
|
265
|
-
|
|
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) &&
|
|
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? &&
|
data/lib/ace/lab/version.rb
CHANGED
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|