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.
- checksums.yaml +4 -4
- data/.ace-defaults/assign/catalog/composition-rules.yml +0 -0
- data/.ace-defaults/assign/catalog/recipes/batch-tasks.recipe.yml +0 -0
- data/.ace-defaults/assign/catalog/recipes/documentation.recipe.yml +0 -0
- data/.ace-defaults/assign/catalog/recipes/fix-and-review.recipe.yml +0 -0
- data/.ace-defaults/assign/catalog/recipes/implement-simple.recipe.yml +0 -0
- data/.ace-defaults/assign/catalog/recipes/implement-with-pr.recipe.yml +0 -0
- data/.ace-defaults/assign/catalog/recipes/release-only.recipe.yml +0 -0
- data/.ace-defaults/assign/catalog/steps/apply-feedback.step.yml +0 -0
- data/.ace-defaults/assign/catalog/steps/commit.step.yml +0 -0
- data/.ace-defaults/assign/catalog/steps/create-retro.step.yml +0 -0
- data/.ace-defaults/assign/catalog/steps/fix-tests.step.yml +0 -0
- data/.ace-defaults/assign/catalog/steps/lint.step.yml +0 -0
- data/.ace-defaults/assign/catalog/steps/mark-task-done.step.yml +0 -0
- data/.ace-defaults/assign/catalog/steps/onboard-base.step.yml +0 -0
- data/.ace-defaults/assign/catalog/steps/onboard.step.yml +0 -0
- data/.ace-defaults/assign/catalog/steps/plan-task.step.yml +0 -0
- data/.ace-defaults/assign/catalog/steps/pre-commit-review.step.yml +0 -0
- data/.ace-defaults/assign/catalog/steps/push-to-remote.step.yml +0 -0
- data/.ace-defaults/assign/catalog/steps/rebase-with-main.step.yml +0 -0
- data/.ace-defaults/assign/catalog/steps/record-demo.step.yml +0 -0
- data/.ace-defaults/assign/catalog/steps/reflect-and-refactor.step.yml +0 -0
- data/.ace-defaults/assign/catalog/steps/reorganize-commits.step.yml +0 -0
- data/.ace-defaults/assign/catalog/steps/research.step.yml +0 -0
- data/.ace-defaults/assign/catalog/steps/review-pr.step.yml +0 -0
- data/.ace-defaults/assign/catalog/steps/security-audit.step.yml +0 -0
- data/.ace-defaults/assign/catalog/steps/split-subtree-root.step.yml +0 -0
- data/.ace-defaults/assign/catalog/steps/squash-changelog.step.yml +0 -0
- data/.ace-defaults/assign/catalog/steps/task-load.step.yml +0 -0
- data/.ace-defaults/assign/catalog/steps/update-docs.step.yml +0 -0
- data/.ace-defaults/assign/catalog/steps/verify-test-suite.step.yml +0 -0
- data/.ace-defaults/assign/catalog/steps/verify-test.step.yml +0 -0
- data/.ace-defaults/assign/catalog/steps/work-on-task.step.yml +0 -0
- 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/quick-implement.yml +0 -0
- data/.ace-defaults/assign/presets/release-only.yml +0 -0
- data/.ace-defaults/assign/presets/supervise-delivery.yml +6 -4
- data/.ace-defaults/assign/presets/work-on-docs.yml +0 -0
- data/.ace-defaults/assign/presets/work-on-task-in-place.yml +0 -0
- data/.ace-defaults/nav/protocols/skill-sources/ace-assign.yml +0 -0
- data/.ace-defaults/nav/protocols/wfi-sources/ace-assign.yml +0 -0
- data/CHANGELOG.md +17 -0
- data/Rakefile +0 -0
- data/docs/demo/canonical-skill-source.gif +0 -0
- data/docs/demo/fork-provider.cast +0 -0
- data/docs/demo/fork-provider.gif +0 -0
- data/docs/demo/fork-provider.recording.json +0 -0
- data/docs/demo/fork-provider.tape.yml +0 -0
- data/docs/getting-started.md +0 -0
- data/docs/handbook.md +0 -0
- data/handbook/guides/fork-context.g.md +0 -0
- data/handbook/skills/as-assign-add-task/SKILL.md +0 -0
- data/handbook/skills/as-assign-compose/SKILL.md +0 -0
- data/handbook/skills/as-assign-create/SKILL.md +0 -0
- data/handbook/skills/as-assign-drive/SKILL.md +0 -0
- data/handbook/skills/as-assign-prepare/SKILL.md +0 -0
- data/handbook/skills/as-assign-recover-fork/SKILL.md +0 -0
- data/handbook/skills/as-assign-run-in-batches/SKILL.md +0 -0
- data/handbook/skills/as-assign-start/SKILL.md +0 -0
- data/handbook/skills/as-create-retro-internal/SKILL.md +0 -0
- data/handbook/skills/as-mark-task-done-internal/SKILL.md +0 -0
- data/handbook/skills/as-reflect-and-refactor-internal/SKILL.md +0 -0
- data/handbook/skills/as-task-load-internal/SKILL.md +0 -0
- data/handbook/workflow-instructions/assign/add-task.wf.md +0 -0
- data/handbook/workflow-instructions/assign/compose.wf.md +0 -0
- data/handbook/workflow-instructions/assign/create-retro-internal.wf.md +0 -0
- data/handbook/workflow-instructions/assign/create.wf.md +0 -0
- data/handbook/workflow-instructions/assign/mark-task-done-internal.wf.md +0 -0
- data/handbook/workflow-instructions/assign/recover-fork.wf.md +0 -0
- data/handbook/workflow-instructions/assign/reflect-and-refactor-internal.wf.md +0 -0
- data/handbook/workflow-instructions/assign/run-in-batches.wf.md +0 -0
- data/handbook/workflow-instructions/assign/start.wf.md +0 -0
- data/handbook/workflow-instructions/assign/task-load-internal.wf.md +0 -0
- data/handbook/workflow-instructions/assign/verify-test-suite.wf.md +0 -0
- data/lib/ace/assign/atoms/assign_frontmatter_parser.rb +0 -0
- data/lib/ace/assign/atoms/assignment_scope.rb +5 -0
- data/lib/ace/assign/atoms/catalog_loader.rb +0 -0
- data/lib/ace/assign/atoms/composition_rules.rb +0 -0
- data/lib/ace/assign/atoms/number_generator.rb +0 -0
- data/lib/ace/assign/atoms/preset_expander.rb +0 -0
- data/lib/ace/assign/atoms/preset_loader.rb +0 -0
- data/lib/ace/assign/atoms/preset_step_resolver.rb +0 -0
- data/lib/ace/assign/atoms/step_file_parser.rb +0 -0
- data/lib/ace/assign/atoms/step_numbering.rb +0 -0
- data/lib/ace/assign/atoms/step_sorter.rb +0 -0
- data/lib/ace/assign/atoms/tree_formatter.rb +0 -0
- data/lib/ace/assign/authority/assignment_definition.rb +35 -0
- data/lib/ace/assign/authority/candidate_transfer.rb +2 -2
- data/lib/ace/assign/authority/endcap_attempts.rb +1 -1
- 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 +94 -0
- data/lib/ace/assign/authority/ordinary_effect_settlement.rb +118 -0
- data/lib/ace/assign/authority/ordinary_lifecycle.rb +191 -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_results.rb +196 -0
- data/lib/ace/assign/authority/ordinary_server.rb +75 -0
- data/lib/ace/assign/authority/prepared_work.rb +2 -2
- data/lib/ace/assign/authority/protected_assignment_context.rb +3 -3
- 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 +2 -2
- data/lib/ace/assign/cli/commands/add.rb +0 -0
- data/lib/ace/assign/cli/commands/attempt/evidence.rb +2 -2
- data/lib/ace/assign/cli/commands/list.rb +0 -0
- data/lib/ace/assign/cli/commands/retry_cmd.rb +0 -0
- data/lib/ace/assign/models/assignment_info.rb +0 -0
- data/lib/ace/assign/models/queue_state.rb +0 -0
- data/lib/ace/assign/models/step.rb +0 -0
- data/lib/ace/assign/molecules/assignment_discoverer.rb +0 -0
- 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 +141 -34
- data/lib/ace/assign/molecules/installed_journal_read.rb +143 -0
- data/lib/ace/assign/molecules/journal_mutation.rb +5 -3
- data/lib/ace/assign/molecules/lifecycle_exclusion.rb +13 -2
- data/lib/ace/assign/molecules/preset_inferrer.rb +0 -0
- data/lib/ace/assign/molecules/proposal_journal.rb +32 -4
- data/lib/ace/assign/molecules/skill_assign_source_resolver.rb +0 -0
- data/lib/ace/assign/molecules/step_renumberer.rb +0 -0
- data/lib/ace/assign/organisms/attempt_coordinator.rb +58 -36
- data/lib/ace/assign/organisms/ordinary_merge_consumer.rb +5 -5
- data/lib/ace/assign/version.rb +1 -1
- metadata +24 -10
|
@@ -1,129 +1,29 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require "fileutils"
|
|
2
|
+
|
|
4
3
|
require_relative "deployment"
|
|
5
|
-
require_relative "
|
|
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
|
-
#
|
|
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",
|
|
16
|
-
@authority_id, @
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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: "
|
|
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
|