ace-assign 0.68.0 → 0.70.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 (129) hide show
  1. checksums.yaml +4 -4
  2. data/.ace-defaults/assign/catalog/composition-rules.yml +0 -0
  3. data/.ace-defaults/assign/catalog/recipes/batch-tasks.recipe.yml +0 -0
  4. data/.ace-defaults/assign/catalog/recipes/documentation.recipe.yml +0 -0
  5. data/.ace-defaults/assign/catalog/recipes/fix-and-review.recipe.yml +0 -0
  6. data/.ace-defaults/assign/catalog/recipes/implement-simple.recipe.yml +0 -0
  7. data/.ace-defaults/assign/catalog/recipes/implement-with-pr.recipe.yml +0 -0
  8. data/.ace-defaults/assign/catalog/recipes/release-only.recipe.yml +0 -0
  9. data/.ace-defaults/assign/catalog/steps/apply-feedback.step.yml +0 -0
  10. data/.ace-defaults/assign/catalog/steps/commit.step.yml +0 -0
  11. data/.ace-defaults/assign/catalog/steps/create-retro.step.yml +0 -0
  12. data/.ace-defaults/assign/catalog/steps/fix-tests.step.yml +0 -0
  13. data/.ace-defaults/assign/catalog/steps/lint.step.yml +0 -0
  14. data/.ace-defaults/assign/catalog/steps/mark-task-done.step.yml +0 -0
  15. data/.ace-defaults/assign/catalog/steps/onboard-base.step.yml +0 -0
  16. data/.ace-defaults/assign/catalog/steps/onboard.step.yml +0 -0
  17. data/.ace-defaults/assign/catalog/steps/plan-task.step.yml +0 -0
  18. data/.ace-defaults/assign/catalog/steps/pre-commit-review.step.yml +0 -0
  19. data/.ace-defaults/assign/catalog/steps/push-to-remote.step.yml +0 -0
  20. data/.ace-defaults/assign/catalog/steps/rebase-with-main.step.yml +0 -0
  21. data/.ace-defaults/assign/catalog/steps/record-demo.step.yml +0 -0
  22. data/.ace-defaults/assign/catalog/steps/reflect-and-refactor.step.yml +0 -0
  23. data/.ace-defaults/assign/catalog/steps/reorganize-commits.step.yml +0 -0
  24. data/.ace-defaults/assign/catalog/steps/research.step.yml +0 -0
  25. data/.ace-defaults/assign/catalog/steps/review-pr.step.yml +0 -0
  26. data/.ace-defaults/assign/catalog/steps/security-audit.step.yml +0 -0
  27. data/.ace-defaults/assign/catalog/steps/split-subtree-root.step.yml +0 -0
  28. data/.ace-defaults/assign/catalog/steps/squash-changelog.step.yml +0 -0
  29. data/.ace-defaults/assign/catalog/steps/task-load.step.yml +0 -0
  30. data/.ace-defaults/assign/catalog/steps/update-docs.step.yml +0 -0
  31. data/.ace-defaults/assign/catalog/steps/verify-test-suite.step.yml +0 -0
  32. data/.ace-defaults/assign/catalog/steps/verify-test.step.yml +0 -0
  33. data/.ace-defaults/assign/catalog/steps/work-on-task.step.yml +0 -0
  34. data/.ace-defaults/assign/presets/deliver-release.yml +11 -8
  35. data/.ace-defaults/assign/presets/integrate-batch.yml +10 -5
  36. data/.ace-defaults/assign/presets/quick-implement.yml +0 -0
  37. data/.ace-defaults/assign/presets/release-only.yml +0 -0
  38. data/.ace-defaults/assign/presets/supervise-delivery.yml +6 -4
  39. data/.ace-defaults/assign/presets/work-on-docs.yml +0 -0
  40. data/.ace-defaults/assign/presets/work-on-task-in-place.yml +0 -0
  41. data/.ace-defaults/nav/protocols/skill-sources/ace-assign.yml +0 -0
  42. data/.ace-defaults/nav/protocols/wfi-sources/ace-assign.yml +0 -0
  43. data/CHANGELOG.md +17 -0
  44. data/Rakefile +0 -0
  45. data/docs/demo/canonical-skill-source.gif +0 -0
  46. data/docs/demo/fork-provider.cast +0 -0
  47. data/docs/demo/fork-provider.gif +0 -0
  48. data/docs/demo/fork-provider.recording.json +0 -0
  49. data/docs/demo/fork-provider.tape.yml +0 -0
  50. data/docs/getting-started.md +0 -0
  51. data/docs/handbook.md +0 -0
  52. data/handbook/guides/fork-context.g.md +0 -0
  53. data/handbook/skills/as-assign-add-task/SKILL.md +0 -0
  54. data/handbook/skills/as-assign-compose/SKILL.md +0 -0
  55. data/handbook/skills/as-assign-create/SKILL.md +0 -0
  56. data/handbook/skills/as-assign-drive/SKILL.md +0 -0
  57. data/handbook/skills/as-assign-prepare/SKILL.md +0 -0
  58. data/handbook/skills/as-assign-recover-fork/SKILL.md +0 -0
  59. data/handbook/skills/as-assign-run-in-batches/SKILL.md +0 -0
  60. data/handbook/skills/as-assign-start/SKILL.md +0 -0
  61. data/handbook/skills/as-create-retro-internal/SKILL.md +0 -0
  62. data/handbook/skills/as-mark-task-done-internal/SKILL.md +0 -0
  63. data/handbook/skills/as-reflect-and-refactor-internal/SKILL.md +0 -0
  64. data/handbook/skills/as-task-load-internal/SKILL.md +0 -0
  65. data/handbook/workflow-instructions/assign/add-task.wf.md +0 -0
  66. data/handbook/workflow-instructions/assign/compose.wf.md +0 -0
  67. data/handbook/workflow-instructions/assign/create-retro-internal.wf.md +0 -0
  68. data/handbook/workflow-instructions/assign/create.wf.md +0 -0
  69. data/handbook/workflow-instructions/assign/mark-task-done-internal.wf.md +0 -0
  70. data/handbook/workflow-instructions/assign/recover-fork.wf.md +0 -0
  71. data/handbook/workflow-instructions/assign/reflect-and-refactor-internal.wf.md +0 -0
  72. data/handbook/workflow-instructions/assign/run-in-batches.wf.md +0 -0
  73. data/handbook/workflow-instructions/assign/start.wf.md +0 -0
  74. data/handbook/workflow-instructions/assign/task-load-internal.wf.md +0 -0
  75. data/handbook/workflow-instructions/assign/verify-test-suite.wf.md +0 -0
  76. data/lib/ace/assign/atoms/assign_frontmatter_parser.rb +0 -0
  77. data/lib/ace/assign/atoms/assignment_scope.rb +5 -0
  78. data/lib/ace/assign/atoms/catalog_loader.rb +0 -0
  79. data/lib/ace/assign/atoms/composition_rules.rb +0 -0
  80. data/lib/ace/assign/atoms/number_generator.rb +0 -0
  81. data/lib/ace/assign/atoms/preset_expander.rb +0 -0
  82. data/lib/ace/assign/atoms/preset_loader.rb +0 -0
  83. data/lib/ace/assign/atoms/preset_step_resolver.rb +0 -0
  84. data/lib/ace/assign/atoms/step_file_parser.rb +0 -0
  85. data/lib/ace/assign/atoms/step_numbering.rb +0 -0
  86. data/lib/ace/assign/atoms/step_sorter.rb +0 -0
  87. data/lib/ace/assign/atoms/tree_formatter.rb +0 -0
  88. data/lib/ace/assign/authority/assignment_definition.rb +35 -0
  89. data/lib/ace/assign/authority/candidate_transfer.rb +2 -2
  90. data/lib/ace/assign/authority/endcap_attempts.rb +1 -1
  91. data/lib/ace/assign/authority/launch_control_exclusions.rb +3 -17
  92. data/lib/ace/assign/authority/ordinary_attempt_start.rb +137 -0
  93. data/lib/ace/assign/authority/ordinary_client.rb +70 -0
  94. data/lib/ace/assign/authority/ordinary_completion.rb +94 -0
  95. data/lib/ace/assign/authority/ordinary_effect_settlement.rb +118 -0
  96. data/lib/ace/assign/authority/ordinary_lifecycle.rb +191 -0
  97. data/lib/ace/assign/authority/ordinary_peer.rb +54 -0
  98. data/lib/ace/assign/authority/ordinary_progress.rb +162 -0
  99. data/lib/ace/assign/authority/ordinary_queue.rb +115 -0
  100. data/lib/ace/assign/authority/ordinary_results.rb +196 -0
  101. data/lib/ace/assign/authority/ordinary_server.rb +75 -0
  102. data/lib/ace/assign/authority/prepared_work.rb +2 -2
  103. data/lib/ace/assign/authority/protected_assignment_context.rb +3 -3
  104. data/lib/ace/assign/authority/router.rb +11 -6
  105. data/lib/ace/assign/authority/server.rb +11 -180
  106. data/lib/ace/assign/authority/socket_server.rb +255 -0
  107. data/lib/ace/assign/authority/transfer_codec.rb +2 -2
  108. data/lib/ace/assign/cli/commands/add.rb +0 -0
  109. data/lib/ace/assign/cli/commands/attempt/evidence.rb +2 -2
  110. data/lib/ace/assign/cli/commands/list.rb +0 -0
  111. data/lib/ace/assign/cli/commands/retry_cmd.rb +0 -0
  112. data/lib/ace/assign/models/assignment_info.rb +0 -0
  113. data/lib/ace/assign/models/queue_state.rb +0 -0
  114. data/lib/ace/assign/models/step.rb +0 -0
  115. data/lib/ace/assign/molecules/assignment_discoverer.rb +0 -0
  116. data/lib/ace/assign/molecules/canonical_read_commands.rb +84 -0
  117. data/lib/ace/assign/molecules/canonical_read_snapshot.rb +2 -63
  118. data/lib/ace/assign/molecules/evidence_journal.rb +141 -34
  119. data/lib/ace/assign/molecules/installed_journal_read.rb +143 -0
  120. data/lib/ace/assign/molecules/journal_mutation.rb +5 -3
  121. data/lib/ace/assign/molecules/lifecycle_exclusion.rb +13 -2
  122. data/lib/ace/assign/molecules/preset_inferrer.rb +0 -0
  123. data/lib/ace/assign/molecules/proposal_journal.rb +32 -4
  124. data/lib/ace/assign/molecules/skill_assign_source_resolver.rb +0 -0
  125. data/lib/ace/assign/molecules/step_renumberer.rb +0 -0
  126. data/lib/ace/assign/organisms/attempt_coordinator.rb +58 -36
  127. data/lib/ace/assign/organisms/ordinary_merge_consumer.rb +5 -5
  128. data/lib/ace/assign/version.rb +1 -1
  129. metadata +24 -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
@@ -16,7 +16,7 @@ module Ace
16
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
17
  artifacts: [256 * 1024, 16, 64 * 1024],
18
18
  receipt_artifacts: [272 * 1024, 17, 64 * 1024],
19
- service_input: [64 * 1024, 1, 64 * 1024]}.freeze
19
+ service_input: [64 * 1024, 1, 64 * 1024], assignment_snapshot: [64 * 1024 * 1024, 1, 64 * 1024 * 1024]}.freeze
20
20
  SHA256 = /\A[0-9a-f]{64}\z/
21
21
 
22
22
  class Input
@@ -149,7 +149,7 @@ module Ace
149
149
  part["bytes"].is_a?(Integer) && part["bytes"].between?(0, each) &&
150
150
  part["sha256"].is_a?(String) && part["sha256"].match?(SHA256) } &&
151
151
  descriptor["bytes"] == descriptor["parts"].sum { |part| part["bytes"] } &&
152
- (!%i[candidate service_input prompt_text].include?(purpose) || descriptor["bytes"].positive?)
152
+ (!%i[candidate service_input prompt_text assignment_snapshot].include?(purpose) || descriptor["bytes"].positive?)
153
153
  reject!("Invalid or oversized transfer descriptor")
154
154
  end
155
155
  if purpose == :receipt_artifacts
File without changes
@@ -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)
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
@@ -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