ace-assign 0.67.0 → 0.68.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 +7 -0
- data/handbook/workflow-instructions/assign/drive.wf.md +39 -0
- data/lib/ace/assign/atoms/fixed_merge_schemas.rb +398 -0
- data/lib/ace/assign/errors.rb +5 -1
- data/lib/ace/assign/models/evidence_event.rb +1 -0
- data/lib/ace/assign/molecules/evidence_journal.rb +281 -10
- data/lib/ace/assign/molecules/fixed_effect_reference.rb +31 -0
- data/lib/ace/assign/molecules/ordinary_merge_cli.rb +68 -0
- data/lib/ace/assign/molecules/ordinary_merge_contract.rb +410 -0
- data/lib/ace/assign/organisms/attempt_coordinator.rb +90 -7
- data/lib/ace/assign/organisms/ordinary_merge_consumer.rb +365 -0
- data/lib/ace/assign/version.rb +1 -1
- data/lib/ace/assign.rb +3 -0
- metadata +7 -2
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require_relative "canonical_attempt_state"
|
|
4
|
+
require_relative "fixed_effect_reference"
|
|
5
|
+
require_relative "../atoms/fixed_merge_schemas"
|
|
4
6
|
|
|
5
7
|
require "digest"
|
|
6
8
|
require "fileutils"
|
|
@@ -32,6 +34,7 @@ module Ace
|
|
|
32
34
|
class EvidenceJournal
|
|
33
35
|
include JournalMutation
|
|
34
36
|
include ProposalJournal
|
|
37
|
+
|
|
35
38
|
CAS_ATTEMPTS = 3
|
|
36
39
|
HISTORY_LIMIT = 100_000
|
|
37
40
|
EVENT_BATCH_BYTES = 1024 * 1024
|
|
@@ -204,8 +207,10 @@ module Ace
|
|
|
204
207
|
def event_commits_for_assignments!(selectors:, commit:)
|
|
205
208
|
unless selectors.is_a?(Hash) && !selectors.empty? && selectors.size <= HISTORY_LIMIT &&
|
|
206
209
|
selectors.keys.all? { |id| id.is_a?(String) && id.match?(JournalMutation::ID) } &&
|
|
207
|
-
selectors.values.all? { |digests|
|
|
208
|
-
digests.
|
|
210
|
+
selectors.values.all? { |digests|
|
|
211
|
+
digests.is_a?(Array) && !digests.empty? &&
|
|
212
|
+
digests.uniq.size == digests.size && digests.all? { |digest| digest.is_a?(String) && digest.match?(/\A[0-9a-f]{64}\z/) }
|
|
213
|
+
} &&
|
|
209
214
|
selectors.values.sum(&:size) <= HISTORY_LIMIT
|
|
210
215
|
raise AttemptErrors::EvidenceUnavailable, "historical event selectors are invalid"
|
|
211
216
|
end
|
|
@@ -400,9 +405,12 @@ module Ace
|
|
|
400
405
|
end
|
|
401
406
|
modes = match.values_at(1, 2)
|
|
402
407
|
unless modes.all? { |mode| %w[000000 100644 100755 120000].include?(mode) } &&
|
|
403
|
-
(status == "A"
|
|
404
|
-
|
|
405
|
-
|
|
408
|
+
(if status == "A"
|
|
409
|
+
old == "0" * 40 && fresh != "0" * 40 && modes.first == "000000" && modes.last != "000000"
|
|
410
|
+
else
|
|
411
|
+
(status == "D") ? old != "0" * 40 && fresh == "0" * 40 && modes.first != "000000" && modes.last == "000000" :
|
|
412
|
+
old != "0" * 40 && fresh != "0" * 40 && modes.none? { |mode| mode == "000000" }
|
|
413
|
+
end)
|
|
406
414
|
raise AttemptErrors::EvidenceUnavailable, "historical event object type differs"
|
|
407
415
|
end
|
|
408
416
|
result.fetch(commit) << [path, status, old, fresh]
|
|
@@ -815,8 +823,10 @@ module Ace
|
|
|
815
823
|
rescue Errno::EACCES, Errno::ELOOP
|
|
816
824
|
false
|
|
817
825
|
end
|
|
818
|
-
|
|
819
|
-
|
|
826
|
+
unless intact
|
|
827
|
+
raise AttemptErrors::ReceiptRejected,
|
|
828
|
+
"Service terminal receipt evidence is unverifiable: #{item["ref"]}"
|
|
829
|
+
end
|
|
820
830
|
verify_service_attestation!(File.read(real), current, state)
|
|
821
831
|
end
|
|
822
832
|
end
|
|
@@ -1224,13 +1234,13 @@ module Ace
|
|
|
1224
1234
|
|
|
1225
1235
|
def raise_authorization_conflict!(conflict)
|
|
1226
1236
|
raise AttemptErrors::Conflict,
|
|
1227
|
-
"Authorization reference already consumed by request #{conflict.fetch(
|
|
1237
|
+
"Authorization reference already consumed by request #{conflict.fetch("request_id")}"
|
|
1228
1238
|
end
|
|
1229
1239
|
|
|
1230
1240
|
def verify_service_attestation!(content, request, state)
|
|
1231
1241
|
expression = /^ace-service-attestation request:#{Regexp.escape(request["request_id"])} input:#{Regexp.escape(request["input_digest"])} outcome:(\S+)( no-effect:(\S+))?$/
|
|
1232
1242
|
attested = content.scan(expression).first
|
|
1233
|
-
outcome = state == "failed-settled" ? "failed" : state
|
|
1243
|
+
outcome = (state == "failed-settled") ? "failed" : state
|
|
1234
1244
|
unless attested && attested.first == outcome && (state != "failed-settled" || attested[2] == "true")
|
|
1235
1245
|
raise AttemptErrors::ReceiptRejected, "Service terminal evidence does not attest #{outcome}"
|
|
1236
1246
|
end
|
|
@@ -1245,6 +1255,267 @@ module Ace
|
|
|
1245
1255
|
raise ArgumentError, "invalid service request ID"
|
|
1246
1256
|
end
|
|
1247
1257
|
|
|
1258
|
+
# ---- ordinary fixed merge records (spec 8x3.t.xz9.4) --------------
|
|
1259
|
+
#
|
|
1260
|
+
# The canonical Assign attempt/evidence state is the SOLE queue/effect
|
|
1261
|
+
# record for ordinary fixed merge requests: admitted records live in
|
|
1262
|
+
# this evidence ref, chained to their owning attempt, and the issuing
|
|
1263
|
+
# receiver keeps no journal, mapping database or receipt ledger. The
|
|
1264
|
+
# closed state model is pending -> uncertain -> succeeded/failed;
|
|
1265
|
+
# pre-effect refusals are never durable (a refusal has no reference),
|
|
1266
|
+
# so every recorded request is an admitted one.
|
|
1267
|
+
|
|
1268
|
+
FIXED_MERGE_STATES = %w[pending uncertain succeeded failed].freeze
|
|
1269
|
+
# Immutable binding: a replay under the same request ID must match
|
|
1270
|
+
# every caller-supplied field, or it refuses before effect. The
|
|
1271
|
+
# opaque reference is receiver-issued, never caller-supplied, and
|
|
1272
|
+
# replays return the originally issued one.
|
|
1273
|
+
FIXED_MERGE_BINDING_FIELDS = %w[request_id project_id assignment_id attempt_id
|
|
1274
|
+
queue_entry operation authorization input_digest request principal receiver_id
|
|
1275
|
+
configuration_sha256 execution_identity].freeze
|
|
1276
|
+
FIXED_MERGE_MUTABLE_FIELDS = %w[state receipt reference recorded_at claimed_at finished_at].freeze
|
|
1277
|
+
|
|
1278
|
+
def fixed_merge_request(request_id, commit: ref_value)
|
|
1279
|
+
return nil unless commit
|
|
1280
|
+
out, stderr, status = git("show", "#{commit}:#{fixed_merge_request_path(request_id)}")
|
|
1281
|
+
if status.success?
|
|
1282
|
+
record = JSON.parse(out)
|
|
1283
|
+
verify_fixed_record!(record, commit: commit) if @mode == :protected
|
|
1284
|
+
return record
|
|
1285
|
+
end
|
|
1286
|
+
return nil if stderr.include?("does not exist") || stderr.include?("exists on disk")
|
|
1287
|
+
raise AttemptErrors::EvidenceUnavailable, "Cannot read fixed merge request: #{stderr}"
|
|
1288
|
+
rescue JSON::ParserError
|
|
1289
|
+
raise AttemptErrors::EvidenceUnavailable, "Corrupt fixed merge request #{request_id}"
|
|
1290
|
+
end
|
|
1291
|
+
|
|
1292
|
+
def fixed_merge_request_records(commit: ref_value)
|
|
1293
|
+
value = commit
|
|
1294
|
+
return [] unless value
|
|
1295
|
+
paths, stderr, status = git("ls-tree", "-r", "--name-only", value, "--", "execution/merge-requests/")
|
|
1296
|
+
raise AttemptErrors::EvidenceUnavailable, "Cannot read fixed merge index: #{stderr}" unless status.success?
|
|
1297
|
+
paths.lines.map(&:strip).select { |path| path.end_with?(".json") }.filter_map do |path|
|
|
1298
|
+
content, error, read_status = git("show", "#{value}:#{path}")
|
|
1299
|
+
unless read_status.success?
|
|
1300
|
+
raise AttemptErrors::EvidenceUnavailable, "Cannot read fixed merge request #{path}: #{error}"
|
|
1301
|
+
end
|
|
1302
|
+
record = JSON.parse(content)
|
|
1303
|
+
verify_fixed_record!(record, commit: value) if @mode == :protected
|
|
1304
|
+
record
|
|
1305
|
+
end
|
|
1306
|
+
rescue JSON::ParserError
|
|
1307
|
+
raise AttemptErrors::EvidenceUnavailable, "Corrupt fixed merge request index"
|
|
1308
|
+
end
|
|
1309
|
+
|
|
1310
|
+
# Admit an ordinary fixed merge request: one CAS create that binds the
|
|
1311
|
+
# receiver-issued opaque reference before the record can ever be read
|
|
1312
|
+
# back. An identical binding under the same request ID replays the
|
|
1313
|
+
# original record (the reference is created once); any different
|
|
1314
|
+
# field is a conflict, never a replay. Another live record holding
|
|
1315
|
+
# the same exact authorization is refused: only a terminal record
|
|
1316
|
+
# with proven no-effect evidence frees an authorization.
|
|
1317
|
+
def admit_fixed_merge_request(record, guard: nil)
|
|
1318
|
+
reference = record.fetch("reference")
|
|
1319
|
+
raise ArgumentError, "fixed merge record needs an opaque reference" unless Molecules::FixedEffectReference.valid?(reference)
|
|
1320
|
+
raise ArgumentError, "invalid fixed merge state" unless record.fetch("state") == "pending"
|
|
1321
|
+
update_fixed_merge_request(record.fetch("request_id"), expected: nil,
|
|
1322
|
+
replacement: record, event_type: "merge_admission", guard: guard)
|
|
1323
|
+
end
|
|
1324
|
+
|
|
1325
|
+
# Claim the start of the fixed effect: pending -> uncertain in one
|
|
1326
|
+
# CAS transition, so a receiver restart can never dispatch a second
|
|
1327
|
+
# merge for the same admitted record — begin replays as already
|
|
1328
|
+
# started, and only the owning effect may later settle it.
|
|
1329
|
+
def begin_fixed_merge_request(request_id)
|
|
1330
|
+
current = fixed_merge_request(request_id)
|
|
1331
|
+
raise AttemptErrors::NotFound, "Fixed merge request #{request_id} not found" unless current
|
|
1332
|
+
return current if current["state"] == "uncertain"
|
|
1333
|
+
unless current["state"] == "pending"
|
|
1334
|
+
raise AttemptErrors::InvalidState, "Fixed merge request #{request_id} is terminal"
|
|
1335
|
+
end
|
|
1336
|
+
update_fixed_merge_request(request_id, expected: current,
|
|
1337
|
+
replacement: current.merge("state" => "uncertain", "claimed_at" => Time.now.utc.iso8601(9)),
|
|
1338
|
+
event_type: "merge_transition")
|
|
1339
|
+
end
|
|
1340
|
+
|
|
1341
|
+
# Terminal settlement: succeeded/failed with the exact receipt
|
|
1342
|
+
# validated HERE at the journal boundary — structure, the closed
|
|
1343
|
+
# terminal invariants, and the binding against the current record.
|
|
1344
|
+
# Failure is terminal only with immutable no-effect evidence.
|
|
1345
|
+
def settle_fixed_merge_request(request_id, terminal_status:, receipt:)
|
|
1346
|
+
unless FIXED_MERGE_STATES.include?(terminal_status) && %w[succeeded failed].include?(terminal_status)
|
|
1347
|
+
raise ArgumentError, "invalid fixed merge terminal status"
|
|
1348
|
+
end
|
|
1349
|
+
current = fixed_merge_request(request_id)
|
|
1350
|
+
raise AttemptErrors::NotFound, "Fixed merge request #{request_id} not found" unless current
|
|
1351
|
+
unless %w[pending uncertain].include?(current["state"])
|
|
1352
|
+
raise AttemptErrors::InvalidState, "Fixed merge request #{request_id} is terminal"
|
|
1353
|
+
end
|
|
1354
|
+
validate_fixed_receipt!(current, terminal_status, receipt)
|
|
1355
|
+
update_fixed_merge_request(request_id, expected: current,
|
|
1356
|
+
replacement: current.merge("state" => terminal_status, "receipt" => receipt,
|
|
1357
|
+
"finished_at" => Time.now.utc.iso8601(9)),
|
|
1358
|
+
event_type: "merge_transition")
|
|
1359
|
+
end
|
|
1360
|
+
|
|
1361
|
+
def fixed_merge_request_path(request_id)
|
|
1362
|
+
"execution/merge-requests/#{request_id}.json"
|
|
1363
|
+
end
|
|
1364
|
+
|
|
1365
|
+
def validate_fixed_receipt!(current, terminal_status, receipt)
|
|
1366
|
+
Atoms::FixedMergeSchemas.validate_receipt!(receipt)
|
|
1367
|
+
Atoms::FixedMergeSchemas.validate_receipt_against!(receipt, current)
|
|
1368
|
+
unless receipt.fetch("terminal_status") == terminal_status
|
|
1369
|
+
raise AttemptErrors::ReceiptRejected, "Fixed merge receipt terminal status does not match the settlement"
|
|
1370
|
+
end
|
|
1371
|
+
true
|
|
1372
|
+
rescue Atoms::FixedMergeSchemas::SchemaError => e
|
|
1373
|
+
raise AttemptErrors::ReceiptRejected, "Fixed merge receipt is invalid: #{e.message}"
|
|
1374
|
+
end
|
|
1375
|
+
|
|
1376
|
+
# Another live fixed merge record holding the same exact
|
|
1377
|
+
# authorization. Terminal failure with intact no-effect evidence
|
|
1378
|
+
# proves the effect never happened and frees the reference.
|
|
1379
|
+
def fixed_authorization_conflict(record)
|
|
1380
|
+
authorization = record["authorization"]
|
|
1381
|
+
return nil unless authorization.is_a?(String) && !authorization.empty?
|
|
1382
|
+
fixed_merge_request_records.find do |other|
|
|
1383
|
+
next false if other["request_id"] == record.fetch("request_id")
|
|
1384
|
+
next false if other["state"] == "failed" && fixed_no_effect_intact?(other)
|
|
1385
|
+
other["authorization"] == authorization &&
|
|
1386
|
+
other["operation"] == record.fetch("operation") &&
|
|
1387
|
+
other["project_id"] == record.fetch("project_id")
|
|
1388
|
+
end
|
|
1389
|
+
end
|
|
1390
|
+
|
|
1391
|
+
def fixed_no_effect_intact?(record)
|
|
1392
|
+
receipt = record["receipt"]
|
|
1393
|
+
return false unless receipt.is_a?(Hash)
|
|
1394
|
+
evidence = receipt.dig("effect", "no_effect_evidence")
|
|
1395
|
+
return false unless evidence.is_a?(Hash)
|
|
1396
|
+
repo_root = File.realpath(@repo_root)
|
|
1397
|
+
path = File.expand_path(evidence["reference"].to_s, repo_root)
|
|
1398
|
+
real = begin
|
|
1399
|
+
File.realpath(path)
|
|
1400
|
+
rescue Errno::ENOENT, Errno::EACCES, Errno::ELOOP
|
|
1401
|
+
return false
|
|
1402
|
+
end
|
|
1403
|
+
real.start_with?(repo_root + File::SEPARATOR) &&
|
|
1404
|
+
Digest::SHA256.file(real).hexdigest == evidence.fetch("sha256")
|
|
1405
|
+
rescue Errno::ENOENT, AttemptErrors::EvidenceUnavailable
|
|
1406
|
+
false
|
|
1407
|
+
end
|
|
1408
|
+
|
|
1409
|
+
def update_fixed_merge_request(request_id, expected:, replacement:, event_type:, guard: nil)
|
|
1410
|
+
unless %w[merge_admission merge_transition].include?(event_type)
|
|
1411
|
+
raise ArgumentError, "invalid fixed merge event type"
|
|
1412
|
+
end
|
|
1413
|
+
with_lock do
|
|
1414
|
+
CAS_ATTEMPTS.times do
|
|
1415
|
+
old = ref_value
|
|
1416
|
+
ensure_checkout!
|
|
1417
|
+
old = ref_value if old.nil?
|
|
1418
|
+
sync_checkout(old)
|
|
1419
|
+
prepared = prepare_fixed_update(request_id: request_id, expected: expected,
|
|
1420
|
+
replacement: replacement, event_type: event_type, guard: guard)
|
|
1421
|
+
return prepared.fetch(:record) if prepared[:replayed]
|
|
1422
|
+
path = fixed_merge_request_path(request_id)
|
|
1423
|
+
full = File.join(checkout_dir, path)
|
|
1424
|
+
FileUtils.mkdir_p(File.dirname(full))
|
|
1425
|
+
File.write(full, JSON.pretty_generate(prepared.fetch(:record)))
|
|
1426
|
+
assignment_id = replacement.fetch("assignment_id")
|
|
1427
|
+
attempt_id = replacement.fetch("attempt_id")
|
|
1428
|
+
prior = read_events(assignment_id).reverse
|
|
1429
|
+
.find { |entry| entry["attempt_id"] == attempt_id }&.fetch("digest")
|
|
1430
|
+
event = Models::EvidenceEvent.build(type: event_type, attempt_id: attempt_id,
|
|
1431
|
+
payload: prepared.fetch(:event).fetch(:payload), previous_digest: prior)
|
|
1432
|
+
write_event_files(assignment_id, [event])
|
|
1433
|
+
git!("-C", checkout_dir, "add", "--", path, "execution/#{assignment_id}/events")
|
|
1434
|
+
state = replacement.fetch("state")
|
|
1435
|
+
git!("-C", checkout_dir, "-c", "user.name=ace-assign", "-c", "user.email=ace-assign@localhost",
|
|
1436
|
+
"commit", "-m", "evidence: fixed merge request #{request_id} #{state}")
|
|
1437
|
+
commit = git!("-C", checkout_dir, "rev-parse", "HEAD").first
|
|
1438
|
+
return replacement.merge("journal_commit" => commit) if update_ref_cas(commit, old)
|
|
1439
|
+
end
|
|
1440
|
+
raise AttemptErrors::EvidenceUnavailable, "Fixed merge evidence ref stayed conflicting"
|
|
1441
|
+
end
|
|
1442
|
+
end
|
|
1443
|
+
private :update_fixed_merge_request
|
|
1444
|
+
|
|
1445
|
+
def prepare_fixed_update(request_id:, expected:, replacement:, event_type:, guard: nil)
|
|
1446
|
+
validate_request_id!(request_id)
|
|
1447
|
+
unless replacement.is_a?(Hash) && replacement["request_id"] == request_id &&
|
|
1448
|
+
FIXED_MERGE_STATES.include?(replacement["state"]) &&
|
|
1449
|
+
(replacement.keys - (FIXED_MERGE_BINDING_FIELDS + FIXED_MERGE_MUTABLE_FIELDS + %w[journal_commit])).empty?
|
|
1450
|
+
raise ArgumentError, "invalid fixed merge update plan"
|
|
1451
|
+
end
|
|
1452
|
+
existing = fixed_merge_request(request_id)
|
|
1453
|
+
if expected.nil?
|
|
1454
|
+
unless event_type == "merge_admission" && replacement["state"] == "pending"
|
|
1455
|
+
raise AttemptErrors::InvalidState, "Fixed merge request needs an initial admission"
|
|
1456
|
+
end
|
|
1457
|
+
if existing
|
|
1458
|
+
return {record: existing, replayed: true} if fixed_replay?(existing, replacement)
|
|
1459
|
+
raise AttemptErrors::Conflict, "Fixed merge request #{request_id} has different input"
|
|
1460
|
+
end
|
|
1461
|
+
unless Molecules::FixedEffectReference.valid?(replacement.fetch("reference"))
|
|
1462
|
+
raise ArgumentError, "fixed merge record needs an opaque reference"
|
|
1463
|
+
end
|
|
1464
|
+
if fixed_merge_request_records.any? { |other| other["reference"] == replacement.fetch("reference") }
|
|
1465
|
+
raise AttemptErrors::Conflict, "Fixed merge reference is already bound"
|
|
1466
|
+
end
|
|
1467
|
+
else
|
|
1468
|
+
unless event_type == "merge_transition"
|
|
1469
|
+
raise AttemptErrors::InvalidState, "Admitted fixed merge request needs a transition"
|
|
1470
|
+
end
|
|
1471
|
+
unless existing == expected
|
|
1472
|
+
raise AttemptErrors::Conflict, "Fixed merge request #{request_id} changed during transition"
|
|
1473
|
+
end
|
|
1474
|
+
unless existing.except(*FIXED_MERGE_MUTABLE_FIELDS) == replacement.except(*FIXED_MERGE_MUTABLE_FIELDS)
|
|
1475
|
+
raise AttemptErrors::Conflict, "Fixed merge request #{request_id} changed immutable binding"
|
|
1476
|
+
end
|
|
1477
|
+
end
|
|
1478
|
+
conflict = guard&.call
|
|
1479
|
+
raise_authorization_conflict!(conflict) if conflict
|
|
1480
|
+
payload = {"request_id" => request_id, "state" => replacement.fetch("state"),
|
|
1481
|
+
"input_digest" => replacement.fetch("input_digest"),
|
|
1482
|
+
"record_digest" => Atoms::EvidenceDigest.digest(replacement),
|
|
1483
|
+
"receipt_digest" => replacement["receipt"] && Atoms::EvidenceDigest.digest(replacement["receipt"])}
|
|
1484
|
+
{record: replacement, event: {type: event_type, payload: payload}, replayed: false}
|
|
1485
|
+
end
|
|
1486
|
+
private :prepare_fixed_update
|
|
1487
|
+
|
|
1488
|
+
def fixed_replay?(existing, replacement)
|
|
1489
|
+
FIXED_MERGE_BINDING_FIELDS.all? do |field|
|
|
1490
|
+
existing[field] == replacement.fetch(field)
|
|
1491
|
+
end
|
|
1492
|
+
end
|
|
1493
|
+
private :fixed_replay?
|
|
1494
|
+
|
|
1495
|
+
# Protected-mode parity with service records: the record must match
|
|
1496
|
+
# the latest merge event digest in the owning attempt chain.
|
|
1497
|
+
def verify_fixed_record!(record, commit: ref_value)
|
|
1498
|
+
events = read_events(record.fetch("assignment_id"), commit: commit)
|
|
1499
|
+
.select { |event| event["attempt_id"] == record.fetch("attempt_id") }
|
|
1500
|
+
unless Models::EvidenceEvent.chain_valid?(events)
|
|
1501
|
+
raise AttemptErrors::EvidenceUnavailable, "Canonical fixed merge event chain is unverifiable"
|
|
1502
|
+
end
|
|
1503
|
+
event = events.reverse.find do |entry|
|
|
1504
|
+
%w[merge_admission merge_transition].include?(entry["type"]) &&
|
|
1505
|
+
entry.dig("payload", "request_id") == record["request_id"]
|
|
1506
|
+
end
|
|
1507
|
+
unless event && event.dig("payload", "record_digest") == Atoms::EvidenceDigest.digest(record)
|
|
1508
|
+
raise AttemptErrors::EvidenceUnavailable, "Canonical fixed merge record does not match accepted event"
|
|
1509
|
+
end
|
|
1510
|
+
rescue KeyError, TypeError
|
|
1511
|
+
raise AttemptErrors::EvidenceUnavailable, "Canonical fixed merge record has invalid binding"
|
|
1512
|
+
end
|
|
1513
|
+
|
|
1514
|
+
# The module marks a private region above; the fixed merge primitive
|
|
1515
|
+
# is a public coordinator-facing surface, so it is re-published here.
|
|
1516
|
+
public :fixed_merge_request, :fixed_merge_request_records, :admit_fixed_merge_request,
|
|
1517
|
+
:begin_fixed_merge_request, :settle_fixed_merge_request, :fixed_authorization_conflict
|
|
1518
|
+
|
|
1248
1519
|
# Latest lifecycle state implied by the events, or nil when the
|
|
1249
1520
|
# events do not describe a full attempt (intent missing).
|
|
1250
1521
|
def derive_state(events)
|
|
@@ -1399,7 +1670,7 @@ module Ace
|
|
|
1399
1670
|
lock.flock(File::LOCK_EX)
|
|
1400
1671
|
begin
|
|
1401
1672
|
yield
|
|
1402
|
-
rescue
|
|
1673
|
+
rescue
|
|
1403
1674
|
# Ordinary mutation rollback is unchanged. Initialization must
|
|
1404
1675
|
# not adopt/reset a foreign disposable checkout on refusal.
|
|
1405
1676
|
sync_checkout(ref_value) if cleanup_on_failure && File.exist?(File.join(checkout_dir, ".git"))
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "base64"
|
|
4
|
+
require "securerandom"
|
|
5
|
+
|
|
6
|
+
module Ace
|
|
7
|
+
module Assign
|
|
8
|
+
module Molecules
|
|
9
|
+
# The shared opaque-reference primitive for fixed-effect records
|
|
10
|
+
# (spec 8x3.t.xz9.4). The reference is receiver-issued at admission and
|
|
11
|
+
# names the canonical Assign record; clients may persist and echo it
|
|
12
|
+
# but must never parse it or derive paths from it. Creation is random
|
|
13
|
+
# (not derived from binding values), so a reference cannot be guessed
|
|
14
|
+
# from public request fields, and resolution always goes through the
|
|
15
|
+
# canonical record — there is no receiver-local mapping.
|
|
16
|
+
class FixedEffectReference
|
|
17
|
+
PREFIX = "fmr1"
|
|
18
|
+
PATTERN = /\Afmr1-[A-Za-z0-9_-]{40,86}\z/
|
|
19
|
+
MAX_LENGTH = 256
|
|
20
|
+
|
|
21
|
+
def self.create
|
|
22
|
+
"#{PREFIX}-#{Base64.urlsafe_encode64(SecureRandom.random_bytes(32), padding: false)}"
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def self.valid?(value)
|
|
26
|
+
value.is_a?(String) && value.length.between?(1, MAX_LENGTH) && value.match?(PATTERN)
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "open3"
|
|
5
|
+
require_relative "../errors"
|
|
6
|
+
|
|
7
|
+
module Ace
|
|
8
|
+
module Assign
|
|
9
|
+
module Molecules
|
|
10
|
+
# Invokes the maintained ordinary fixed merge producer through the
|
|
11
|
+
# installed `ace-lab service request/status --mode ordinary-merge`
|
|
12
|
+
# CLI. The argv is closed: no shell, no caller-selected executable,
|
|
13
|
+
# socket, evidence path or token selector beyond the published request
|
|
14
|
+
# form. Output is bounded and parsed as one strict JSON envelope.
|
|
15
|
+
#
|
|
16
|
+
# Any local execution, framing or parse failure raises
|
|
17
|
+
# UnavailableError: a caller-side blocker that never proves refusal
|
|
18
|
+
# and never authorizes resubmission.
|
|
19
|
+
class OrdinaryMergeCli
|
|
20
|
+
MAX_OUTPUT_BYTES = 16 * 1024
|
|
21
|
+
|
|
22
|
+
# Raised when no maintained producer response is obtainable before
|
|
23
|
+
# the envelope could be observed. Maps to the closed
|
|
24
|
+
# `ordinary_merge_unavailable` caller-side blocker.
|
|
25
|
+
class UnavailableError < Ace::Assign::AttemptErrors::EvidenceUnavailable
|
|
26
|
+
def initialize(message = "ordinary merge producer response unavailable")
|
|
27
|
+
super
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def initialize(binary: "ace-lab")
|
|
32
|
+
@binary = binary
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def request(project:, assignment:, attempt:, queue_entry:, input_path:, authorization:, request_id:)
|
|
36
|
+
run(["service", "request", "--mode", "ordinary-merge",
|
|
37
|
+
"--project", project, "--assignment", assignment, "--attempt", attempt,
|
|
38
|
+
"--queue-entry", queue_entry, "--operation", "merge", "--input", input_path,
|
|
39
|
+
"--authorization", authorization, "--request-id", request_id])
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def status(reference:, request_id:, project:, assignment:, attempt:, queue_entry:, input_digest:)
|
|
43
|
+
run(["service", "status", "--mode", "ordinary-merge", "--reference", reference,
|
|
44
|
+
"--request", request_id, "--project", project, "--assignment", assignment,
|
|
45
|
+
"--attempt", attempt, "--queue-entry", queue_entry,
|
|
46
|
+
"--input-digest", input_digest, "--format", "json"])
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
private
|
|
50
|
+
|
|
51
|
+
def run(arguments)
|
|
52
|
+
output, = Open3.capture3(@binary, *arguments)
|
|
53
|
+
raise UnavailableError, "ordinary merge producer output is missing or exceeds its bound" unless
|
|
54
|
+
output.is_a?(String) && !output.empty? && output.bytesize <= MAX_OUTPUT_BYTES
|
|
55
|
+
envelope = JSON.parse(output, max_nesting: 16, create_additions: false,
|
|
56
|
+
allow_duplicate_key: false, allow_comments: false, allow_nan: false)
|
|
57
|
+
unless envelope.is_a?(Hash) && envelope["status"].is_a?(String) &&
|
|
58
|
+
(envelope.keys - %w[status data error]).empty?
|
|
59
|
+
raise UnavailableError, "ordinary merge producer output is not a maintained envelope"
|
|
60
|
+
end
|
|
61
|
+
envelope
|
|
62
|
+
rescue JSON::ParserError, SystemCallError
|
|
63
|
+
raise UnavailableError, "ordinary merge producer response unavailable"
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|