ace-assign 0.69.0 → 0.71.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/.ace-defaults/assign/presets/deliver-release.yml +11 -8
- data/.ace-defaults/assign/presets/integrate-batch.yml +10 -5
- data/.ace-defaults/assign/presets/supervise-delivery.yml +6 -4
- data/CHANGELOG.md +27 -0
- data/handbook/skills/as-assign-drive/SKILL.md +5 -2
- data/handbook/workflow-instructions/assign/drive.wf.md +77 -0
- data/lib/ace/assign/atoms/assignment_scope.rb +5 -0
- data/lib/ace/assign/atoms/candidate_limits.rb +20 -0
- data/lib/ace/assign/atoms/git_blob_batch.rb +59 -0
- data/lib/ace/assign/authority/assignment_definition.rb +35 -0
- data/lib/ace/assign/authority/candidate_blob_batch.rb +55 -0
- data/lib/ace/assign/authority/candidate_head_comparison.rb +139 -0
- data/lib/ace/assign/authority/candidate_transfer.rb +57 -27
- data/lib/ace/assign/authority/command_execution.rb +180 -0
- data/lib/ace/assign/authority/endcap.rb +4 -2
- data/lib/ace/assign/authority/endcap_attempts.rb +1 -1
- data/lib/ace/assign/authority/endcap_campaign_rounds.rb +1 -1
- data/lib/ace/assign/authority/endcap_publication.rb +0 -166
- data/lib/ace/assign/authority/endcap_service_context.rb +38 -0
- data/lib/ace/assign/authority/fixed_publication_lifecycle.rb +176 -0
- data/lib/ace/assign/authority/{endcap_services.rb → fixed_service_lifecycle.rb} +42 -46
- data/lib/ace/assign/authority/launch_control_exclusions.rb +3 -17
- data/lib/ace/assign/authority/ordinary_attempt_start.rb +137 -0
- data/lib/ace/assign/authority/ordinary_client.rb +70 -0
- data/lib/ace/assign/authority/ordinary_completion.rb +104 -0
- data/lib/ace/assign/authority/ordinary_effect_settlement.rb +118 -0
- data/lib/ace/assign/authority/ordinary_lifecycle.rb +227 -0
- data/lib/ace/assign/authority/ordinary_merge_evidence.rb +81 -0
- data/lib/ace/assign/authority/ordinary_peer.rb +54 -0
- data/lib/ace/assign/authority/ordinary_progress.rb +162 -0
- data/lib/ace/assign/authority/ordinary_queue.rb +115 -0
- data/lib/ace/assign/authority/ordinary_release_artifacts.rb +143 -0
- data/lib/ace/assign/authority/ordinary_results.rb +206 -0
- data/lib/ace/assign/authority/ordinary_review_evidence.rb +180 -0
- data/lib/ace/assign/authority/ordinary_reviews.rb +214 -0
- data/lib/ace/assign/authority/ordinary_server.rb +75 -0
- data/lib/ace/assign/authority/ordinary_supervisor.rb +99 -0
- data/lib/ace/assign/authority/prepared_work.rb +9 -4
- data/lib/ace/assign/authority/protected_assignment_context.rb +3 -3
- data/lib/ace/assign/authority/release_artifact_manifest.rb +91 -0
- data/lib/ace/assign/authority/retained_input.rb +53 -0
- data/lib/ace/assign/authority/router.rb +11 -6
- data/lib/ace/assign/authority/server.rb +11 -180
- data/lib/ace/assign/authority/socket_server.rb +255 -0
- data/lib/ace/assign/authority/transfer_codec.rb +5 -3
- data/lib/ace/assign/cli/commands/attempt/evidence.rb +2 -2
- data/lib/ace/assign/cli/commands/submit_candidate.rb +1 -1
- data/lib/ace/assign/molecules/canonical_evidence.rb +25 -8
- data/lib/ace/assign/molecules/canonical_read_commands.rb +84 -0
- data/lib/ace/assign/molecules/canonical_read_snapshot.rb +2 -63
- data/lib/ace/assign/molecules/evidence_journal.rb +157 -63
- data/lib/ace/assign/molecules/installed_journal_read.rb +143 -0
- data/lib/ace/assign/molecules/journal_mutation.rb +7 -4
- data/lib/ace/assign/molecules/lifecycle_exclusion.rb +13 -2
- data/lib/ace/assign/molecules/proposal_journal.rb +32 -4
- data/lib/ace/assign/molecules/receipt_verifier.rb +8 -2
- data/lib/ace/assign/molecules/runtime_control_surface_runner.rb +2 -1
- data/lib/ace/assign/organisms/attempt_coordinator.rb +88 -36
- data/lib/ace/assign/organisms/ordinary_driver.rb +310 -0
- data/lib/ace/assign/organisms/ordinary_merge_consumer.rb +5 -5
- data/lib/ace/assign/version.rb +1 -1
- metadata +39 -10
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ace
|
|
4
|
+
module Assign
|
|
5
|
+
module Molecules
|
|
6
|
+
# Closed bounded read dialect shared by isolated protected snapshots and
|
|
7
|
+
# trusted installed journals. Neither boundary can dispatch a Git write.
|
|
8
|
+
module CanonicalReadCommands
|
|
9
|
+
BYTE_LIMIT = 256 * 1_048_576
|
|
10
|
+
OID = /\A[0-9a-f]{40}\z/
|
|
11
|
+
|
|
12
|
+
attr_reader :commit
|
|
13
|
+
|
|
14
|
+
# EvidenceJournal uses this only as its existing read command boundary.
|
|
15
|
+
def call(argv, stdin_data: "", output_limit: BYTE_LIMIT)
|
|
16
|
+
refuse!("read_outside_snapshot") unless @view && @commit
|
|
17
|
+
unless read_command?(argv, stdin_data)
|
|
18
|
+
refuse!("write_or_unsupported_command")
|
|
19
|
+
end
|
|
20
|
+
result = read_result(argv, stdin_data: stdin_data, limit: [output_limit, BYTE_LIMIT].min)
|
|
21
|
+
[result.stdout.to_s.strip, result.stderr.to_s.strip, result.status]
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def blob_batch(entries, output_limit:)
|
|
25
|
+
refuse!("read_outside_snapshot") unless @view && @commit
|
|
26
|
+
unless entries.is_a?(Array) && entries.all? { |oid| oid.is_a?(String) && oid.match?(OID) }
|
|
27
|
+
refuse!("unsupported_command")
|
|
28
|
+
end
|
|
29
|
+
result = run!(["cat-file", "--batch"], stdin_data: entries.join("\n") + "\n",
|
|
30
|
+
limit: [output_limit, BYTE_LIMIT].min)
|
|
31
|
+
result.stdout.to_s.b
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def history_diff(commits, paths, output_limit:)
|
|
35
|
+
refuse!("read_outside_snapshot") unless @view && @commit
|
|
36
|
+
unless commits.is_a?(Array) && commits.size.between?(1, 64) && commits.uniq.size == commits.size &&
|
|
37
|
+
commits.all? { |commit| commit.is_a?(String) && commit.match?(OID) } &&
|
|
38
|
+
paths.is_a?(Array) && !paths.empty? && paths.uniq.size == paths.size &&
|
|
39
|
+
(paths == ["execution/"] || paths.all? { |path| path.is_a?(String) && path.match?(%r{\Aexecution/[a-zA-Z0-9_.:-]+/events/\z}) }) &&
|
|
40
|
+
output_limit.is_a?(Integer) && output_limit.positive?
|
|
41
|
+
refuse!("unsupported_command")
|
|
42
|
+
end
|
|
43
|
+
result = run!(%w[diff-tree --stdin --always --root -r --raw -z --no-renames --no-ext-diff
|
|
44
|
+
--no-textconv --no-abbrev --] + paths, stdin_data: commits.join("\n") + "\n",
|
|
45
|
+
limit: [output_limit, BYTE_LIMIT].min)
|
|
46
|
+
result.stdout.to_s.b
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def blob_sizes(oids)
|
|
50
|
+
refuse!("read_outside_snapshot") unless @view && @commit
|
|
51
|
+
unless oids.is_a?(Array) && oids.size.between?(1, 64) && oids.all? { |oid| oid.is_a?(String) && oid.match?(OID) }
|
|
52
|
+
refuse!("unsupported_command")
|
|
53
|
+
end
|
|
54
|
+
run!(%w[cat-file --batch-check], stdin_data: oids.join("\n") + "\n", limit: oids.size * 128).stdout.to_s.b
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def blob(path, commit:, max_bytes:)
|
|
58
|
+
refuse!("read_outside_snapshot") unless @view && @commit
|
|
59
|
+
unless commit.is_a?(String) && commit.match?(OID) && max_bytes.is_a?(Integer) && max_bytes.positive?
|
|
60
|
+
refuse!("unsupported_command")
|
|
61
|
+
end
|
|
62
|
+
result = run!(["cat-file", "blob", "#{commit}:#{path}"], limit: [max_bytes, BYTE_LIMIT].min)
|
|
63
|
+
result.stdout.to_s.b
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
private
|
|
67
|
+
|
|
68
|
+
def read_result(argv, **options)
|
|
69
|
+
run!(argv, **options)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def read_command?(argv, stdin)
|
|
73
|
+
return false unless argv.is_a?(Array) && argv.all? { |item| item.is_a?(String) && !item.include?("\0") } && stdin.empty?
|
|
74
|
+
allowed = {"rev-parse" => %w[--verify --quiet --git-dir], "cat-file" => %w[-t -s blob],
|
|
75
|
+
"rev-list" => %w[--first-parent --parents], "ls-tree" => %w[-r -l -d --name-only --],
|
|
76
|
+
"show" => [], "log" => %w[--diff-filter=A -1 --format=%H --]}
|
|
77
|
+
flags = allowed[argv.first]
|
|
78
|
+
flags && argv.drop(1).all? { |item| !item.start_with?("-") || flags.include?(item) || item == "--max-count=100001" }
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
@@ -5,6 +5,7 @@ require "fileutils"
|
|
|
5
5
|
require "tmpdir"
|
|
6
6
|
require "ace/herdr/molecules/bounded_process"
|
|
7
7
|
require_relative "evidence_journal"
|
|
8
|
+
require_relative "canonical_read_commands"
|
|
8
9
|
require_relative "../authority/private_directory"
|
|
9
10
|
|
|
10
11
|
module Ace
|
|
@@ -13,6 +14,7 @@ module Ace
|
|
|
13
14
|
# Isolates read-only Git from an installed authority's live configuration.
|
|
14
15
|
# The copied view never publishes evidence or mutates the source journal.
|
|
15
16
|
class CanonicalReadSnapshot
|
|
17
|
+
include CanonicalReadCommands
|
|
16
18
|
class Unavailable < AttemptErrors::EvidenceUnavailable; end
|
|
17
19
|
BYTE_LIMIT = 256 * 1_048_576
|
|
18
20
|
ENTRY_LIMIT = 4096
|
|
@@ -94,60 +96,6 @@ module Ace
|
|
|
94
96
|
end
|
|
95
97
|
private :with_snapshot
|
|
96
98
|
|
|
97
|
-
attr_reader :commit
|
|
98
|
-
|
|
99
|
-
# EvidenceJournal uses this only as its existing read command boundary.
|
|
100
|
-
def call(argv, stdin_data: "", output_limit: BYTE_LIMIT)
|
|
101
|
-
refuse!("read_outside_snapshot") unless @view && @commit
|
|
102
|
-
unless read_command?(argv, stdin_data)
|
|
103
|
-
refuse!("write_or_unsupported_command")
|
|
104
|
-
end
|
|
105
|
-
result = run!(argv, stdin_data: stdin_data, limit: [output_limit, BYTE_LIMIT].min)
|
|
106
|
-
[result.stdout.to_s.strip, result.stderr.to_s.strip, result.status]
|
|
107
|
-
end
|
|
108
|
-
|
|
109
|
-
def blob_batch(entries, output_limit:)
|
|
110
|
-
refuse!("read_outside_snapshot") unless @view && @commit
|
|
111
|
-
unless entries.is_a?(Array) && entries.all? { |oid| oid.is_a?(String) && oid.match?(OID) }
|
|
112
|
-
refuse!("unsupported_command")
|
|
113
|
-
end
|
|
114
|
-
result = run!(["cat-file", "--batch"], stdin_data: entries.join("\n") + "\n",
|
|
115
|
-
limit: [output_limit, BYTE_LIMIT].min)
|
|
116
|
-
result.stdout.to_s.b
|
|
117
|
-
end
|
|
118
|
-
|
|
119
|
-
def history_diff(commits, paths, output_limit:)
|
|
120
|
-
refuse!("read_outside_snapshot") unless @view && @commit
|
|
121
|
-
unless commits.is_a?(Array) && commits.size.between?(1, 64) && commits.uniq.size == commits.size &&
|
|
122
|
-
commits.all? { |commit| commit.is_a?(String) && commit.match?(OID) } &&
|
|
123
|
-
paths.is_a?(Array) && !paths.empty? && paths.uniq.size == paths.size &&
|
|
124
|
-
(paths == ["execution/"] || paths.all? { |path| path.is_a?(String) && path.match?(%r{\Aexecution/[a-zA-Z0-9_.:-]+/events/\z}) }) &&
|
|
125
|
-
output_limit.is_a?(Integer) && output_limit.positive?
|
|
126
|
-
refuse!("unsupported_command")
|
|
127
|
-
end
|
|
128
|
-
result = run!(%w[diff-tree --stdin --always --root -r --raw -z --no-renames --no-ext-diff
|
|
129
|
-
--no-textconv --no-abbrev --] + paths, stdin_data: commits.join("\n") + "\n",
|
|
130
|
-
limit: [output_limit, BYTE_LIMIT].min)
|
|
131
|
-
result.stdout.to_s.b
|
|
132
|
-
end
|
|
133
|
-
|
|
134
|
-
def blob_sizes(oids)
|
|
135
|
-
refuse!("read_outside_snapshot") unless @view && @commit
|
|
136
|
-
unless oids.is_a?(Array) && oids.size.between?(1, 64) && oids.all? { |oid| oid.is_a?(String) && oid.match?(OID) }
|
|
137
|
-
refuse!("unsupported_command")
|
|
138
|
-
end
|
|
139
|
-
run!(%w[cat-file --batch-check], stdin_data: oids.join("\n") + "\n", limit: oids.size * 128).stdout.to_s.b
|
|
140
|
-
end
|
|
141
|
-
|
|
142
|
-
def blob(path, commit:, max_bytes:)
|
|
143
|
-
refuse!("read_outside_snapshot") unless @view && @commit
|
|
144
|
-
unless commit.is_a?(String) && commit.match?(OID) && max_bytes.is_a?(Integer) && max_bytes.positive?
|
|
145
|
-
refuse!("unsupported_command")
|
|
146
|
-
end
|
|
147
|
-
result = run!(["cat-file", "blob", "#{commit}:#{path}"], limit: [max_bytes, BYTE_LIMIT].min)
|
|
148
|
-
result.stdout.to_s.b
|
|
149
|
-
end
|
|
150
|
-
|
|
151
99
|
private
|
|
152
100
|
|
|
153
101
|
def refuse!(reason)
|
|
@@ -352,15 +300,6 @@ module Ace
|
|
|
352
300
|
File.binwrite(ref, "#{@commit}\n")
|
|
353
301
|
end
|
|
354
302
|
|
|
355
|
-
def read_command?(argv, stdin)
|
|
356
|
-
return false unless argv.is_a?(Array) && argv.all? { |item| item.is_a?(String) && !item.include?("\0") } && stdin.empty?
|
|
357
|
-
allowed = {"rev-parse" => %w[--verify --quiet --git-dir], "cat-file" => %w[-t -s blob],
|
|
358
|
-
"rev-list" => %w[--first-parent --parents], "ls-tree" => %w[-r -l -d --name-only --],
|
|
359
|
-
"show" => [], "log" => %w[--diff-filter=A -1 --format=%H --]}
|
|
360
|
-
flags = allowed[argv.first]
|
|
361
|
-
flags && argv.drop(1).all? { |item| !item.start_with?("-") || flags.include?(item) || item == "--max-count=100001" }
|
|
362
|
-
end
|
|
363
|
-
|
|
364
303
|
def run!(argv, stdin_data: "", limit:)
|
|
365
304
|
result = Herdr::Molecules::BoundedProcess.call([@git, "--git-dir=#{@view}", "--no-pager", *argv],
|
|
366
305
|
environment: ENVIRONMENT, chdir: @view, stdin_data: stdin_data,
|
|
@@ -7,6 +7,7 @@ require_relative "../atoms/fixed_merge_schemas"
|
|
|
7
7
|
require "digest"
|
|
8
8
|
require "fileutils"
|
|
9
9
|
require "json"
|
|
10
|
+
require_relative "../atoms/git_blob_batch"
|
|
10
11
|
require "open3"
|
|
11
12
|
require "pathname"
|
|
12
13
|
require "ace/herdr/molecules/bounded_process"
|
|
@@ -62,6 +63,7 @@ module Ace
|
|
|
62
63
|
def initialize(repo_root:, ref: nil, checkout_root: nil, mode: :local,
|
|
63
64
|
evidence_reader: nil, service_authorizer: nil, read_boundary: nil)
|
|
64
65
|
unless %i[local protected].include?(mode) &&
|
|
66
|
+
(evidence_reader.nil? || evidence_reader.respond_to?(:call)) &&
|
|
65
67
|
(mode != :protected || [evidence_reader, service_authorizer].all? { |owner| owner.respond_to?(:call) })
|
|
66
68
|
raise ArgumentError, "protected journal requires source-owned evidence and service boundaries"
|
|
67
69
|
end
|
|
@@ -128,7 +130,7 @@ module Ace
|
|
|
128
130
|
paths.each do |path|
|
|
129
131
|
parts = path.split("/", -1)
|
|
130
132
|
event = parts.size == 4 && parts[0] == "execution" && parts[2] == "events" && JournalMutation::ID.match?(parts[1]) && parts[3].end_with?(".json")
|
|
131
|
-
request = parts.size == 3 &&
|
|
133
|
+
request = parts.size == 3 && [%w[execution requests], %w[execution merge-requests]].include?(parts[0, 2]) && parts[2].end_with?(".json") && JournalMutation::ID.match?(parts[2].delete_suffix(".json"))
|
|
132
134
|
# Definitions, prepared packets, candidate bundles and imports
|
|
133
135
|
# retain the SAME mutation owner's closed immutable blob dialect.
|
|
134
136
|
validate_blob_path!(path) unless event || request
|
|
@@ -138,6 +140,12 @@ module Ace
|
|
|
138
140
|
unless records.map { |record| record.fetch("request_id") }.sort == expected_requests
|
|
139
141
|
raise AttemptErrors::EvidenceUnavailable, "canonical initialization service inventory differs"
|
|
140
142
|
end
|
|
143
|
+
fixed_records = fixed_merge_request_records(commit: value)
|
|
144
|
+
expected_fixed = paths.select { |path| path.start_with?("execution/merge-requests/") }.map { |path| File.basename(path, ".json") }.sort
|
|
145
|
+
unless fixed_records.map { |record| record.fetch("request_id") }.sort == expected_fixed
|
|
146
|
+
raise AttemptErrors::EvidenceUnavailable, "canonical initialization fixed merge inventory differs"
|
|
147
|
+
end
|
|
148
|
+
fixed_records.each { |record| verify_fixed_record!(record, commit: value) }
|
|
141
149
|
current = File.stat(@repo_root)
|
|
142
150
|
unless !File.symlink?(@repo_root) && [current.dev, current.ino, current.uid, current.gid, current.mode] == identity && ref_value == value
|
|
143
151
|
raise AttemptErrors::EvidenceUnavailable, "canonical initialization selection changed"
|
|
@@ -201,6 +209,21 @@ module Ace
|
|
|
201
209
|
event_commits_for_assignments!(selectors: {assignment_id => event_digests}, commit: commit).fetch(assignment_id)
|
|
202
210
|
end
|
|
203
211
|
|
|
212
|
+
# Cross-attempt projections use canonical publication order, never the
|
|
213
|
+
# wall-clock timestamps of independently chained attempts. Exactly one
|
|
214
|
+
# selected event must have been introduced by the latest relevant CAS.
|
|
215
|
+
def latest_event!(assignment_id:, event_digests:, commit:)
|
|
216
|
+
introductions = event_commits!(assignment_id: assignment_id, event_digests: event_digests, commit: commit)
|
|
217
|
+
introduced_commits = introductions.values.to_h { |value| [value, true] }
|
|
218
|
+
node = canonical_history_nodes!(commit).find { |entry| introduced_commits.key?(entry.first) }
|
|
219
|
+
selected = node && introductions.select { |_digest, introduced| introduced == node.first }.keys
|
|
220
|
+
unless selected&.one?
|
|
221
|
+
raise AttemptErrors::EvidenceUnavailable, "latest canonical event is missing or ambiguous"
|
|
222
|
+
end
|
|
223
|
+
read_events(assignment_id, commit: commit).find { |event| event.fetch("digest") == selected.first } ||
|
|
224
|
+
raise(AttemptErrors::EvidenceUnavailable, "latest canonical event is unavailable")
|
|
225
|
+
end
|
|
226
|
+
|
|
204
227
|
# One immutable walk for an operation selecting several assignments.
|
|
205
228
|
# Every selected assignment retains its complete chain and exact raw
|
|
206
229
|
# blob proof; the result is returned, never cached on this owner.
|
|
@@ -353,13 +376,10 @@ module Ace
|
|
|
353
376
|
else
|
|
354
377
|
bounded_history_read!(%w[cat-file --batch-check], oids.join("\n") + "\n", oids.size * 128)
|
|
355
378
|
end
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
raise AttemptErrors::EvidenceUnavailable, "historical blob metadata differs" unless match && match[1] == oid
|
|
361
|
-
match[2].to_i
|
|
362
|
-
end
|
|
379
|
+
Atoms::GitBlobBatch.sizes(oids, raw)
|
|
380
|
+
rescue Atoms::GitBlobBatch::Invalid => error
|
|
381
|
+
message = error.reason == :count ? "historical blob metadata count differs" : "historical blob metadata differs"
|
|
382
|
+
raise AttemptErrors::EvidenceUnavailable, message
|
|
363
383
|
end
|
|
364
384
|
|
|
365
385
|
def bounded_history_read!(argv, stdin, limit)
|
|
@@ -760,7 +780,9 @@ module Ace
|
|
|
760
780
|
%w[succeeded failed failed-settled].include?(state)
|
|
761
781
|
end
|
|
762
782
|
|
|
763
|
-
|
|
783
|
+
# Shared schema/binding checks; storage interpretation belongs to the
|
|
784
|
+
# caller's source-selected evidence owner, not this pure validator.
|
|
785
|
+
def validate_terminal_receipt_structure!(current, state, receipt)
|
|
764
786
|
unless receipt.is_a?(Hash) && receipt.keys.sort == TERMINAL_RECEIPT_FIELDS.sort
|
|
765
787
|
raise AttemptErrors::ReceiptRejected,
|
|
766
788
|
"Service terminal receipt has invalid fields"
|
|
@@ -787,7 +809,14 @@ module Ace
|
|
|
787
809
|
raise AttemptErrors::ReceiptRejected,
|
|
788
810
|
"Service settlement requires a no-effect attestation artifact"
|
|
789
811
|
end
|
|
790
|
-
|
|
812
|
+
evidence_items
|
|
813
|
+
end
|
|
814
|
+
|
|
815
|
+
def validate_terminal_receipt!(current, state, receipt, pending: nil)
|
|
816
|
+
evidence_items = validate_terminal_receipt_structure!(current, state, receipt)
|
|
817
|
+
# Canonical byte ownership is also usable by the ordinary installed
|
|
818
|
+
# service. It does not opt that service into protected authority.
|
|
819
|
+
if @evidence_reader
|
|
791
820
|
if (current["operation"] == "prune-preserved-workspace" && %w[succeeded failed-settled].include?(state)) ||
|
|
792
821
|
(current["operation"] == "publish" && %w[succeeded failed].include?(state))
|
|
793
822
|
# The same installed evidence reader owns the entire imported
|
|
@@ -922,6 +951,9 @@ module Ace
|
|
|
922
951
|
def authorization_conflict(binding)
|
|
923
952
|
authorization = binding["authorization"]
|
|
924
953
|
return nil unless authorization.is_a?(String) && !authorization.empty?
|
|
954
|
+
if authorization.start_with?("proposal-")
|
|
955
|
+
return proposal_claim_conflict(authorization, request_id: binding.fetch("request_id"), fixed: false)
|
|
956
|
+
end
|
|
925
957
|
service_request_records.find do |other|
|
|
926
958
|
next false if other["request_id"] == binding.fetch("request_id")
|
|
927
959
|
next false if other["state"] == "rejected" && other["consumed"] == false
|
|
@@ -994,8 +1026,8 @@ module Ace
|
|
|
994
1026
|
#
|
|
995
1027
|
# @param assignment_id [String] Assignment ID
|
|
996
1028
|
# @return [Array<Models::Attempt>] Derived attempts
|
|
997
|
-
def derived_attempts(assignment_id)
|
|
998
|
-
events = read_events(assignment_id)
|
|
1029
|
+
def derived_attempts(assignment_id, commit: ref_value)
|
|
1030
|
+
events = read_events(assignment_id, commit: commit)
|
|
999
1031
|
by_attempt = events.group_by { |event| event["attempt_id"] }
|
|
1000
1032
|
by_attempt.delete(nil)
|
|
1001
1033
|
|
|
@@ -1006,7 +1038,7 @@ module Ace
|
|
|
1006
1038
|
state = derive_state(attempt_events)
|
|
1007
1039
|
next if state.nil?
|
|
1008
1040
|
|
|
1009
|
-
build_attempt(assignment_id, attempt_id, intent["payload"], attempt_events, state)
|
|
1041
|
+
build_attempt(assignment_id, attempt_id, intent["payload"], attempt_events, state, commit: commit)
|
|
1010
1042
|
end
|
|
1011
1043
|
end
|
|
1012
1044
|
|
|
@@ -1052,26 +1084,11 @@ module Ace
|
|
|
1052
1084
|
end
|
|
1053
1085
|
|
|
1054
1086
|
def decode_event_blobs!(entries, output)
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
raise AttemptErrors::EvidenceUnavailable, "canonical blob batch header is invalid"
|
|
1061
|
-
end
|
|
1062
|
-
offset = ending + 1
|
|
1063
|
-
content = output.byteslice(offset, size)
|
|
1064
|
-
unless content&.bytesize == size && output.byteslice(offset + size, 1) == "\n" &&
|
|
1065
|
-
Digest::SHA1.hexdigest("blob #{size}\0" + content) == oid
|
|
1066
|
-
raise AttemptErrors::EvidenceUnavailable, "canonical blob batch bytes are invalid"
|
|
1067
|
-
end
|
|
1068
|
-
offset += size + 1
|
|
1069
|
-
content
|
|
1070
|
-
end.tap do
|
|
1071
|
-
unless offset == output.bytesize
|
|
1072
|
-
raise AttemptErrors::EvidenceUnavailable, "canonical blob batch has unexpected trailing bytes"
|
|
1073
|
-
end
|
|
1074
|
-
end
|
|
1087
|
+
Atoms::GitBlobBatch.decode(entries, output)
|
|
1088
|
+
rescue Atoms::GitBlobBatch::Invalid => error
|
|
1089
|
+
message = {header: "canonical blob batch header is invalid", bytes: "canonical blob batch bytes are invalid",
|
|
1090
|
+
trailing: "canonical blob batch has unexpected trailing bytes"}.fetch(error.reason, "canonical blob batch selector is invalid")
|
|
1091
|
+
raise AttemptErrors::EvidenceUnavailable, message
|
|
1075
1092
|
end
|
|
1076
1093
|
|
|
1077
1094
|
def update_service_request(request_id, expected:, replacement:, event_type:, guard: nil)
|
|
@@ -1271,7 +1288,7 @@ module Ace
|
|
|
1271
1288
|
# opaque reference is receiver-issued, never caller-supplied, and
|
|
1272
1289
|
# replays return the originally issued one.
|
|
1273
1290
|
FIXED_MERGE_BINDING_FIELDS = %w[request_id project_id assignment_id attempt_id
|
|
1274
|
-
queue_entry operation authorization input_digest request principal receiver_id
|
|
1291
|
+
queue_entry operation authorization input_digest request principal caller_uid receiver_id
|
|
1275
1292
|
configuration_sha256 execution_identity].freeze
|
|
1276
1293
|
FIXED_MERGE_MUTABLE_FIELDS = %w[state receipt reference recorded_at claimed_at finished_at].freeze
|
|
1277
1294
|
|
|
@@ -1324,25 +1341,27 @@ module Ace
|
|
|
1324
1341
|
|
|
1325
1342
|
# Claim the start of the fixed effect: pending -> uncertain in one
|
|
1326
1343
|
# CAS transition, so a receiver restart can never dispatch a second
|
|
1327
|
-
# merge for the same admitted record
|
|
1328
|
-
#
|
|
1329
|
-
def begin_fixed_merge_request(request_id)
|
|
1344
|
+
# merge for the same admitted record. An already-started claim refuses;
|
|
1345
|
+
# only the winning effect may later settle it.
|
|
1346
|
+
def begin_fixed_merge_request(request_id, guard: nil)
|
|
1330
1347
|
current = fixed_merge_request(request_id)
|
|
1331
1348
|
raise AttemptErrors::NotFound, "Fixed merge request #{request_id} not found" unless current
|
|
1332
|
-
|
|
1349
|
+
if current["state"] == "uncertain"
|
|
1350
|
+
raise AttemptErrors::InvalidState, "Fixed merge request #{request_id} was already started"
|
|
1351
|
+
end
|
|
1333
1352
|
unless current["state"] == "pending"
|
|
1334
1353
|
raise AttemptErrors::InvalidState, "Fixed merge request #{request_id} is terminal"
|
|
1335
1354
|
end
|
|
1336
1355
|
update_fixed_merge_request(request_id, expected: current,
|
|
1337
1356
|
replacement: current.merge("state" => "uncertain", "claimed_at" => Time.now.utc.iso8601(9)),
|
|
1338
|
-
event_type: "merge_transition")
|
|
1357
|
+
event_type: "merge_transition", guard: -> { guard&.call; verify_fixed_proposal!(current) })
|
|
1339
1358
|
end
|
|
1340
1359
|
|
|
1341
1360
|
# Terminal settlement: succeeded/failed with the exact receipt
|
|
1342
1361
|
# validated HERE at the journal boundary — structure, the closed
|
|
1343
1362
|
# terminal invariants, and the binding against the current record.
|
|
1344
1363
|
# Failure is terminal only with immutable no-effect evidence.
|
|
1345
|
-
def settle_fixed_merge_request(request_id, terminal_status:, receipt:)
|
|
1364
|
+
def settle_fixed_merge_request(request_id, terminal_status:, receipt:, attestation: nil)
|
|
1346
1365
|
unless FIXED_MERGE_STATES.include?(terminal_status) && %w[succeeded failed].include?(terminal_status)
|
|
1347
1366
|
raise ArgumentError, "invalid fixed merge terminal status"
|
|
1348
1367
|
end
|
|
@@ -1351,17 +1370,40 @@ module Ace
|
|
|
1351
1370
|
unless %w[pending uncertain].include?(current["state"])
|
|
1352
1371
|
raise AttemptErrors::InvalidState, "Fixed merge request #{request_id} is terminal"
|
|
1353
1372
|
end
|
|
1373
|
+
evidence_files = {}
|
|
1374
|
+
if terminal_status == "failed"
|
|
1375
|
+
unless attestation.is_a?(Hash) &&
|
|
1376
|
+
attestation.values_at("request_id", "input_digest", "reference", "outcome") ==
|
|
1377
|
+
current.values_at("request_id", "input_digest", "reference") + ["no_effect"] &&
|
|
1378
|
+
attestation["candidate_head"] == current.dig("request", "candidate_head") &&
|
|
1379
|
+
attestation["expected_base_head"] == current.dig("request", "expected_base_head")
|
|
1380
|
+
raise AttemptErrors::ReceiptRejected, "Fixed merge no_effect_evidence requires a bound canonical attestation"
|
|
1381
|
+
end
|
|
1382
|
+
content = JSON.generate(attestation)
|
|
1383
|
+
raise AttemptErrors::ReceiptRejected, "Fixed merge attestation exceeds bound" if content.bytesize > 16_384
|
|
1384
|
+
path = fixed_no_effect_path(request_id)
|
|
1385
|
+
evidence_files[path] = content
|
|
1386
|
+
receipt = receipt.merge("effect" => receipt.fetch("effect").merge(
|
|
1387
|
+
"no_effect_evidence" => {"reference" => path, "sha256" => Digest::SHA256.hexdigest(content)}))
|
|
1388
|
+
elsif attestation
|
|
1389
|
+
raise AttemptErrors::ReceiptRejected, "Successful merge cannot carry no-effect attestation"
|
|
1390
|
+
end
|
|
1354
1391
|
validate_fixed_receipt!(current, terminal_status, receipt)
|
|
1355
1392
|
update_fixed_merge_request(request_id, expected: current,
|
|
1356
1393
|
replacement: current.merge("state" => terminal_status, "receipt" => receipt,
|
|
1357
1394
|
"finished_at" => Time.now.utc.iso8601(9)),
|
|
1358
|
-
event_type: "merge_transition")
|
|
1395
|
+
event_type: "merge_transition", evidence_files: evidence_files)
|
|
1359
1396
|
end
|
|
1360
1397
|
|
|
1361
1398
|
def fixed_merge_request_path(request_id)
|
|
1362
1399
|
"execution/merge-requests/#{request_id}.json"
|
|
1363
1400
|
end
|
|
1364
1401
|
|
|
1402
|
+
def fixed_no_effect_path(request_id)
|
|
1403
|
+
validate_request_id!(request_id)
|
|
1404
|
+
"execution/merge-evidence/#{request_id}.json"
|
|
1405
|
+
end
|
|
1406
|
+
|
|
1365
1407
|
def validate_fixed_receipt!(current, terminal_status, receipt)
|
|
1366
1408
|
Atoms::FixedMergeSchemas.validate_receipt!(receipt)
|
|
1367
1409
|
Atoms::FixedMergeSchemas.validate_receipt_against!(receipt, current)
|
|
@@ -1388,25 +1430,39 @@ module Ace
|
|
|
1388
1430
|
end
|
|
1389
1431
|
end
|
|
1390
1432
|
|
|
1391
|
-
def fixed_no_effect_intact?(record)
|
|
1433
|
+
def fixed_no_effect_intact?(record, commit: ref_value)
|
|
1392
1434
|
receipt = record["receipt"]
|
|
1393
1435
|
return false unless receipt.is_a?(Hash)
|
|
1394
1436
|
evidence = receipt.dig("effect", "no_effect_evidence")
|
|
1395
1437
|
return false unless evidence.is_a?(Hash)
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
Digest::SHA256.file(real).hexdigest == evidence.fetch("sha256")
|
|
1405
|
-
rescue Errno::ENOENT, AttemptErrors::EvidenceUnavailable
|
|
1438
|
+
path = fixed_no_effect_path(record.fetch("request_id"))
|
|
1439
|
+
return false unless evidence["reference"] == path
|
|
1440
|
+
content = bounded_blob(path, commit: commit, max_bytes: 16_384)
|
|
1441
|
+
proof = JSON.parse(content)
|
|
1442
|
+
Digest::SHA256.hexdigest(content) == evidence["sha256"] &&
|
|
1443
|
+
proof.values_at("request_id", "input_digest", "reference", "outcome") == record.values_at("request_id", "input_digest", "reference") + ["no_effect"] &&
|
|
1444
|
+
proof["candidate_head"] == record.dig("request", "candidate_head") && proof["expected_base_head"] == record.dig("request", "expected_base_head")
|
|
1445
|
+
rescue AttemptErrors::EvidenceUnavailable, JSON::ParserError, NoMethodError
|
|
1406
1446
|
false
|
|
1407
1447
|
end
|
|
1408
1448
|
|
|
1409
|
-
|
|
1449
|
+
# Immutable terminal consumption for ordinary completion. It uses the
|
|
1450
|
+
# same receipt and no-effect owners as admission/authorization release.
|
|
1451
|
+
def fixed_merge_settlement!(request_id, commit: ref_value)
|
|
1452
|
+
record = fixed_merge_request(request_id, commit: commit)
|
|
1453
|
+
raise AttemptErrors::EvidenceUnavailable, "canonical fixed merge is missing" unless record
|
|
1454
|
+
verify_fixed_record!(record, commit: commit)
|
|
1455
|
+
unless %w[succeeded failed].include?(record["state"])
|
|
1456
|
+
raise AttemptErrors::ServiceSettlementPending, "canonical fixed merge is unresolved"
|
|
1457
|
+
end
|
|
1458
|
+
validate_fixed_receipt!(record, record.fetch("state"), record.fetch("receipt"))
|
|
1459
|
+
if record["state"] == "failed" && !fixed_no_effect_intact?(record, commit: commit)
|
|
1460
|
+
raise AttemptErrors::EvidenceUnavailable, "canonical fixed merge no-effect proof differs"
|
|
1461
|
+
end
|
|
1462
|
+
record
|
|
1463
|
+
end
|
|
1464
|
+
|
|
1465
|
+
def update_fixed_merge_request(request_id, expected:, replacement:, event_type:, guard: nil, evidence_files: {})
|
|
1410
1466
|
unless %w[merge_admission merge_transition].include?(event_type)
|
|
1411
1467
|
raise ArgumentError, "invalid fixed merge event type"
|
|
1412
1468
|
end
|
|
@@ -1419,10 +1475,8 @@ module Ace
|
|
|
1419
1475
|
prepared = prepare_fixed_update(request_id: request_id, expected: expected,
|
|
1420
1476
|
replacement: replacement, event_type: event_type, guard: guard)
|
|
1421
1477
|
return prepared.fetch(:record) if prepared[:replayed]
|
|
1478
|
+
write_mutation_blobs(evidence_files, commit: old)
|
|
1422
1479
|
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
1480
|
assignment_id = replacement.fetch("assignment_id")
|
|
1427
1481
|
attempt_id = replacement.fetch("attempt_id")
|
|
1428
1482
|
prior = read_events(assignment_id).reverse
|
|
@@ -1430,7 +1484,8 @@ module Ace
|
|
|
1430
1484
|
event = Models::EvidenceEvent.build(type: event_type, attempt_id: attempt_id,
|
|
1431
1485
|
payload: prepared.fetch(:event).fetch(:payload), previous_digest: prior)
|
|
1432
1486
|
write_event_files(assignment_id, [event])
|
|
1433
|
-
git!("-C", checkout_dir, "add", "--",
|
|
1487
|
+
git!("-C", checkout_dir, "add", "--", "execution/#{assignment_id}/events")
|
|
1488
|
+
stage_mutation_blobs(evidence_files.merge(path => JSON.pretty_generate(prepared.fetch(:record))))
|
|
1434
1489
|
state = replacement.fetch("state")
|
|
1435
1490
|
git!("-C", checkout_dir, "-c", "user.name=ace-assign", "-c", "user.email=ace-assign@localhost",
|
|
1436
1491
|
"commit", "-m", "evidence: fixed merge request #{request_id} #{state}")
|
|
@@ -1455,7 +1510,11 @@ module Ace
|
|
|
1455
1510
|
raise AttemptErrors::InvalidState, "Fixed merge request needs an initial admission"
|
|
1456
1511
|
end
|
|
1457
1512
|
if existing
|
|
1458
|
-
|
|
1513
|
+
if fixed_replay?(existing, replacement)
|
|
1514
|
+
conflict = guard&.call
|
|
1515
|
+
raise_authorization_conflict!(conflict) if conflict
|
|
1516
|
+
return {record: existing, replayed: true}
|
|
1517
|
+
end
|
|
1459
1518
|
raise AttemptErrors::Conflict, "Fixed merge request #{request_id} has different input"
|
|
1460
1519
|
end
|
|
1461
1520
|
unless Molecules::FixedEffectReference.valid?(replacement.fetch("reference"))
|
|
@@ -1477,6 +1536,13 @@ module Ace
|
|
|
1477
1536
|
end
|
|
1478
1537
|
conflict = guard&.call
|
|
1479
1538
|
raise_authorization_conflict!(conflict) if conflict
|
|
1539
|
+
if expected.nil? || replacement["state"] == "uncertain"
|
|
1540
|
+
attempt = derived_attempts(replacement.fetch("assignment_id")).find { |item| item.attempt_id == replacement.fetch("attempt_id") }
|
|
1541
|
+
unless attempt&.managed? && attempt.active? && attempt.binding.project_id == replacement.fetch("project_id")
|
|
1542
|
+
raise AttemptErrors::ReceiptRejected, "Fixed merge admission requires its current active canonical attempt"
|
|
1543
|
+
end
|
|
1544
|
+
end
|
|
1545
|
+
verify_fixed_proposal!(replacement) if event_type == "merge_admission"
|
|
1480
1546
|
payload = {"request_id" => request_id, "state" => replacement.fetch("state"),
|
|
1481
1547
|
"input_digest" => replacement.fetch("input_digest"),
|
|
1482
1548
|
"record_digest" => Atoms::EvidenceDigest.digest(replacement),
|
|
@@ -1485,6 +1551,21 @@ module Ace
|
|
|
1485
1551
|
end
|
|
1486
1552
|
private :prepare_fixed_update
|
|
1487
1553
|
|
|
1554
|
+
def verify_fixed_proposal!(record)
|
|
1555
|
+
return unless record.fetch("authorization").start_with?("proposal-")
|
|
1556
|
+
proposal_authorize!(record.fetch("authorization"), fixed_proposal_binding(record))
|
|
1557
|
+
conflict = proposal_claim_conflict(record.fetch("authorization"),
|
|
1558
|
+
request_id: record.fetch("request_id"), fixed: true)
|
|
1559
|
+
raise AttemptErrors::AuthorizationConflict, "Proposal already holds an effect claim" if conflict
|
|
1560
|
+
nil
|
|
1561
|
+
end
|
|
1562
|
+
|
|
1563
|
+
def fixed_proposal_binding(record)
|
|
1564
|
+
record.slice("operation", "project_id", "assignment_id", "attempt_id", "input_digest", "caller_uid").merge(
|
|
1565
|
+
"target" => {"resource" => record.fetch("request").fetch("pr_url"), "artifact_digest" => nil},
|
|
1566
|
+
"candidate_head" => record.fetch("request").fetch("candidate_head"))
|
|
1567
|
+
end
|
|
1568
|
+
|
|
1488
1569
|
def fixed_replay?(existing, replacement)
|
|
1489
1570
|
FIXED_MERGE_BINDING_FIELDS.all? do |field|
|
|
1490
1571
|
existing[field] == replacement.fetch(field)
|
|
@@ -1514,7 +1595,8 @@ module Ace
|
|
|
1514
1595
|
# The module marks a private region above; the fixed merge primitive
|
|
1515
1596
|
# is a public coordinator-facing surface, so it is re-published here.
|
|
1516
1597
|
public :fixed_merge_request, :fixed_merge_request_records, :admit_fixed_merge_request,
|
|
1517
|
-
:begin_fixed_merge_request, :settle_fixed_merge_request, :fixed_authorization_conflict
|
|
1598
|
+
:begin_fixed_merge_request, :settle_fixed_merge_request, :fixed_authorization_conflict, :fixed_merge_settlement!,
|
|
1599
|
+
:verify_service_attestation!
|
|
1518
1600
|
|
|
1519
1601
|
# Latest lifecycle state implied by the events, or nil when the
|
|
1520
1602
|
# events do not describe a full attempt (intent missing).
|
|
@@ -1522,7 +1604,7 @@ module Ace
|
|
|
1522
1604
|
CanonicalAttemptState.derive(events)
|
|
1523
1605
|
end
|
|
1524
1606
|
|
|
1525
|
-
def build_attempt(assignment_id, attempt_id, intent_payload, events, state)
|
|
1607
|
+
def build_attempt(assignment_id, attempt_id, intent_payload, events, state, commit: ref_value)
|
|
1526
1608
|
process_start = events.reverse.find do |event|
|
|
1527
1609
|
event["type"] == "process_start" && event["attempt_id"] == attempt_id
|
|
1528
1610
|
end
|
|
@@ -1552,7 +1634,7 @@ module Ace
|
|
|
1552
1634
|
binding: binding,
|
|
1553
1635
|
state: state,
|
|
1554
1636
|
candidate_head: candidate_head,
|
|
1555
|
-
journal_commit:
|
|
1637
|
+
journal_commit: commit
|
|
1556
1638
|
)
|
|
1557
1639
|
end
|
|
1558
1640
|
|
|
@@ -1658,7 +1740,7 @@ module Ace
|
|
|
1658
1740
|
File.join(@checkout_root, ".evidence.lock")
|
|
1659
1741
|
end
|
|
1660
1742
|
|
|
1661
|
-
def with_lock(cleanup_on_failure: true, owner_uid: nil, owner_gid: nil)
|
|
1743
|
+
def with_lock(cleanup_on_failure: true, owner_uid: nil, owner_gid: nil, deadline: nil)
|
|
1662
1744
|
raise AttemptErrors::EvidenceUnavailable, "read-only journal cannot mutate canonical evidence" if @read_boundary
|
|
1663
1745
|
FileUtils.mkdir_p(@checkout_root)
|
|
1664
1746
|
lock = if owner_uid
|
|
@@ -1667,7 +1749,19 @@ module Ace
|
|
|
1667
1749
|
File.open(lock_path, File::RDWR | File::CREAT, 0o600)
|
|
1668
1750
|
end
|
|
1669
1751
|
begin
|
|
1670
|
-
|
|
1752
|
+
if deadline
|
|
1753
|
+
unless deadline.is_a?(Numeric) && deadline.finite?
|
|
1754
|
+
raise ArgumentError, "canonical lock deadline must be finite"
|
|
1755
|
+
end
|
|
1756
|
+
loop do
|
|
1757
|
+
remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
1758
|
+
raise AttemptErrors::Conflict, "canonical journal lock deadline elapsed" unless remaining.positive?
|
|
1759
|
+
break if lock.flock(File::LOCK_EX | File::LOCK_NB)
|
|
1760
|
+
sleep [remaining, 0.01].min
|
|
1761
|
+
end
|
|
1762
|
+
else
|
|
1763
|
+
lock.flock(File::LOCK_EX)
|
|
1764
|
+
end
|
|
1671
1765
|
begin
|
|
1672
1766
|
yield
|
|
1673
1767
|
rescue
|