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.
Files changed (63) hide show
  1. checksums.yaml +4 -4
  2. data/.ace-defaults/assign/presets/deliver-release.yml +11 -8
  3. data/.ace-defaults/assign/presets/integrate-batch.yml +10 -5
  4. data/.ace-defaults/assign/presets/supervise-delivery.yml +6 -4
  5. data/CHANGELOG.md +27 -0
  6. data/handbook/skills/as-assign-drive/SKILL.md +5 -2
  7. data/handbook/workflow-instructions/assign/drive.wf.md +77 -0
  8. data/lib/ace/assign/atoms/assignment_scope.rb +5 -0
  9. data/lib/ace/assign/atoms/candidate_limits.rb +20 -0
  10. data/lib/ace/assign/atoms/git_blob_batch.rb +59 -0
  11. data/lib/ace/assign/authority/assignment_definition.rb +35 -0
  12. data/lib/ace/assign/authority/candidate_blob_batch.rb +55 -0
  13. data/lib/ace/assign/authority/candidate_head_comparison.rb +139 -0
  14. data/lib/ace/assign/authority/candidate_transfer.rb +57 -27
  15. data/lib/ace/assign/authority/command_execution.rb +180 -0
  16. data/lib/ace/assign/authority/endcap.rb +4 -2
  17. data/lib/ace/assign/authority/endcap_attempts.rb +1 -1
  18. data/lib/ace/assign/authority/endcap_campaign_rounds.rb +1 -1
  19. data/lib/ace/assign/authority/endcap_publication.rb +0 -166
  20. data/lib/ace/assign/authority/endcap_service_context.rb +38 -0
  21. data/lib/ace/assign/authority/fixed_publication_lifecycle.rb +176 -0
  22. data/lib/ace/assign/authority/{endcap_services.rb → fixed_service_lifecycle.rb} +42 -46
  23. data/lib/ace/assign/authority/launch_control_exclusions.rb +3 -17
  24. data/lib/ace/assign/authority/ordinary_attempt_start.rb +137 -0
  25. data/lib/ace/assign/authority/ordinary_client.rb +70 -0
  26. data/lib/ace/assign/authority/ordinary_completion.rb +104 -0
  27. data/lib/ace/assign/authority/ordinary_effect_settlement.rb +118 -0
  28. data/lib/ace/assign/authority/ordinary_lifecycle.rb +227 -0
  29. data/lib/ace/assign/authority/ordinary_merge_evidence.rb +81 -0
  30. data/lib/ace/assign/authority/ordinary_peer.rb +54 -0
  31. data/lib/ace/assign/authority/ordinary_progress.rb +162 -0
  32. data/lib/ace/assign/authority/ordinary_queue.rb +115 -0
  33. data/lib/ace/assign/authority/ordinary_release_artifacts.rb +143 -0
  34. data/lib/ace/assign/authority/ordinary_results.rb +206 -0
  35. data/lib/ace/assign/authority/ordinary_review_evidence.rb +180 -0
  36. data/lib/ace/assign/authority/ordinary_reviews.rb +214 -0
  37. data/lib/ace/assign/authority/ordinary_server.rb +75 -0
  38. data/lib/ace/assign/authority/ordinary_supervisor.rb +99 -0
  39. data/lib/ace/assign/authority/prepared_work.rb +9 -4
  40. data/lib/ace/assign/authority/protected_assignment_context.rb +3 -3
  41. data/lib/ace/assign/authority/release_artifact_manifest.rb +91 -0
  42. data/lib/ace/assign/authority/retained_input.rb +53 -0
  43. data/lib/ace/assign/authority/router.rb +11 -6
  44. data/lib/ace/assign/authority/server.rb +11 -180
  45. data/lib/ace/assign/authority/socket_server.rb +255 -0
  46. data/lib/ace/assign/authority/transfer_codec.rb +5 -3
  47. data/lib/ace/assign/cli/commands/attempt/evidence.rb +2 -2
  48. data/lib/ace/assign/cli/commands/submit_candidate.rb +1 -1
  49. data/lib/ace/assign/molecules/canonical_evidence.rb +25 -8
  50. data/lib/ace/assign/molecules/canonical_read_commands.rb +84 -0
  51. data/lib/ace/assign/molecules/canonical_read_snapshot.rb +2 -63
  52. data/lib/ace/assign/molecules/evidence_journal.rb +157 -63
  53. data/lib/ace/assign/molecules/installed_journal_read.rb +143 -0
  54. data/lib/ace/assign/molecules/journal_mutation.rb +7 -4
  55. data/lib/ace/assign/molecules/lifecycle_exclusion.rb +13 -2
  56. data/lib/ace/assign/molecules/proposal_journal.rb +32 -4
  57. data/lib/ace/assign/molecules/receipt_verifier.rb +8 -2
  58. data/lib/ace/assign/molecules/runtime_control_surface_runner.rb +2 -1
  59. data/lib/ace/assign/organisms/attempt_coordinator.rb +88 -36
  60. data/lib/ace/assign/organisms/ordinary_driver.rb +310 -0
  61. data/lib/ace/assign/organisms/ordinary_merge_consumer.rb +5 -5
  62. data/lib/ace/assign/version.rb +1 -1
  63. metadata +39 -10
@@ -1,129 +1,29 @@
1
1
  # frozen_string_literal: true
2
- require "socket"
3
- require "fileutils"
2
+
4
3
  require_relative "deployment"
5
- require_relative "transfer_codec"
6
- require_relative "../molecules/evidence_journal"
4
+ require_relative "socket_server"
7
5
 
8
6
  module Ace
9
7
  module Assign
10
8
  module Authority
11
- # Existing assignment owner transport; kernel peers select roles, never argv.
12
- class Server
13
- CONNECTIONS = 16
9
+ # Protected admission is mandatory for this source-selected composition.
10
+ class Server < SocketServer
14
11
  def initialize(authority_id:, lifecycle:, deployment: Deployment.load,
15
- kernel: Ace::Runtime::Molecules::ProtectedLinux.new, composition: "launch", hitl_service: nil)
16
- @authority_id, @lifecycle, @deployment, @kernel = authority_id, lifecycle, deployment, kernel
17
- @composition = composition
18
- @hitl_service = hitl_service
12
+ kernel: Ace::Runtime::Molecules::ProtectedLinux.new, composition: "launch", services: [])
13
+ @authority_id, @deployment, @kernel, @composition = authority_id, deployment, kernel, composition
19
14
  deployment.verify_composition!(authority_id, composition: composition)
20
- @service = deployment.authority(authority_id)
21
- @mutex = Mutex.new
22
- @connections = []
23
- @handlers = []
24
- @transfers = 0
25
- @stopped = false
15
+ super(service: deployment.authority(authority_id), lifecycle: lifecycle, services: services)
26
16
  end
27
17
 
28
- def serve
18
+ private
19
+
20
+ def verify_listener!
29
21
  @kernel.supported!
30
22
  me = @kernel.capture(Process.pid)
31
23
  unless principal?(me, @service, "uid", "gid", "groups")
32
24
  raise AttemptErrors::UnauthorizedIdentity, "listener principal differs from installed authority"
33
25
  end
34
26
  @deployment.verify_receiver_paths!(@authority_id)
35
- directory = File.dirname(@service.fetch("socket_path"))
36
- wire.root_path!(directory, directory: true, owner: @service.fetch("uid"))
37
- lock = File.open(File.join(directory, "listener.lock"), File::RDWR | File::CREAT | File::NOFOLLOW, 0o600)
38
- unless lock.flock(File::LOCK_EX | File::LOCK_NB)
39
- raise AttemptErrors::Conflict, "assignment authority already owns its listener"
40
- end
41
- start_hitl_service!
42
- path = @service.fetch("socket_path")
43
- if File.exist?(path) || File.symlink?(path)
44
- raise AttemptErrors::Conflict, "existing authority endpoint requires verified installer recovery"
45
- end
46
- @listener = UNIXServer.new(path)
47
- File.chmod(0o660, path)
48
- @endpoint = wire.socket_identity(path)
49
- until @stopped
50
- if @hitl_thread && !@hitl_thread.alive?
51
- raise Ace::Runtime::RuntimeUnavailableError, "protected HITL boundary stopped before its authority"
52
- end
53
- ready = IO.select([@listener], nil, nil, 0.25)
54
- next unless ready
55
- socket = @listener.accept_nonblock(exception: false)
56
- next if socket == :wait_readable
57
- admitted = @mutex.synchronize do
58
- if @connections.size < CONNECTIONS
59
- @connections << socket
60
- true
61
- end
62
- end
63
- unless admitted
64
- refusal(socket, "busy")
65
- socket.close
66
- next
67
- end
68
- @mutex.synchronize do
69
- @handlers.reject! { |handler| !handler.alive? }
70
- @handlers << Thread.new(socket) { |connection| receive(connection) }
71
- end
72
- end
73
- rescue IOError, Errno::EBADF
74
- raise unless @stopped
75
- ensure
76
- stop
77
- stop_hitl_service!
78
- # Closing a stream does not cancel a handler already inside dispatch.
79
- # The owner lock and endpoint remain held until every handler ends.
80
- @mutex.synchronize { @handlers.dup }.each(&:join)
81
- @lifecycle.close if @endpoint && @lifecycle.respond_to?(:close)
82
- if @endpoint && same_endpoint?(path)
83
- File.unlink(path)
84
- end
85
- lock&.close
86
- end
87
-
88
- def request_stop
89
- @stopped = true
90
- end
91
-
92
- def stop
93
- @stopped = true
94
- @listener&.close unless @listener&.closed?
95
- @mutex.synchronize { @connections.each { |socket| socket.close unless socket.closed? } }
96
- end
97
-
98
- private
99
-
100
- # The existing HITL boundary is a sibling owned by this listener's
101
- # lifetime. It must be ready before assignment ingress is opened.
102
- def start_hitl_service!
103
- return unless @hitl_service
104
- ready = Queue.new
105
- @hitl_thread = Thread.new do
106
- @hitl_service.run(on_ready: -> { ready << :ready })
107
- rescue Exception => error
108
- @hitl_failure = error
109
- ready << error
110
- end
111
- @hitl_thread.report_on_exception = false
112
- result = ready.pop(timeout: 5)
113
- raise Ace::Runtime::RuntimeUnavailableError, "protected HITL boundary startup timed out" if result.nil?
114
- raise result if result.is_a?(Exception)
115
- end
116
-
117
- def stop_hitl_service!
118
- return unless @hitl_thread
119
- @hitl_service.stop
120
- @hitl_thread.join
121
- end
122
-
123
- def same_endpoint?(path)
124
- wire.socket_identity(path) == @endpoint
125
- rescue StandardError
126
- false
127
27
  end
128
28
 
129
29
  def receive(socket)
@@ -200,40 +100,7 @@ module Ace
200
100
  raise AttemptErrors::UnauthorizedIdentity, "gate readiness requires worker peer" unless role == :worker
201
101
  @lifecycle.gate_ready(request: request, peer: peer, socket: socket, deadline: deadline)
202
102
  else
203
- Molecules::EvidenceJournal.with_event_read_operation do
204
- binding = @lifecycle.transfer_binding(request) if @lifecycle.respond_to?(:transfer_binding)
205
- if binding
206
- raise AttemptErrors::UnauthorizedIdentity, "transfer role differs" unless binding.fetch(:roles).include?(role)
207
- raise ArgumentError, "transfer control header is oversized" if frame.fetch(:bytesize) > 16_384
208
- @lifecycle.authorize_transfer!(request: request, peer: peer, role: role)
209
- admitted_transfer = @mutex.synchronize do
210
- if @transfers < 2
211
- @transfers += 1
212
- true
213
- end
214
- end
215
- raise AttemptErrors::Conflict, "transfer capacity is busy" unless admitted_transfer
216
- codec = transfer_codec
217
- if binding.fetch(:direction) == :upload
218
- result = codec.receive(socket, descriptor: params.fetch("transfer"), purpose: binding.fetch(:purpose), deadline: deadline) do |input|
219
- @lifecycle.dispatch(request: request, peer: peer, role: role, transfer: input)
220
- end
221
- else
222
- result = @lifecycle.dispatch(request: request, peer: peer, role: role)
223
- parts = result.fetch(:transfer_parts)
224
- descriptor = codec.descriptor(parts, purpose: binding.fetch(:purpose))
225
- result = result.merge(data: result.fetch(:data).merge("transfer" => descriptor))
226
- end
227
- else
228
- raise ArgumentError, "unexpected transfer descriptor" if params.key?("transfer")
229
- result = @lifecycle.dispatch(request: request, peer: peer, role: role)
230
- end
231
- wire.write(socket, {"status" => "ok", "data" => result.fetch(:data),
232
- "transport" => {"replayed" => result.fetch(:replayed, false)}}, deadline: deadline, limit: 16_384)
233
- if binding && binding.fetch(:direction) == :download
234
- codec.send(socket, parts: parts, descriptor: descriptor, purpose: binding.fetch(:purpose), deadline: deadline)
235
- end
236
- end
103
+ dispatch_request(socket, request, peer, role, deadline, frame_bytes: frame.fetch(:bytesize))
237
104
  end
238
105
  rescue ArgumentError, KeyError, AttemptErrors::MalformedTransfer
239
106
  refusal(socket, "invalid_input")
@@ -255,45 +122,9 @@ module Ace
255
122
  end
256
123
  end
257
124
 
258
- # These closed read operations have no request body. Half-close proves
259
- # framing completion before status or artifact data can be exposed.
260
- def bodyless_read!(socket, deadline)
261
- loop do
262
- remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
263
- raise ArgumentError, "read request framing is incomplete" unless remaining.positive? && IO.select([socket], nil, nil, remaining)
264
- bytes = socket.read_nonblock(1, exception: false)
265
- next if bytes == :wait_readable
266
- raise ArgumentError, "unexpected read request body" unless bytes.nil?
267
- return
268
- end
269
- end
270
-
271
- def transfer_codec
272
- @mutex.synchronize do
273
- @transfer_codec ||= begin
274
- root = @service.fetch("state_root")
275
- PrivateDirectory.verify!(root)
276
- spool = File.join(root, "transfers")
277
- Dir.mkdir(spool, 0o700) unless File.exist?(spool) || File.symlink?(spool)
278
- TransferCodec.new(root: spool)
279
- end
280
- end
281
- end
282
-
283
125
  def principal?(identity, mapping, uid, gid, groups)
284
126
  identity["uid"] == mapping[uid] && identity["gid"] == mapping[gid] && identity["groups"] == mapping[groups]
285
127
  end
286
-
287
- def refusal(socket, code)
288
- wire.write(socket, {"status" => "error", "error" => {"code" => code, "message" => "Protected authority refused #{code}"}},
289
- deadline: wire.deadline(0.1))
290
- rescue StandardError
291
- nil
292
- end
293
-
294
- def wire
295
- Ace::Runtime::Molecules::ProtectedSocket
296
- end
297
128
  end
298
129
  end
299
130
  end
@@ -0,0 +1,255 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "socket"
4
+ require "fileutils"
5
+ require_relative "transfer_codec"
6
+ require_relative "../molecules/evidence_journal"
7
+
8
+ module Ace
9
+ module Assign
10
+ module Authority
11
+ # Shared endpoint lifetime and bounded wire protocol. Concrete source
12
+ # compositions authenticate their own installed principals before dispatch.
13
+ # Protected deployment/kernel requirements remain in Server.
14
+ class SocketServer
15
+ CONNECTIONS = 16
16
+
17
+ def initialize(service:, lifecycle:, services: [])
18
+ unless services.is_a?(Array) && services.uniq.size == services.size &&
19
+ services.all? { |owned| owned.respond_to?(:run) && owned.respond_to?(:stop) }
20
+ raise ArgumentError, "authority services require distinct source-owned run/stop lifetimes"
21
+ end
22
+ @service, @lifecycle, @services = service, lifecycle, services.dup.freeze
23
+ @service_threads = []
24
+ @activation_gates = []
25
+ @mutex = Mutex.new
26
+ @connections = []
27
+ @handlers = []
28
+ @transfers = 0
29
+ @stopped = false
30
+ end
31
+
32
+ def serve
33
+ verify_listener!
34
+ directory = File.dirname(@service.fetch("socket_path"))
35
+ wire.root_path!(directory, directory: true, owner: @service.fetch("uid"))
36
+ lock = File.open(File.join(directory, "listener.lock"), File::RDWR | File::CREAT | File::NOFOLLOW, 0o600)
37
+ unless lock.flock(File::LOCK_EX | File::LOCK_NB)
38
+ raise AttemptErrors::Conflict, "assignment authority already owns its listener"
39
+ end
40
+ path = @service.fetch("socket_path")
41
+ if File.exist?(path) || File.symlink?(path)
42
+ raise AttemptErrors::Conflict, "existing authority endpoint requires verified installer recovery"
43
+ end
44
+ @listener = UNIXServer.new(path)
45
+ @endpoint = wire.socket_identity(path)
46
+ File.chmod(0o660, path)
47
+ start_services!
48
+ verify_services!
49
+ @activation_gates.each { |gate| gate << :activate }
50
+ until @stopped
51
+ verify_services!
52
+ ready = IO.select([@listener], nil, nil, 0.25)
53
+ next unless ready
54
+ socket = @listener.accept_nonblock(exception: false)
55
+ next if socket == :wait_readable
56
+ admitted = @mutex.synchronize do
57
+ if @connections.size < CONNECTIONS
58
+ @connections << socket
59
+ true
60
+ end
61
+ end
62
+ unless admitted
63
+ refusal(socket, "busy")
64
+ socket.close
65
+ next
66
+ end
67
+ @mutex.synchronize do
68
+ @handlers.reject! { |handler| !handler.alive? }
69
+ @handlers << Thread.new(socket) { |connection| receive(connection) }
70
+ end
71
+ end
72
+ rescue IOError, Errno::EBADF
73
+ raise unless @stopped
74
+ ensure
75
+ begin
76
+ stop
77
+ stop_services!
78
+ ensure
79
+ # Closing a stream does not cancel an admitted transaction. Hold
80
+ # the owner lock and endpoint until every handler has ended.
81
+ begin
82
+ @mutex.synchronize { @handlers.dup }.each(&:join)
83
+ @lifecycle.close if @endpoint && @lifecycle.respond_to?(:close)
84
+ ensure
85
+ begin
86
+ File.unlink(path) if @endpoint && same_endpoint?(path)
87
+ ensure
88
+ lock&.close
89
+ end
90
+ end
91
+ end
92
+ end
93
+
94
+ def request_stop
95
+ @stopped = true
96
+ end
97
+
98
+ def stop
99
+ @stopped = true
100
+ @listener&.close unless @listener&.closed?
101
+ @mutex.synchronize { @connections.each { |socket| socket.close unless socket.closed? } }
102
+ end
103
+
104
+ private
105
+
106
+ # Installed HITL and merge listeners share the assignment owner lock.
107
+ # Every selected boundary must be ready before assignment ingress opens;
108
+ # a stopped boundary closes admission until the complete owner restarts.
109
+ def start_services!
110
+ @services.each do |owned|
111
+ ready = Queue.new
112
+ activation = Queue.new
113
+ @activation_gates << activation
114
+ thread = Thread.new do
115
+ owned.run(on_ready: lambda do
116
+ ready << :ready
117
+ unless activation.pop == :activate
118
+ raise Ace::Runtime::RuntimeUnavailableError, "authority startup was cancelled"
119
+ end
120
+ end)
121
+ rescue Exception => error
122
+ ready << error
123
+ ensure
124
+ ready << :stopped
125
+ end
126
+ thread.report_on_exception = false
127
+ @service_threads << [owned, thread]
128
+ result = ready.pop(timeout: 5)
129
+ raise Ace::Runtime::RuntimeUnavailableError, "authority service startup did not become ready" unless result == :ready || result.is_a?(Exception)
130
+ raise result if result.is_a?(Exception)
131
+ end
132
+ end
133
+
134
+ def verify_services!
135
+ unless @service_threads.all? { |_, thread| thread.alive? }
136
+ raise Ace::Runtime::RuntimeUnavailableError, "an owned service stopped before its authority"
137
+ end
138
+ end
139
+
140
+ def stop_services!
141
+ errors = []
142
+ @activation_gates.each { |gate| gate << :cancel }
143
+ @service_threads.reverse_each do |owned, _|
144
+ owned.stop
145
+ rescue Exception => error
146
+ errors << error
147
+ end
148
+ @service_threads.each do |_, thread|
149
+ thread.join
150
+ rescue Exception => error
151
+ errors << error
152
+ end
153
+ raise errors.first unless errors.empty?
154
+ end
155
+
156
+ def same_endpoint?(path)
157
+ wire.socket_identity(path) == @endpoint
158
+ rescue StandardError
159
+ false
160
+ end
161
+
162
+ def verify_listener!
163
+ raise NotImplementedError, "source composition must verify its installed listener"
164
+ end
165
+
166
+ def dispatch_request(socket, request, peer, role, deadline, frame_bytes:)
167
+ params = request.fetch("params")
168
+ admitted_transfer = false
169
+ Molecules::EvidenceJournal.with_event_read_operation do
170
+ binding = @lifecycle.transfer_binding(request) if @lifecycle.respond_to?(:transfer_binding)
171
+ if binding
172
+ raise AttemptErrors::UnauthorizedIdentity, "transfer role differs" unless binding.fetch(:roles).include?(role)
173
+ raise ArgumentError, "transfer control header is oversized" if frame_bytes > 16_384
174
+ authorize_transfer!(request: request, peer: peer, role: role, deadline: deadline)
175
+ admitted_transfer = @mutex.synchronize do
176
+ if @transfers < 2
177
+ @transfers += 1
178
+ true
179
+ end
180
+ end
181
+ raise AttemptErrors::Conflict, "transfer capacity is busy" unless admitted_transfer
182
+ codec = transfer_codec
183
+ if binding.fetch(:direction) == :upload
184
+ result = codec.receive(socket, descriptor: params.fetch("transfer"), purpose: binding.fetch(:purpose), deadline: deadline) do |input|
185
+ dispatch_lifecycle(request: request, peer: peer, role: role, transfer: input, deadline: deadline)
186
+ end
187
+ else
188
+ result = dispatch_lifecycle(request: request, peer: peer, role: role, deadline: deadline)
189
+ parts = result.fetch(:transfer_parts)
190
+ descriptor = codec.descriptor(parts, purpose: binding.fetch(:purpose))
191
+ result = result.merge(data: result.fetch(:data).merge("transfer" => descriptor))
192
+ end
193
+ else
194
+ raise ArgumentError, "unexpected transfer descriptor" if params.key?("transfer")
195
+ result = dispatch_lifecycle(request: request, peer: peer, role: role, deadline: deadline)
196
+ end
197
+ wire.write(socket, {"status" => "ok", "data" => result.fetch(:data),
198
+ "transport" => {"replayed" => result.fetch(:replayed, false)}}, deadline: deadline, limit: 16_384)
199
+ if binding && binding.fetch(:direction) == :download
200
+ codec.send(socket, parts: parts, descriptor: descriptor, purpose: binding.fetch(:purpose), deadline: deadline)
201
+ end
202
+ end
203
+ ensure
204
+ @mutex.synchronize { @transfers -= 1 } if admitted_transfer
205
+ end
206
+
207
+ def authorize_transfer!(request:, peer:, role:, deadline:)
208
+ @lifecycle.authorize_transfer!(request: request, peer: peer, role: role)
209
+ end
210
+
211
+ def dispatch_lifecycle(request:, peer:, role:, deadline:, transfer: nil)
212
+ options = {request: request, peer: peer, role: role}
213
+ options[:transfer] = transfer if transfer
214
+ @lifecycle.dispatch(**options)
215
+ end
216
+
217
+ # These closed read operations have no request body. Half-close proves
218
+ # framing completion before status or artifact data can be exposed.
219
+ def bodyless_read!(socket, deadline)
220
+ loop do
221
+ remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
222
+ raise ArgumentError, "read request framing is incomplete" unless remaining.positive? && IO.select([socket], nil, nil, remaining)
223
+ bytes = socket.read_nonblock(1, exception: false)
224
+ next if bytes == :wait_readable
225
+ raise ArgumentError, "unexpected read request body" unless bytes.nil?
226
+ return
227
+ end
228
+ end
229
+
230
+ def transfer_codec
231
+ @mutex.synchronize do
232
+ @transfer_codec ||= begin
233
+ root = @service.fetch("state_root")
234
+ PrivateDirectory.verify!(root)
235
+ spool = File.join(root, "transfers")
236
+ Dir.mkdir(spool, 0o700) unless File.exist?(spool) || File.symlink?(spool)
237
+ TransferCodec.new(root: spool)
238
+ end
239
+ end
240
+ end
241
+
242
+ def refusal(socket, code)
243
+ wire.write(socket, {"status" => "error", "error" => {"code" => code, "message" => "Protected authority refused #{code}"}},
244
+ deadline: wire.deadline(0.1))
245
+ rescue StandardError
246
+ nil
247
+ end
248
+
249
+ def wire
250
+ Ace::Runtime::Molecules::ProtectedSocket
251
+ end
252
+ end
253
+ end
254
+ end
255
+ end
@@ -5,6 +5,7 @@ require "tempfile"
5
5
  require "timeout"
6
6
  require "ace/runtime/molecules/protected_socket"
7
7
  require_relative "private_directory"
8
+ require_relative "../atoms/candidate_limits"
8
9
 
9
10
  module Ace
10
11
  module Assign
@@ -13,10 +14,11 @@ module Ace
13
14
  # Server selects purpose from a source handler's fixed operation table,
14
15
  # admits a transfer slot before calling, and holds no journal lock here.
15
16
  class TransferCodec
16
- LIMITS = {prompt_text: [16_384, 1, 16_384], scope_boundary_observation: [65_536, 1, 65_536], candidate: [64 * 1024 * 1024, 1, 64 * 1024 * 1024],
17
+ LIMITS = {prompt_text: [16_384, 1, 16_384], scope_boundary_observation: [65_536, 1, 65_536], candidate: [Atoms::CandidateLimits::BYTES, 1, Atoms::CandidateLimits::BYTES],
18
+ release_artifact: [64 * 1024 * 1024, 1, 64 * 1024 * 1024],
17
19
  artifacts: [256 * 1024, 16, 64 * 1024],
18
20
  receipt_artifacts: [272 * 1024, 17, 64 * 1024],
19
- service_input: [64 * 1024, 1, 64 * 1024]}.freeze
21
+ service_input: [64 * 1024, 1, 64 * 1024], assignment_snapshot: [Atoms::CandidateLimits::BYTES, 1, Atoms::CandidateLimits::BYTES]}.freeze
20
22
  SHA256 = /\A[0-9a-f]{64}\z/
21
23
 
22
24
  class Input
@@ -149,7 +151,7 @@ module Ace
149
151
  part["bytes"].is_a?(Integer) && part["bytes"].between?(0, each) &&
150
152
  part["sha256"].is_a?(String) && part["sha256"].match?(SHA256) } &&
151
153
  descriptor["bytes"] == descriptor["parts"].sum { |part| part["bytes"] } &&
152
- (!%i[candidate service_input prompt_text].include?(purpose) || descriptor["bytes"].positive?)
154
+ (!%i[candidate service_input prompt_text assignment_snapshot release_artifact].include?(purpose) || descriptor["bytes"].positive?)
153
155
  reject!("Invalid or oversized transfer descriptor")
154
156
  end
155
157
  if purpose == :receipt_artifacts
@@ -7,12 +7,12 @@ module Ace
7
7
  module Attempt
8
8
  class Evidence < Ace::Support::Cli::Command
9
9
  include Attempt::Base
10
- desc "Read coordinator-accepted live execution or historical review authority"
10
+ desc "Read coordinator-accepted live execution or historical execution authority"
11
11
  option :attempt, required: true, desc: "Accepted execution attempt ID"
12
12
  option :receipt_digest, required: true, desc: "Accepted receipt digest"
13
13
  option :kind, default: "check", desc: "Evidence purpose: check, review-collection or review-approval"
14
14
  option :check_name, default: "tests", desc: "Required check name (tests binds operation test)"
15
- option :historical_head, desc: "Recorded collection/approval head for historical authority only"
15
+ option :historical_head, desc: "Exact recorded head for historical authority only (never current-head evidence)"
16
16
  option :format, default: "json", desc: "JSON evidence projection"
17
17
 
18
18
  def call(**options)
@@ -23,7 +23,7 @@ module Ace
23
23
  head = require_option(options, :head, usage)
24
24
  raise Ace::Support::Cli::Error, "Head must be an exact SHA-1" unless head.match?(/\A[0-9a-f]{40}\z/)
25
25
  params.merge!("head" => head, "candidate_generation" => integer_option(options, :candidate_generation, usage))
26
- bytes = input_bytes(require_option(options, :bundle, usage), limit: 64 * 1024 * 1024)
26
+ bytes = input_bytes(require_option(options, :bundle, usage), limit: Ace::Assign::Authority::CandidateTransfer::MAX_BYTES)
27
27
  parts = [bytes]
28
28
  emit_json(upload_call(client, "submit_candidate", params, mutation, parts, :candidate))
29
29
  end
@@ -14,8 +14,12 @@ module Ace
14
14
  MAX_TOTAL_BYTES = 256 * 1024
15
15
  MAX_ARTIFACTS = 16
16
16
  ID = /\A[a-zA-Z0-9][a-zA-Z0-9_.-]{0,127}\z/
17
+ MAX_RELEASE_ARTIFACT_BYTES = 64 * 1024 * 1024
17
18
  ROLES = {"service" => "executor", "review" => "reviewer",
18
- "result" => "worker"}.freeze
19
+ "result" => "worker", "release-artifact" => "worker"}.freeze
20
+ REPORT_LIMITS = [MAX_ARTIFACT_BYTES, MAX_TOTAL_BYTES, MAX_ARTIFACTS].freeze
21
+ LIMITS = {"service" => REPORT_LIMITS, "review" => REPORT_LIMITS, "result" => REPORT_LIMITS,
22
+ "release-artifact" => [MAX_RELEASE_ARTIFACT_BYTES, MAX_RELEASE_ARTIFACT_BYTES, 1].freeze}.freeze
19
23
  DESCRIPTOR_FIELDS = %w[version artifact_id kind project_id assignment_id attempt_id peer_uid role
20
24
  binding_digest sha256 bytes admitted_at admitted_after_event_digest request_id_or_event_id
21
25
  candidate_generation_or_claim_generation].freeze
@@ -27,7 +31,8 @@ module Ace
27
31
  %w[artifact_id project_id assignment_id attempt_id request_id_or_event_id].all? { |key| descriptor[key].is_a?(String) && descriptor[key].match?(ID) } &&
28
32
  %w[binding_digest sha256 admitted_after_event_digest].all? { |key| descriptor[key].is_a?(String) && descriptor[key].match?(/\A[0-9a-f]{64}\z/) } &&
29
33
  descriptor["peer_uid"].is_a?(Integer) && descriptor["peer_uid"] >= 0 &&
30
- descriptor["bytes"].is_a?(Integer) && descriptor["bytes"].between?(0, MAX_ARTIFACT_BYTES) &&
34
+ descriptor["bytes"].is_a?(Integer) && descriptor["bytes"].between?(descriptor["kind"] == "release-artifact" ? 1 : 0,
35
+ LIMITS.fetch(descriptor["kind"]).first) &&
31
36
  descriptor["candidate_generation_or_claim_generation"].is_a?(Integer) && descriptor["candidate_generation_or_claim_generation"] >= 0 &&
32
37
  descriptor["admitted_at"].is_a?(String)
33
38
  Time.iso8601(descriptor.fetch("admitted_at"))
@@ -46,13 +51,15 @@ module Ace
46
51
  def import_plan(kind:, project_id:, assignment_id:, attempt_id:, peer_uid:, binding:,
47
52
  request_id_or_event_id:, generation:, admitted_after_event_digest:, artifacts:)
48
53
  role = ROLES.fetch(kind) { raise ArgumentError, "invalid evidence kind" }
54
+ max_each, max_total, max_count = LIMITS.fetch(kind)
55
+ minimum = kind == "release-artifact" ? 1 : 0
49
56
  ids = [project_id, assignment_id, attempt_id, request_id_or_event_id]
50
57
  unless ids.all? { |id| id.is_a?(String) && id.match?(ID) } && binding.is_a?(Hash) &&
51
58
  admitted_after_event_digest.is_a?(String) && admitted_after_event_digest.match?(/\A[0-9a-f]{64}\z/) &&
52
59
  peer_uid.is_a?(Integer) && peer_uid >= 0 && generation.is_a?(Integer) && generation >= 0 &&
53
- artifacts.is_a?(Array) && !artifacts.empty? && artifacts.length <= MAX_ARTIFACTS &&
54
- artifacts.all? { |bytes| bytes.is_a?(String) && bytes.bytesize <= MAX_ARTIFACT_BYTES } &&
55
- artifacts.sum(&:bytesize) <= MAX_TOTAL_BYTES
60
+ artifacts.is_a?(Array) && !artifacts.empty? && artifacts.length <= max_count &&
61
+ artifacts.all? { |bytes| bytes.is_a?(String) && bytes.bytesize.between?(minimum, max_each) } &&
62
+ artifacts.sum(&:bytesize) <= max_total
56
63
  raise ArgumentError, "invalid or oversized canonical evidence import"
57
64
  end
58
65
  events = []
@@ -85,7 +92,11 @@ module Ace
85
92
  verified_bytes(reference, events: events, kind: kind, project_id: project_id,
86
93
  assignment_id: assignment_id, attempt_id: attempt_id, peer_uid: peer_uid, binding: binding,
87
94
  request_id_or_event_id: request_id_or_event_id, generation: generation) do |path|
88
- @journal.blob(path, commit: commit)
95
+ if kind == "release-artifact"
96
+ @journal.bounded_blob(path, commit: commit, max_bytes: MAX_RELEASE_ARTIFACT_BYTES)
97
+ else
98
+ @journal.blob(path, commit: commit)
99
+ end
89
100
  end
90
101
  end
91
102
 
@@ -102,7 +113,13 @@ module Ace
102
113
  verified_bytes(reference, events: events, kind: kind, project_id: project_id,
103
114
  assignment_id: assignment_id, attempt_id: attempt_id, peer_uid: peer_uid, binding: binding,
104
115
  request_id_or_event_id: request_id_or_event_id, generation: generation) do |path|
105
- blobs.key?(path) ? blobs.fetch(path) : @journal.blob(path, commit: commit)
116
+ if blobs.key?(path)
117
+ blobs.fetch(path)
118
+ elsif kind == "release-artifact"
119
+ @journal.bounded_blob(path, commit: commit, max_bytes: MAX_RELEASE_ARTIFACT_BYTES)
120
+ else
121
+ @journal.blob(path, commit: commit)
122
+ end
106
123
  end
107
124
  end
108
125
 
@@ -137,7 +154,7 @@ module Ace
137
154
  end
138
155
  Time.iso8601(descriptor.fetch("admitted_at"))
139
156
  bytes = yield(reference.fetch("ref"))
140
- unavailable! unless bytes.is_a?(String) && bytes.bytesize <= MAX_ARTIFACT_BYTES && bytes.bytesize == descriptor["bytes"] &&
157
+ unavailable! unless bytes.is_a?(String) && bytes.bytesize <= LIMITS.fetch(kind).first && bytes.bytesize == descriptor["bytes"] &&
141
158
  Digest::SHA256.hexdigest(bytes) == descriptor["sha256"]
142
159
  bytes
143
160
  rescue ArgumentError, KeyError, TypeError