ace-lab 0.6.0 → 0.7.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 +13 -0
- data/lib/ace/lab/cli/commands/service.rb +80 -0
- data/lib/ace/lab/cli.rb +3 -1
- data/lib/ace/lab/molecules/forge_merge_config.rb +127 -0
- data/lib/ace/lab/molecules/forgejo_merge_adapter.rb +150 -0
- data/lib/ace/lab/molecules/grant_resolver.rb +15 -13
- data/lib/ace/lab/molecules/ordinary_admission_resolver.rb +112 -0
- data/lib/ace/lab/molecules/ordinary_ff_merge.rb +86 -0
- data/lib/ace/lab/molecules/ordinary_listener.rb +238 -0
- data/lib/ace/lab/molecules/ordinary_receiver_config.rb +86 -0
- data/lib/ace/lab/organisms/ordinary_merge_receiver.rb +663 -0
- data/lib/ace/lab/version.rb +1 -1
- metadata +25 -4
|
@@ -0,0 +1,663 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
require "fileutils"
|
|
5
|
+
require "json"
|
|
6
|
+
require "uri"
|
|
7
|
+
require_relative "ordinary_merge_request"
|
|
8
|
+
require_relative "../molecules/ordinary_receiver_config"
|
|
9
|
+
require_relative "../molecules/ordinary_admission_resolver"
|
|
10
|
+
require_relative "../molecules/ordinary_ff_merge"
|
|
11
|
+
require_relative "../molecules/forge_merge_config"
|
|
12
|
+
require_relative "../molecules/forgejo_merge_adapter"
|
|
13
|
+
require "ace/assign/atoms/fixed_merge_schemas"
|
|
14
|
+
|
|
15
|
+
module Ace
|
|
16
|
+
module Lab
|
|
17
|
+
module Organisms
|
|
18
|
+
# The ordinary fixed merge RECEIVER/EXECUTOR (spec 8x3.t.xz9.4
|
|
19
|
+
# execution clauses) — the execution side of the ordinary merge chain.
|
|
20
|
+
# One mode of the maintained service vocabulary: `handle` performs the
|
|
21
|
+
# authenticated admission (mutual-identity binding, canonical digest
|
|
22
|
+
# re-derivation, scoped YAML authorization resolution, pre-effect
|
|
23
|
+
# target verification, durable Assign admission) and returns the exact
|
|
24
|
+
# wire envelope; `execute` then runs the authorized fixed operation as
|
|
25
|
+
# the authorized executor — at most one fast-forward merge per admitted
|
|
26
|
+
# request — and settles the exact terminal receipt into the canonical
|
|
27
|
+
# Assign attempt/evidence journal. The receiver owns no ledger: every
|
|
28
|
+
# durable write lives in Assign, and every refusal uses the closed
|
|
29
|
+
# producer envelope vocabulary.
|
|
30
|
+
#
|
|
31
|
+
# Merge-method contract: the closed schema's literal `method=merge`
|
|
32
|
+
# executes FAST-FORWARD-ONLY, on the forge wire as well (`Do:
|
|
33
|
+
# fast-forward-only`): a candidate that is not a descendant of the
|
|
34
|
+
# current base head refuses in the moved-target/needs-rebase family
|
|
35
|
+
# (never a silent substitution, never a merge-commit fallback); the
|
|
36
|
+
# terminal receipt proves the strategy by joining `merge_commit ==
|
|
37
|
+
# resulting_target_head == merged_candidate_head`. The merge POST
|
|
38
|
+
# additionally binds the approved candidate head SERVER-SIDE
|
|
39
|
+
# (`head_commit_id`): the forge refuses to merge any head other than
|
|
40
|
+
# the reviewed candidate, so a race between the state readback and
|
|
41
|
+
# the merge POST can never merge an unreviewed head.
|
|
42
|
+
#
|
|
43
|
+
# Forge effect contract (spec 8x3.t.xz9.5 integration handoff): the
|
|
44
|
+
# authorized effect is a REAL Forgejo pull request merge invoked under
|
|
45
|
+
# the scoped service identity from the host-installed forge merge
|
|
46
|
+
# configuration, and the terminal receipt records the REAL forge
|
|
47
|
+
# readback (`merged`, `merge_commit_sha`) — a local git effect is
|
|
48
|
+
# never presented as a forge result, and the receiver never updates
|
|
49
|
+
# the checked-out canonical main locally. The local repository
|
|
50
|
+
# (the producer's canonical checkout) serves read-only pre-effect
|
|
51
|
+
# verification only. Missing host configuration is an immediate
|
|
52
|
+
# actionable refusal naming the path and owner; ambiguous outcomes
|
|
53
|
+
# (timeouts, 5xx, unavailable readback) reconcile by bounded readback
|
|
54
|
+
# retry — never a blind re-dispatch — and leave the canonical record
|
|
55
|
+
# uncertain with the escalation owner named when unresolved.
|
|
56
|
+
class OrdinaryMergeReceiver
|
|
57
|
+
SUBMISSION_KEYS = %w[project_id assignment_id attempt_id queue_entry operation
|
|
58
|
+
authorization request_id input_digest request principal receiver_id
|
|
59
|
+
configuration_sha256].freeze
|
|
60
|
+
ID = Molecules::ServicePolicy::ID
|
|
61
|
+
NO_EFFECT_DIR = ".ace/ordinary-no-effect"
|
|
62
|
+
|
|
63
|
+
def initialize(config:, coordinator:, policy_document:, merge_factory: nil,
|
|
64
|
+
forge_config: nil, forge_factory: nil, resolver: nil,
|
|
65
|
+
readback_attempts: 3, readback_backoff_seconds: 1.0)
|
|
66
|
+
unless config.is_a?(Molecules::OrdinaryReceiverConfig)
|
|
67
|
+
raise ArgumentError, "receiver needs validated OrdinaryReceiverConfig"
|
|
68
|
+
end
|
|
69
|
+
@config = config
|
|
70
|
+
@coordinator = coordinator
|
|
71
|
+
@policy_document = policy_document
|
|
72
|
+
@configuration_sha256 = Ace::Lab::Atoms::ServiceInput.digest(policy_document)
|
|
73
|
+
@merge_factory = merge_factory || ->(document) {
|
|
74
|
+
Molecules::OrdinaryFfMerge.new(repo_path: config.repo_path,
|
|
75
|
+
base_ref: document.fetch("base_ref"))
|
|
76
|
+
}
|
|
77
|
+
# The scoped service identity is host-installed; an absent
|
|
78
|
+
# configuration refuses construction with the actionable path.
|
|
79
|
+
@forge_config = forge_config || Molecules::ForgeMergeConfig.load
|
|
80
|
+
@forge_factory = forge_factory || ->(_document) {
|
|
81
|
+
Molecules::ForgejoMergeAdapter.new(base_url: @forge_config.base_url,
|
|
82
|
+
token: @forge_config.token, timeout_seconds: @forge_config.merge_timeout_seconds)
|
|
83
|
+
}
|
|
84
|
+
@resolver = resolver || Molecules::OrdinaryAdmissionResolver.new(
|
|
85
|
+
policy_document, path: config.policy_path
|
|
86
|
+
)
|
|
87
|
+
@readback_attempts = readback_attempts
|
|
88
|
+
@readback_backoff_seconds = readback_backoff_seconds
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
attr_reader :config, :forge_config
|
|
92
|
+
|
|
93
|
+
# The listener handler entrypoint: admission only; the listener
|
|
94
|
+
# invokes #execute after the acknowledgment is on the wire.
|
|
95
|
+
def call(submission)
|
|
96
|
+
handle(submission)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Authenticated admission of one wire submission. Returns the exact
|
|
100
|
+
# wire envelope: the durable acknowledgment after the canonical
|
|
101
|
+
# Assign record exists, or a closed pre-effect refusal envelope that
|
|
102
|
+
# proves no effect began. Never a receiver-local record.
|
|
103
|
+
def handle(submission)
|
|
104
|
+
invalid!("submission is not a mapping") unless submission.is_a?(Hash)
|
|
105
|
+
invalid!("submission has unknown selectors: #{unknown(submission)}") unless
|
|
106
|
+
(submission.keys - SUBMISSION_KEYS).empty?
|
|
107
|
+
invalid!("submission is missing selectors") unless (SUBMISSION_KEYS - submission.keys).empty?
|
|
108
|
+
request_id = submission.fetch("request_id")
|
|
109
|
+
unless submission.fetch("operation") == OrdinaryMergeRequest::OPERATION
|
|
110
|
+
invalid!("operation must be merge", request_id)
|
|
111
|
+
end
|
|
112
|
+
identifiers!(submission)
|
|
113
|
+
unless submission.fetch("receiver_id") == @config.receiver_id
|
|
114
|
+
return selection_mismatch("submission does not target this receiver", request_id)
|
|
115
|
+
end
|
|
116
|
+
unless submission.fetch("project_id") == @config.project
|
|
117
|
+
return selection_mismatch("submission does not target this receiver project", request_id)
|
|
118
|
+
end
|
|
119
|
+
unless submission.fetch("principal") == @config.principal
|
|
120
|
+
return selection_mismatch("submission principal does not match the installed mapping", request_id)
|
|
121
|
+
end
|
|
122
|
+
unless submission.fetch("configuration_sha256") == @configuration_sha256
|
|
123
|
+
return selection_mismatch("installed configuration differs from the submitting policy", request_id)
|
|
124
|
+
end
|
|
125
|
+
document = submission.fetch("request")
|
|
126
|
+
begin
|
|
127
|
+
Ace::Assign::Atoms::FixedMergeSchemas.validate_request!(document)
|
|
128
|
+
Ace::Assign::Atoms::FixedMergeSchemas.require_request_joins!(document)
|
|
129
|
+
rescue Ace::Assign::Atoms::FixedMergeSchemas::SchemaError => e
|
|
130
|
+
invalid!(e.message, request_id)
|
|
131
|
+
end
|
|
132
|
+
digest = rederive_digest(submission, document)
|
|
133
|
+
unless digest == submission.fetch("input_digest")
|
|
134
|
+
invalid!("canonical request digest does not re-derive from the submission", request_id)
|
|
135
|
+
end
|
|
136
|
+
# Selection and evidence integrity before authorization: the PR
|
|
137
|
+
# must target THIS receiver's configured forge repository, and the
|
|
138
|
+
# handed-over review must be independent of the executor.
|
|
139
|
+
forge_identity!(document, request_id)
|
|
140
|
+
reviewer_independence!(document, request_id)
|
|
141
|
+
decision = @resolver.decide(submission.fetch("authorization"), authorization_binding(submission))
|
|
142
|
+
unless decision.authorized?
|
|
143
|
+
return OrdinaryMergeRequest.refusal_envelope(decision.code, decision.message,
|
|
144
|
+
request_id: request_id, input_digest: digest)
|
|
145
|
+
end
|
|
146
|
+
verify_pre_effect_target!(document, request_id)
|
|
147
|
+
admitted = admit(submission)
|
|
148
|
+
ack_envelope(admitted)
|
|
149
|
+
rescue OrdinaryMergeRefusal => e
|
|
150
|
+
OrdinaryMergeRequest.refusal_envelope(e.code, e.detail,
|
|
151
|
+
request_id: e.request_id, input_digest: e.input_digest)
|
|
152
|
+
rescue Ace::Assign::AttemptErrors::AuthorizationConflict => e
|
|
153
|
+
OrdinaryMergeRequest.refusal_envelope("ordinary_merge_authorization_invalid", e.message,
|
|
154
|
+
request_id: submission.is_a?(Hash) ? submission.fetch("request_id", "unknown") : "unknown",
|
|
155
|
+
input_digest: nil)
|
|
156
|
+
rescue Ace::Assign::AttemptErrors::Conflict => e
|
|
157
|
+
OrdinaryMergeRequest.refusal_envelope("ordinary_merge_request_conflict", e.message,
|
|
158
|
+
request_id: submission.is_a?(Hash) ? submission.fetch("request_id", "unknown") : "unknown",
|
|
159
|
+
input_digest: nil)
|
|
160
|
+
rescue Molecules::OrdinaryFfMerge::GitUnavailable, Ace::Assign::AttemptErrors::EvidenceUnavailable,
|
|
161
|
+
Ace::Lab::InvalidConfigurationError => e
|
|
162
|
+
OrdinaryMergeRequest.refusal_envelope("ordinary_merge_unavailable", e.message,
|
|
163
|
+
request_id: submission.is_a?(Hash) ? submission.fetch("request_id", "unknown") : "unknown",
|
|
164
|
+
input_digest: nil)
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
# The executor lifecycle for one admitted request: the single
|
|
168
|
+
# pending->uncertain CAS is the at-most-one-merge proof; a record
|
|
169
|
+
# already begun by a lost lifecycle stays uncertain and is NEVER
|
|
170
|
+
# re-executed or resubmitted. The effect is one REAL forge merge
|
|
171
|
+
# invocation under the scoped service identity; settlement is one
|
|
172
|
+
# journal CAS validated against the closed receipt schema from the
|
|
173
|
+
# real forge readback. Any crash between begin and settle leaves the
|
|
174
|
+
# canonical record uncertain with the original reference.
|
|
175
|
+
def execute(request_id)
|
|
176
|
+
record = @coordinator.fixed_merge_record(request_id)
|
|
177
|
+
return :unknown unless record
|
|
178
|
+
return :skipped_terminal unless record.fetch("state") == "pending"
|
|
179
|
+
@coordinator.begin_fixed_merge_effect(request_id)
|
|
180
|
+
execute_effect(record)
|
|
181
|
+
:executed
|
|
182
|
+
rescue Ace::Assign::AttemptErrors::InvalidState, Ace::Assign::AttemptErrors::NotFound
|
|
183
|
+
# Another lifecycle won the CAS or the record is terminal: never a
|
|
184
|
+
# second effect, never a resubmission.
|
|
185
|
+
:skipped_terminal
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
# Receiver-restart reconciliation: adopt only records this exact
|
|
189
|
+
# installed configuration owns, still pending, and drive each one to
|
|
190
|
+
# a terminal state at most once. Already-uncertain records (a lost
|
|
191
|
+
# lifecycle) are never re-executed — they reconcile by reference.
|
|
192
|
+
def recover_pending!
|
|
193
|
+
records = @coordinator.fixed_merge_records
|
|
194
|
+
records.filter_map do |record|
|
|
195
|
+
next nil unless record.fetch("state") == "pending" &&
|
|
196
|
+
record.fetch("receiver_id") == @config.receiver_id &&
|
|
197
|
+
record.fetch("configuration_sha256") == @configuration_sha256 &&
|
|
198
|
+
record.fetch("project_id") == @config.project
|
|
199
|
+
[record.fetch("request_id"), execute(record.fetch("request_id"))]
|
|
200
|
+
end
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
private
|
|
204
|
+
|
|
205
|
+
# ---- admission -----------------------------------------------------
|
|
206
|
+
|
|
207
|
+
def identifiers!(submission)
|
|
208
|
+
%w[project_id assignment_id attempt_id queue_entry request_id].each do |key|
|
|
209
|
+
value = submission.fetch(key)
|
|
210
|
+
invalid!("invalid #{key}") unless value.is_a?(String) && value.match?(ID)
|
|
211
|
+
end
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
def rederive_digest(submission, document)
|
|
215
|
+
Ace::Assign::Atoms::FixedMergeSchemas.canonical_request_digest(
|
|
216
|
+
project: submission.fetch("project_id"), assignment: submission.fetch("assignment_id"),
|
|
217
|
+
attempt: submission.fetch("attempt_id"), queue_entry: submission.fetch("queue_entry"),
|
|
218
|
+
authorization: submission.fetch("authorization"),
|
|
219
|
+
request_id: submission.fetch("request_id"), input: document
|
|
220
|
+
)
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
def authorization_binding(submission)
|
|
224
|
+
document = submission.fetch("request")
|
|
225
|
+
{
|
|
226
|
+
"operation" => submission.fetch("operation"),
|
|
227
|
+
"project_id" => submission.fetch("project_id"),
|
|
228
|
+
"assignment_id" => submission.fetch("assignment_id"),
|
|
229
|
+
"attempt_id" => submission.fetch("attempt_id"),
|
|
230
|
+
"queue_entry" => submission.fetch("queue_entry"),
|
|
231
|
+
"input_digest" => submission.fetch("input_digest"),
|
|
232
|
+
"forge" => document.fetch("forge"),
|
|
233
|
+
"pr_url" => document.fetch("pr_url"),
|
|
234
|
+
"head_repository" => document.fetch("head_repository"),
|
|
235
|
+
"head_ref" => document.fetch("head_ref"),
|
|
236
|
+
"candidate_head" => document.fetch("candidate_head"),
|
|
237
|
+
"base_repository" => document.fetch("base_repository"),
|
|
238
|
+
"base_ref" => document.fetch("base_ref"),
|
|
239
|
+
"expected_base_head" => document.fetch("expected_base_head")
|
|
240
|
+
}
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
# Pre-effect target verification before any admission — read-only
|
|
244
|
+
# against the canonical repository (the producer's canonical
|
|
245
|
+
# checkout): the candidate object must exist and the base head must
|
|
246
|
+
# still equal the immutable request. Moved targets answer the
|
|
247
|
+
# moved-target/needs-rebase refusal family before any effect.
|
|
248
|
+
def verify_pre_effect_target!(document, request_id)
|
|
249
|
+
merge = @merge_factory.call(document)
|
|
250
|
+
candidate = document.fetch("candidate_head")
|
|
251
|
+
unless merge.candidate_commit?(candidate)
|
|
252
|
+
target_changed!("candidate head is absent from the canonical repository", request_id)
|
|
253
|
+
end
|
|
254
|
+
observed = merge.base_head
|
|
255
|
+
if observed != document.fetch("expected_base_head")
|
|
256
|
+
target_changed!("base head moved from the immutable request", request_id)
|
|
257
|
+
end
|
|
258
|
+
# Merge-method contract: `method=merge` is FAST-FORWARD-ONLY. A
|
|
259
|
+
# candidate that is not a descendant of the current base head is
|
|
260
|
+
# the moved-target/needs-rebase refusal family — never a silent
|
|
261
|
+
# method substitution, never a merge-commit fallback.
|
|
262
|
+
unless merge.fast_forwardable?(candidate)
|
|
263
|
+
target_changed!("candidate head is not fast-forwardable onto the base head", request_id)
|
|
264
|
+
end
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
def admit(submission)
|
|
268
|
+
record = submission.merge(
|
|
269
|
+
"principal" => @config.principal,
|
|
270
|
+
"execution_identity" => @config.execution_identity
|
|
271
|
+
)
|
|
272
|
+
@coordinator.admit_fixed_merge_request(record)
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
def ack_envelope(record)
|
|
276
|
+
{"status" => "ok", "data" => {
|
|
277
|
+
"schema" => Ace::Assign::Atoms::FixedMergeSchemas::ACK_SCHEMA_ID,
|
|
278
|
+
"type" => "accepted",
|
|
279
|
+
"request_id" => record.fetch("request_id"),
|
|
280
|
+
"input_digest" => record.fetch("input_digest"),
|
|
281
|
+
"reference" => record.fetch("reference")
|
|
282
|
+
}}
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
# The effect: re-verify the base head read-only, verify the forge PR
|
|
286
|
+
# state readback (head == candidate, base == expected), attach the
|
|
287
|
+
# independent final-head review when the host installed it, invoke
|
|
288
|
+
# the fast-forward-only merge, and settle from the real readback —
|
|
289
|
+
# reconciling ambiguity by bounded readback retry, never by
|
|
290
|
+
# repeating the merge.
|
|
291
|
+
def execute_effect(admitted_record)
|
|
292
|
+
request_id = admitted_record.fetch("request_id")
|
|
293
|
+
document = admitted_record.fetch("request")
|
|
294
|
+
candidate = document.fetch("candidate_head")
|
|
295
|
+
expected = document.fetch("expected_base_head")
|
|
296
|
+
merge = @merge_factory.call(document)
|
|
297
|
+
observed = merge.base_head
|
|
298
|
+
if observed != expected
|
|
299
|
+
return settle_no_effect(request_id, admitted_record, merge,
|
|
300
|
+
"base head moved before the effect: #{observed[0, 12]}")
|
|
301
|
+
end
|
|
302
|
+
identity = forge_identity!(document, request_id)
|
|
303
|
+
forge = @forge_factory.call(document)
|
|
304
|
+
pr = readback(forge, identity)
|
|
305
|
+
if pr.nil?
|
|
306
|
+
return settle_no_effect(request_id, admitted_record, merge,
|
|
307
|
+
"pull request is absent from the forge")
|
|
308
|
+
end
|
|
309
|
+
if pr["merged"]
|
|
310
|
+
sha = pr["merge_commit_sha"]
|
|
311
|
+
if sha == candidate
|
|
312
|
+
return settle_merged(request_id, admitted_record, document, sha,
|
|
313
|
+
outcome: "already_merged")
|
|
314
|
+
end
|
|
315
|
+
return leave_uncertain(request_id,
|
|
316
|
+
"forge shows the pull request already merged at #{head_label(sha)}, " \
|
|
317
|
+
"not the approved candidate #{candidate[0, 12]}",
|
|
318
|
+
owner: @forge_config.escalation_owner)
|
|
319
|
+
end
|
|
320
|
+
unless pr.dig("head", "sha") == candidate
|
|
321
|
+
return settle_no_effect(request_id, admitted_record, merge,
|
|
322
|
+
"forge pull request head moved from the approved candidate")
|
|
323
|
+
end
|
|
324
|
+
unless pr.dig("base", "sha") == expected
|
|
325
|
+
return settle_no_effect(request_id, admitted_record, merge,
|
|
326
|
+
"forge base head moved from the immutable request")
|
|
327
|
+
end
|
|
328
|
+
refusal = attach_independent_review!(forge, identity, request_id,
|
|
329
|
+
admitted_record, merge, document)
|
|
330
|
+
return refusal if refusal
|
|
331
|
+
# Exact-head contract: the merge POST binds the approved candidate
|
|
332
|
+
# head server-side — the forge refuses to merge any head other
|
|
333
|
+
# than the reviewed candidate (race between readback and POST can
|
|
334
|
+
# never merge an unreviewed head).
|
|
335
|
+
result = forge.merge_pull_request!(owner: identity.fetch("owner"),
|
|
336
|
+
repo: identity.fetch("repo"), index: identity.fetch("index"),
|
|
337
|
+
head_commit_id: candidate)
|
|
338
|
+
case result.outcome
|
|
339
|
+
when :invoked
|
|
340
|
+
reconcile_invoked(forge, identity, request_id, admitted_record, merge,
|
|
341
|
+
document, candidate)
|
|
342
|
+
when :refused
|
|
343
|
+
settle_no_effect(request_id, admitted_record, merge,
|
|
344
|
+
"forge refused the merge: #{result.reason}")
|
|
345
|
+
else
|
|
346
|
+
reconcile_ambiguous(forge, identity, request_id, admitted_record, merge,
|
|
347
|
+
document, candidate, result.reason)
|
|
348
|
+
end
|
|
349
|
+
rescue Molecules::OrdinaryFfMerge::GitUnavailable, Molecules::ForgejoMergeAdapter::Unavailable => e
|
|
350
|
+
# The effect outcome is unprovable: the canonical record stays
|
|
351
|
+
# uncertain with the original reference — never a resubmission.
|
|
352
|
+
leave_uncertain(request_id, e.message)
|
|
353
|
+
end
|
|
354
|
+
|
|
355
|
+
# The merge invocation was accepted: prove the terminal state by
|
|
356
|
+
# readback before any settlement. A readback showing a merge at any
|
|
357
|
+
# head other than the approved candidate is never a success. A 404
|
|
358
|
+
# AFTER the invocation is never a no-effect proof either: the merge
|
|
359
|
+
# POST was actually invoked, so the pull request's absence
|
|
360
|
+
# (permission change, deletion, visibility loss) cannot prove
|
|
361
|
+
# absence of effect — the record stays uncertain with the original
|
|
362
|
+
# reference, the escalation owner named, no no-effect attestation
|
|
363
|
+
# and no retry, never a re-invocation.
|
|
364
|
+
def reconcile_invoked(forge, identity, request_id, admitted_record, merge,
|
|
365
|
+
document, candidate)
|
|
366
|
+
pr = readback(forge, identity)
|
|
367
|
+
if pr.nil?
|
|
368
|
+
return leave_uncertain(request_id,
|
|
369
|
+
"pull request is absent from the forge after the merge invocation: absence " \
|
|
370
|
+
"cannot prove no effect (permission change, deletion, or visibility loss " \
|
|
371
|
+
"may hide an applied merge)",
|
|
372
|
+
owner: @forge_config.escalation_owner)
|
|
373
|
+
end
|
|
374
|
+
if pr["merged"]
|
|
375
|
+
sha = pr["merge_commit_sha"]
|
|
376
|
+
return settle_merged(request_id, admitted_record, document, sha) if sha == candidate
|
|
377
|
+
return leave_uncertain(request_id,
|
|
378
|
+
"forge merged the pull request at #{head_label(sha)}, " \
|
|
379
|
+
"not the approved candidate #{candidate[0, 12]}",
|
|
380
|
+
owner: @forge_config.escalation_owner)
|
|
381
|
+
end
|
|
382
|
+
leave_uncertain(request_id,
|
|
383
|
+
"forge accepted the merge invocation but the readback shows the pull request unmerged")
|
|
384
|
+
end
|
|
385
|
+
|
|
386
|
+
# The merge invocation outcome is ambiguous (timeout, reset, 5xx):
|
|
387
|
+
# reconciliation is readback-only. A readback proving the merge
|
|
388
|
+
# settles it; anything else stays uncertain — never a blind repeat,
|
|
389
|
+
# never a fabricated no-effect claim while the forge may still be
|
|
390
|
+
# applying the invocation. A 404 after the invocation stays in the
|
|
391
|
+
# same uncertain family: absence cannot prove no effect.
|
|
392
|
+
def reconcile_ambiguous(forge, identity, request_id, admitted_record, merge,
|
|
393
|
+
document, candidate, reason)
|
|
394
|
+
pr = readback(forge, identity)
|
|
395
|
+
if pr.nil?
|
|
396
|
+
return leave_uncertain(request_id,
|
|
397
|
+
"merge invocation outcome ambiguous (#{reason}); the pull request is absent " \
|
|
398
|
+
"from the forge after the invocation and absence cannot prove no effect",
|
|
399
|
+
owner: @forge_config.escalation_owner)
|
|
400
|
+
end
|
|
401
|
+
if pr["merged"]
|
|
402
|
+
sha = pr["merge_commit_sha"]
|
|
403
|
+
return settle_merged(request_id, admitted_record, document, sha) if sha == candidate
|
|
404
|
+
return leave_uncertain(request_id,
|
|
405
|
+
"forge merged the pull request at #{head_label(sha)}, " \
|
|
406
|
+
"not the approved candidate #{candidate[0, 12]}",
|
|
407
|
+
owner: @forge_config.escalation_owner)
|
|
408
|
+
end
|
|
409
|
+
leave_uncertain(request_id,
|
|
410
|
+
"merge invocation outcome ambiguous (#{reason}); forge readback after " \
|
|
411
|
+
"reconciliation shows the pull request unmerged",
|
|
412
|
+
owner: @forge_config.escalation_owner)
|
|
413
|
+
end
|
|
414
|
+
|
|
415
|
+
# Attach the INDEPENDENT final-head review body to the PR before the
|
|
416
|
+
# merge when the host enabled review attachment. The body is composed
|
|
417
|
+
# only of the reviewer-produced evidence handed over in the admitted
|
|
418
|
+
# request — the receiver never composes an approval of its own. A
|
|
419
|
+
# refused or ambiguous attachment settles failed_no_effect: the merge
|
|
420
|
+
# was never invoked, so no effect provably occurred.
|
|
421
|
+
def attach_independent_review!(forge, identity, request_id, admitted_record,
|
|
422
|
+
merge, document)
|
|
423
|
+
return nil unless @forge_config.review_attachment_enabled
|
|
424
|
+
evidence = document.fetch("review_evidence")
|
|
425
|
+
body = [
|
|
426
|
+
"Independent final-head review evidence (reviewer-produced, attached verbatim " \
|
|
427
|
+
"from the admitted merge request):",
|
|
428
|
+
"- reviewer: #{evidence.fetch("reviewer")}",
|
|
429
|
+
"- verdict: #{evidence.fetch("verdict")}",
|
|
430
|
+
"- reviewed_head: #{evidence.fetch("reviewed_head")}",
|
|
431
|
+
"- reference: #{evidence.fetch("reference")}",
|
|
432
|
+
"- recorded_at: #{evidence.fetch("recorded_at")}",
|
|
433
|
+
"- record_sha256: #{evidence.fetch("record_sha256")}"
|
|
434
|
+
].join("\n")
|
|
435
|
+
result = forge.create_comment(owner: identity.fetch("owner"),
|
|
436
|
+
repo: identity.fetch("repo"), index: identity.fetch("index"), body: body)
|
|
437
|
+
case result.outcome
|
|
438
|
+
when :invoked
|
|
439
|
+
nil
|
|
440
|
+
when :refused
|
|
441
|
+
settle_no_effect(request_id, admitted_record, merge,
|
|
442
|
+
"forge refused the independent review attachment: #{result.reason}")
|
|
443
|
+
else
|
|
444
|
+
settle_no_effect(request_id, admitted_record, merge,
|
|
445
|
+
"independent review attachment outcome ambiguous; merge not invoked: #{result.reason}")
|
|
446
|
+
end
|
|
447
|
+
end
|
|
448
|
+
|
|
449
|
+
# One authoritative readback; transient forge unavailability is
|
|
450
|
+
# retried (reconciliation) a bounded number of times before
|
|
451
|
+
# surfacing — the merge is never re-dispatched from here.
|
|
452
|
+
def readback(forge, identity)
|
|
453
|
+
attempts = @readback_attempts
|
|
454
|
+
begin
|
|
455
|
+
attempts -= 1
|
|
456
|
+
forge.pull_request(owner: identity.fetch("owner"),
|
|
457
|
+
repo: identity.fetch("repo"), index: identity.fetch("index"))
|
|
458
|
+
rescue Molecules::ForgejoMergeAdapter::Unavailable
|
|
459
|
+
raise if attempts.zero?
|
|
460
|
+
sleep @readback_backoff_seconds
|
|
461
|
+
retry
|
|
462
|
+
end
|
|
463
|
+
end
|
|
464
|
+
|
|
465
|
+
def settle_merged(request_id, admitted_record, document, merge_commit_sha,
|
|
466
|
+
outcome: "merged")
|
|
467
|
+
receipt = {
|
|
468
|
+
"schema" => Ace::Assign::Atoms::FixedMergeSchemas::RECEIPT_SCHEMA_ID,
|
|
469
|
+
"reference" => admitted_record.fetch("reference"),
|
|
470
|
+
"request_id" => admitted_record.fetch("request_id"),
|
|
471
|
+
"input_digest" => admitted_record.fetch("input_digest"),
|
|
472
|
+
"terminal_status" => "succeeded",
|
|
473
|
+
"request" => {
|
|
474
|
+
"project" => admitted_record.fetch("project_id"),
|
|
475
|
+
"assignment" => admitted_record.fetch("assignment_id"),
|
|
476
|
+
"attempt" => admitted_record.fetch("attempt_id"),
|
|
477
|
+
"queue_entry" => admitted_record.fetch("queue_entry"),
|
|
478
|
+
"operation" => admitted_record.fetch("operation"),
|
|
479
|
+
"authorization" => admitted_record.fetch("authorization"),
|
|
480
|
+
"input" => document
|
|
481
|
+
},
|
|
482
|
+
"producer" => {
|
|
483
|
+
"receiver_id" => admitted_record.fetch("receiver_id"),
|
|
484
|
+
"principal" => admitted_record.fetch("principal"),
|
|
485
|
+
"configuration_sha256" => admitted_record.fetch("configuration_sha256")
|
|
486
|
+
},
|
|
487
|
+
"effect" => {
|
|
488
|
+
"started_at" => Time.now.utc.iso8601,
|
|
489
|
+
"finished_at" => Time.now.utc.iso8601,
|
|
490
|
+
"outcome" => outcome,
|
|
491
|
+
"failure_code" => nil,
|
|
492
|
+
"no_effect_evidence" => nil
|
|
493
|
+
},
|
|
494
|
+
# The REAL forge readback: the merge proof is the forge-reported
|
|
495
|
+
# merge commit (for the fast-forward-only strategy it equals the
|
|
496
|
+
# merged candidate head), never a local git observation.
|
|
497
|
+
"remote_observation" => {
|
|
498
|
+
"forge_instance" => document.fetch("forge"),
|
|
499
|
+
"pr_identity_url" => document.fetch("pr_url"),
|
|
500
|
+
"target_repository_identity" => document.fetch("base_repository"),
|
|
501
|
+
"target_ref_observed" => document.fetch("base_ref"),
|
|
502
|
+
"merged_candidate_head" => document.fetch("candidate_head"),
|
|
503
|
+
"admission_target_head" => document.fetch("expected_base_head"),
|
|
504
|
+
"resulting_target_head" => merge_commit_sha,
|
|
505
|
+
"merge_commit" => merge_commit_sha,
|
|
506
|
+
"checkout_id" => admitted_record.dig("execution_identity", "checkout_id"),
|
|
507
|
+
"worktree_id" => admitted_record.dig("execution_identity", "worktree_id"),
|
|
508
|
+
"branch" => admitted_record.dig("execution_identity", "branch")
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
settle(request_id, "succeeded", receipt)
|
|
512
|
+
end
|
|
513
|
+
|
|
514
|
+
# 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)
|
|
518
|
+
record = @coordinator.fixed_merge_record(request_id)
|
|
519
|
+
document = record.fetch("request")
|
|
520
|
+
attestation = {
|
|
521
|
+
"request_id" => request_id,
|
|
522
|
+
"input_digest" => record.fetch("input_digest"),
|
|
523
|
+
"reference" => record.fetch("reference"),
|
|
524
|
+
"candidate_head" => document.fetch("candidate_head"),
|
|
525
|
+
"expected_base_head" => document.fetch("expected_base_head"),
|
|
526
|
+
"observed_base_head" => merge.base_head,
|
|
527
|
+
"strategy" => Molecules::OrdinaryFfMerge::STRATEGY,
|
|
528
|
+
"outcome" => "no_effect",
|
|
529
|
+
"reason" => reason[0, 512],
|
|
530
|
+
"recorded_at" => Time.now.utc.iso8601
|
|
531
|
+
}
|
|
532
|
+
evidence = write_no_effect_attestation(request_id, attestation)
|
|
533
|
+
receipt = {
|
|
534
|
+
"schema" => Ace::Assign::Atoms::FixedMergeSchemas::RECEIPT_SCHEMA_ID,
|
|
535
|
+
"reference" => record.fetch("reference"),
|
|
536
|
+
"request_id" => record.fetch("request_id"),
|
|
537
|
+
"input_digest" => record.fetch("input_digest"),
|
|
538
|
+
"terminal_status" => "failed",
|
|
539
|
+
"request" => {
|
|
540
|
+
"project" => record.fetch("project_id"),
|
|
541
|
+
"assignment" => record.fetch("assignment_id"),
|
|
542
|
+
"attempt" => record.fetch("attempt_id"),
|
|
543
|
+
"queue_entry" => record.fetch("queue_entry"),
|
|
544
|
+
"operation" => record.fetch("operation"),
|
|
545
|
+
"authorization" => record.fetch("authorization"),
|
|
546
|
+
"input" => document
|
|
547
|
+
},
|
|
548
|
+
"producer" => {
|
|
549
|
+
"receiver_id" => record.fetch("receiver_id"),
|
|
550
|
+
"principal" => record.fetch("principal"),
|
|
551
|
+
"configuration_sha256" => record.fetch("configuration_sha256")
|
|
552
|
+
},
|
|
553
|
+
"effect" => {
|
|
554
|
+
"started_at" => Time.now.utc.iso8601,
|
|
555
|
+
"finished_at" => Time.now.utc.iso8601,
|
|
556
|
+
"outcome" => "failed_no_effect",
|
|
557
|
+
"failure_code" => "merge_failed_no_effect",
|
|
558
|
+
"no_effect_evidence" => evidence
|
|
559
|
+
},
|
|
560
|
+
"remote_observation" => nil
|
|
561
|
+
}
|
|
562
|
+
settle(request_id, "failed", receipt)
|
|
563
|
+
end
|
|
564
|
+
|
|
565
|
+
def settle(request_id, terminal_status, receipt)
|
|
566
|
+
@coordinator.settle_fixed_merge_effect(request_id, terminal_status: terminal_status,
|
|
567
|
+
receipt: receipt)
|
|
568
|
+
terminal_status.to_sym
|
|
569
|
+
rescue Ace::Assign::AttemptErrors::ReceiptRejected
|
|
570
|
+
# The journal refused the receipt: the record stays uncertain with
|
|
571
|
+
# its original reference. Never fabricate, never resubmit.
|
|
572
|
+
:uncertain
|
|
573
|
+
rescue Ace::Assign::AttemptErrors::InvalidState, Ace::Assign::AttemptErrors::NotFound
|
|
574
|
+
# Another lifecycle already settled this record: exactly one
|
|
575
|
+
# terminal settlement exists — never a second one.
|
|
576
|
+
:skipped_terminal
|
|
577
|
+
end
|
|
578
|
+
|
|
579
|
+
def leave_uncertain(request_id, reason, owner: @forge_config.escalation_owner)
|
|
580
|
+
# Intentionally no state change: uncertainty is the canonical
|
|
581
|
+
# unproved-outcome state and reconciliation happens by reference
|
|
582
|
+
# (never a re-dispatch). What is known is recorded on the service
|
|
583
|
+
# log with the actionable owner named.
|
|
584
|
+
warn(JSON.generate({"event" => "ordinary_merge_uncertain",
|
|
585
|
+
"request_id" => request_id, "reason" => reason.to_s[0, 512],
|
|
586
|
+
"owner" => owner}))
|
|
587
|
+
nil
|
|
588
|
+
end
|
|
589
|
+
|
|
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
|
+
def target_changed!(message, request_id)
|
|
600
|
+
raise OrdinaryMergeRefusal.new("ordinary_merge_target_changed", message,
|
|
601
|
+
request_id: request_id)
|
|
602
|
+
end
|
|
603
|
+
|
|
604
|
+
# The forge PR identity from the immutable request's pr_url, validated
|
|
605
|
+
# against the host-installed forge merge configuration: the PR must
|
|
606
|
+
# belong to THIS receiver's configured forge, owner and repository —
|
|
607
|
+
# anything else never targets this receiver (selection mismatch).
|
|
608
|
+
def forge_identity!(document, request_id)
|
|
609
|
+
forge_host = URI.parse(@forge_config.base_url).host.to_s.downcase
|
|
610
|
+
uri = URI.parse(document.fetch("pr_url"))
|
|
611
|
+
match = %r{\A/([^/]+)/([^/]+)/pulls/(\d+)\z}.match(uri.path.to_s)
|
|
612
|
+
unless uri.is_a?(URI::HTTPS) && uri.host.to_s.downcase == forge_host && match
|
|
613
|
+
selection_refusal!("pr_url does not target this receiver's configured forge repository",
|
|
614
|
+
request_id)
|
|
615
|
+
end
|
|
616
|
+
unless match[1] == @forge_config.owner && match[2] == @forge_config.repo
|
|
617
|
+
selection_refusal!("pr_url repository does not match the configured forge repository",
|
|
618
|
+
request_id)
|
|
619
|
+
end
|
|
620
|
+
{"owner" => match[1], "repo" => match[2], "index" => match[3].to_i}
|
|
621
|
+
end
|
|
622
|
+
|
|
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
|
+
def selection_refusal!(message, request_id)
|
|
637
|
+
raise OrdinaryMergeRefusal.new("ordinary_merge_selection_mismatch", message,
|
|
638
|
+
request_id: request_id)
|
|
639
|
+
end
|
|
640
|
+
|
|
641
|
+
def head_label(sha)
|
|
642
|
+
(sha.is_a?(String) && sha.length >= 12) ? sha[0, 12] : sha.to_s
|
|
643
|
+
end
|
|
644
|
+
|
|
645
|
+
def selection_mismatch(message, request_id)
|
|
646
|
+
OrdinaryMergeRequest.refusal_envelope("ordinary_merge_selection_mismatch", message,
|
|
647
|
+
request_id: request_id, input_digest: nil)
|
|
648
|
+
end
|
|
649
|
+
|
|
650
|
+
def invalid!(message, request_id = "unknown")
|
|
651
|
+
raise OrdinaryMergeRefusal.new("ordinary_merge_invalid_request", message,
|
|
652
|
+
request_id: request_id)
|
|
653
|
+
end
|
|
654
|
+
|
|
655
|
+
def unknown(submission)
|
|
656
|
+
(submission.keys - SUBMISSION_KEYS).sort.join(", ")
|
|
657
|
+
end
|
|
658
|
+
|
|
659
|
+
OrdinaryMergeRefusal = OrdinaryMergeRequest::OrdinaryMergeRefusal
|
|
660
|
+
end
|
|
661
|
+
end
|
|
662
|
+
end
|
|
663
|
+
end
|