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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +21 -0
- data/docs/usage.md +343 -0
- data/lib/ace/lab/cli/commands/assignment.rb +92 -0
- data/lib/ace/lab/cli/commands/command_execute.rb +28 -0
- data/lib/ace/lab/cli/commands/run.rb +65 -0
- data/lib/ace/lab/cli/commands/service.rb +12 -28
- data/lib/ace/lab/cli.rb +16 -1
- data/lib/ace/lab/molecules/command_owner_admission.rb +183 -0
- data/lib/ace/lab/molecules/command_policy.rb +158 -0
- data/lib/ace/lab/molecules/grant_resolver.rb +15 -0
- 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 +152 -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/command_executor.rb +220 -0
- data/lib/ace/lab/organisms/command_run_client.rb +131 -0
- data/lib/ace/lab/organisms/ordinary_assignment_client.rb +120 -0
- data/lib/ace/lab/organisms/ordinary_authority_composition.rb +153 -0
- data/lib/ace/lab/organisms/ordinary_merge_receiver.rb +77 -61
- data/lib/ace/lab/organisms/ordinary_merge_request.rb +41 -7
- data/lib/ace/lab/version.rb +1 -1
- metadata +33 -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?
|
|
@@ -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
|
-
#
|
|
137
|
-
#
|
|
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::
|
|
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
|
-
|
|
180
|
-
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
371
|
+
reconcile_ambiguous(forge, identity, request_id, admitted_record,
|
|
347
372
|
document, candidate, result.reason)
|
|
348
373
|
end
|
|
349
|
-
rescue Molecules::
|
|
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,
|
|
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,
|
|
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
|
-
|
|
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,
|
|
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,
|
|
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
|
-
#
|
|
516
|
-
#
|
|
517
|
-
def settle_no_effect(request_id, admitted_record,
|
|
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::
|
|
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" =>
|
|
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" =>
|
|
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.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.
|
|
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,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
|