ace-assign 0.63.1 → 0.66.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 (147) hide show
  1. checksums.yaml +4 -4
  2. data/.ace-defaults/assign/catalog/steps/auto-merge.step.yml +11 -0
  3. data/.ace-defaults/assign/catalog/steps/create-pr.step.yml +2 -2
  4. data/.ace-defaults/assign/catalog/steps/mark-pr-ready.step.yml +1 -1
  5. data/.ace-defaults/assign/catalog/steps/release-minor.step.yml +4 -2
  6. data/.ace-defaults/assign/catalog/steps/release.step.yml +4 -2
  7. data/.ace-defaults/assign/catalog/steps/update-pr-desc.step.yml +1 -1
  8. data/.ace-defaults/assign/presets/fix-bug.yml +15 -3
  9. data/.ace-defaults/assign/presets/work-on-task-ace-development.yml +16 -4
  10. data/.ace-defaults/assign/presets/work-on-task-auto-merge.yml +15 -3
  11. data/.ace-defaults/assign/presets/work-on-task.yml +16 -4
  12. data/CHANGELOG.md +62 -0
  13. data/docs/demo/canonical-skill-source.tape.yml +4 -4
  14. data/docs/protected-authority.md +282 -0
  15. data/docs/usage.md +205 -3
  16. data/exe/ace-scope-ready +55 -0
  17. data/handbook/workflow-instructions/assign/drive.wf.md +28 -3
  18. data/handbook/workflow-instructions/assign/prepare.wf.md +2 -2
  19. data/lib/ace/assign/atoms/attempt_state_machine.rb +8 -0
  20. data/lib/ace/assign/atoms/delivery_parameters.rb +19 -0
  21. data/lib/ace/assign/authority/assignment_inventory.rb +290 -0
  22. data/lib/ace/assign/authority/campaign_consumer_policy.rb +100 -0
  23. data/lib/ace/assign/authority/campaign_execution.rb +119 -0
  24. data/lib/ace/assign/authority/campaign_round_transfer.rb +82 -0
  25. data/lib/ace/assign/authority/candidate_transfer.rb +327 -0
  26. data/lib/ace/assign/authority/cleanup_result_protection.rb +53 -0
  27. data/lib/ace/assign/authority/client.rb +257 -0
  28. data/lib/ace/assign/authority/deployment.rb +627 -0
  29. data/lib/ace/assign/authority/deployment_history.rb +146 -0
  30. data/lib/ace/assign/authority/endcap.rb +455 -0
  31. data/lib/ace/assign/authority/endcap_attempts.rb +274 -0
  32. data/lib/ace/assign/authority/endcap_campaign_results.rb +232 -0
  33. data/lib/ace/assign/authority/endcap_campaign_rounds.rb +266 -0
  34. data/lib/ace/assign/authority/endcap_contexts.rb +23 -0
  35. data/lib/ace/assign/authority/endcap_inboxes.rb +37 -0
  36. data/lib/ace/assign/authority/endcap_prepared_work.rb +80 -0
  37. data/lib/ace/assign/authority/endcap_publication.rb +207 -0
  38. data/lib/ace/assign/authority/endcap_results.rb +398 -0
  39. data/lib/ace/assign/authority/endcap_review_requests.rb +183 -0
  40. data/lib/ace/assign/authority/endcap_reviews.rb +130 -0
  41. data/lib/ace/assign/authority/endcap_service_settlement.rb +152 -0
  42. data/lib/ace/assign/authority/endcap_services.rb +598 -0
  43. data/lib/ace/assign/authority/evidence_transfer.rb +67 -0
  44. data/lib/ace/assign/authority/execution_scope_observation.rb +903 -0
  45. data/lib/ace/assign/authority/inbox_context_original.rb +113 -0
  46. data/lib/ace/assign/authority/launch_attempt_consumers.rb +51 -0
  47. data/lib/ace/assign/authority/launch_campaign_registration.rb +214 -0
  48. data/lib/ace/assign/authority/launch_control_channel.rb +313 -0
  49. data/lib/ace/assign/authority/launch_control_exclusions.rb +141 -0
  50. data/lib/ace/assign/authority/launch_driver.rb +393 -0
  51. data/lib/ace/assign/authority/launch_input_inhibition.rb +132 -0
  52. data/lib/ace/assign/authority/launch_lifecycle.rb +997 -0
  53. data/lib/ace/assign/authority/launch_prepared_work.rb +122 -0
  54. data/lib/ace/assign/authority/launch_review.rb +91 -0
  55. data/lib/ace/assign/authority/launch_scope_abort.rb +81 -0
  56. data/lib/ace/assign/authority/launch_scope_admission.rb +215 -0
  57. data/lib/ace/assign/authority/launch_scope_close.rb +146 -0
  58. data/lib/ace/assign/authority/launch_scope_parent.rb +44 -0
  59. data/lib/ace/assign/authority/launch_scope_release.rb +859 -0
  60. data/lib/ace/assign/authority/launch_steering.rb +353 -0
  61. data/lib/ace/assign/authority/launch_stop.rb +254 -0
  62. data/lib/ace/assign/authority/maintenance_workspace_retirement.rb +229 -0
  63. data/lib/ace/assign/authority/original_inbox_selection.rb +41 -0
  64. data/lib/ace/assign/authority/posix_acl.rb +59 -0
  65. data/lib/ace/assign/authority/prepared_input.rb +130 -0
  66. data/lib/ace/assign/authority/prepared_queue.rb +185 -0
  67. data/lib/ace/assign/authority/prepared_task_context.rb +116 -0
  68. data/lib/ace/assign/authority/prepared_work.rb +304 -0
  69. data/lib/ace/assign/authority/prepared_worker.rb +69 -0
  70. data/lib/ace/assign/authority/private_directory.rb +33 -0
  71. data/lib/ace/assign/authority/protected_assignment_context.rb +226 -0
  72. data/lib/ace/assign/authority/readiness_hook.rb +120 -0
  73. data/lib/ace/assign/authority/receipt_transfer.rb +48 -0
  74. data/lib/ace/assign/authority/router.rb +76 -0
  75. data/lib/ace/assign/authority/server.rb +300 -0
  76. data/lib/ace/assign/authority/service_cleanup_dispatch.rb +88 -0
  77. data/lib/ace/assign/authority/service_cleanup_evidence.rb +180 -0
  78. data/lib/ace/assign/authority/service_delivery_evidence.rb +101 -0
  79. data/lib/ace/assign/authority/service_evidence.rb +286 -0
  80. data/lib/ace/assign/authority/service_publication_evidence.rb +110 -0
  81. data/lib/ace/assign/authority/task_context_entry.rb +32 -0
  82. data/lib/ace/assign/authority/transfer_codec.rb +187 -0
  83. data/lib/ace/assign/cli/commands/assignment_target.rb +15 -2
  84. data/lib/ace/assign/cli/commands/attempt/base.rb +49 -0
  85. data/lib/ace/assign/cli/commands/attempt/finish.rb +20 -4
  86. data/lib/ace/assign/cli/commands/attempt/reconcile.rb +11 -0
  87. data/lib/ace/assign/cli/commands/authority/inbox_context_principal.rb +25 -0
  88. data/lib/ace/assign/cli/commands/authority/inbox_context_selection.rb +28 -0
  89. data/lib/ace/assign/cli/commands/authority/launch.rb +97 -0
  90. data/lib/ace/assign/cli/commands/authority/serve.rb +31 -0
  91. data/lib/ace/assign/cli/commands/authority/status.rb +26 -0
  92. data/lib/ace/assign/cli/commands/authority/task_context.rb +31 -0
  93. data/lib/ace/assign/cli/commands/authority/task_context_principal.rb +25 -0
  94. data/lib/ace/assign/cli/commands/authority/task_context_selection.rb +30 -0
  95. data/lib/ace/assign/cli/commands/authority/terminate.rb +29 -0
  96. data/lib/ace/assign/cli/commands/authority/worker.rb +23 -0
  97. data/lib/ace/assign/cli/commands/campaign_export_result.rb +55 -0
  98. data/lib/ace/assign/cli/commands/campaign_record_round.rb +57 -0
  99. data/lib/ace/assign/cli/commands/create.rb +11 -1
  100. data/lib/ace/assign/cli/commands/delivery.rb +107 -0
  101. data/lib/ace/assign/cli/commands/fail.rb +6 -0
  102. data/lib/ace/assign/cli/commands/finish.rb +23 -2
  103. data/lib/ace/assign/cli/commands/fork_session.rb +6 -2
  104. data/lib/ace/assign/cli/commands/inbox_bind.rb +34 -0
  105. data/lib/ace/assign/cli/commands/protected_submission.rb +61 -0
  106. data/lib/ace/assign/cli/commands/resume.rb +38 -0
  107. data/lib/ace/assign/cli/commands/select.rb +2 -0
  108. data/lib/ace/assign/cli/commands/start.rb +2 -0
  109. data/lib/ace/assign/cli/commands/status.rb +4 -2
  110. data/lib/ace/assign/cli/commands/step.rb +3 -1
  111. data/lib/ace/assign/cli/commands/submit_candidate.rb +34 -0
  112. data/lib/ace/assign/cli/commands/submit_result.rb +61 -0
  113. data/lib/ace/assign/cli.rb +56 -4
  114. data/lib/ace/assign/errors.rb +139 -0
  115. data/lib/ace/assign/models/assignment.rb +26 -4
  116. data/lib/ace/assign/models/evidence_event.rb +4 -2
  117. data/lib/ace/assign/molecules/assignment_manager.rb +12 -2
  118. data/lib/ace/assign/molecules/attempt_reconciler.rb +51 -12
  119. data/lib/ace/assign/molecules/canonical_attempt_state.rb +151 -0
  120. data/lib/ace/assign/molecules/canonical_evidence.rb +153 -0
  121. data/lib/ace/assign/molecules/canonical_read_snapshot.rb +374 -0
  122. data/lib/ace/assign/molecules/evidence_journal.rb +922 -91
  123. data/lib/ace/assign/molecules/execution_identity_resolver.rb +49 -9
  124. data/lib/ace/assign/molecules/execution_scope_lineage.rb +345 -0
  125. data/lib/ace/assign/molecules/fork_session_launcher.rb +16 -6
  126. data/lib/ace/assign/molecules/journal_input_inhibition.rb +78 -0
  127. data/lib/ace/assign/molecules/journal_mutation.rb +271 -0
  128. data/lib/ace/assign/molecules/journal_prompt_mutation.rb +241 -0
  129. data/lib/ace/assign/molecules/lifecycle_exclusion.rb +182 -26
  130. data/lib/ace/assign/molecules/original_launch_binding.rb +37 -0
  131. data/lib/ace/assign/molecules/proposal_journal.rb +151 -0
  132. data/lib/ace/assign/molecules/protected_control_exclusion.rb +303 -0
  133. data/lib/ace/assign/molecules/protected_workspace_exclusion.rb +330 -0
  134. data/lib/ace/assign/molecules/protected_workspace_provisioner.rb +164 -0
  135. data/lib/ace/assign/molecules/queue_scanner.rb +26 -11
  136. data/lib/ace/assign/molecules/receipt_verifier.rb +68 -32
  137. data/lib/ace/assign/molecules/step_writer.rb +27 -5
  138. data/lib/ace/assign/molecules/terminal_scope_receipt.rb +88 -0
  139. data/lib/ace/assign/organisms/assignment_executor.rb +10 -4
  140. data/lib/ace/assign/organisms/attempt_coordinator.rb +302 -13
  141. data/lib/ace/assign/organisms/delivery_coordinator.rb +342 -0
  142. data/lib/ace/assign/organisms/prepared_work_builder.rb +194 -0
  143. data/lib/ace/assign/organisms/protected_delivery_coordinator.rb +100 -0
  144. data/lib/ace/assign/organisms/task_assignment_creator.rb +11 -4
  145. data/lib/ace/assign/version.rb +1 -1
  146. data/lib/ace/assign.rb +4 -102
  147. metadata +186 -8
@@ -0,0 +1,187 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "tempfile"
5
+ require "timeout"
6
+ require "ace/runtime/molecules/protected_socket"
7
+ require_relative "private_directory"
8
+
9
+ module Ace
10
+ module Assign
11
+ module Authority
12
+ # Binary framing after the existing authenticated control header. The
13
+ # Server selects purpose from a source handler's fixed operation table,
14
+ # admits a transfer slot before calling, and holds no journal lock here.
15
+ 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
+ artifacts: [256 * 1024, 16, 64 * 1024],
18
+ receipt_artifacts: [272 * 1024, 17, 64 * 1024],
19
+ service_input: [64 * 1024, 1, 64 * 1024]}.freeze
20
+ SHA256 = /\A[0-9a-f]{64}\z/
21
+
22
+ class Input
23
+ def initialize(file, descriptor)
24
+ @file, @descriptor = file, descriptor
25
+ end
26
+
27
+ def bytes(index: 0)
28
+ unless index.is_a?(Integer) && index.between?(0, count - 1)
29
+ raise ArgumentError, "Invalid transferred part index"
30
+ end
31
+ part = @descriptor.fetch("parts").fetch(index)
32
+ offset = @descriptor.fetch("parts").take(index).sum { |entry| entry.fetch("bytes") }
33
+ @file.seek(offset)
34
+ bytes = (@file.read(part.fetch("bytes")) || "").b
35
+ unless bytes.bytesize == part["bytes"] && Digest::SHA256.hexdigest(bytes) == part["sha256"]
36
+ raise AttemptErrors::ReceiptRejected, "Transferred bytes no longer match accepted binding"
37
+ end
38
+ bytes
39
+ end
40
+
41
+ def count
42
+ @descriptor.fetch("parts").length
43
+ end
44
+ end
45
+
46
+ def initialize(root: nil)
47
+ @root = root && PrivateDirectory.verify!(root)
48
+ end
49
+
50
+ # Upload write-EOF is required before invoking the mutation consumer.
51
+ # Persistent launch prompt streams use their fixed framed entry point below.
52
+ def receive(socket, descriptor:, purpose:, deadline:)
53
+ receive_parts(socket, descriptor: descriptor, purpose: purpose, deadline: deadline, eof: true) { |input| yield input }
54
+ end
55
+
56
+ # Fixed authenticated launch_control stream only. A declared exact
57
+ # prompt body is followed by the next bounded control frame; unlike
58
+ # public uploads, this private duplex connection remains writable.
59
+ def receive_launch_prompt(socket, descriptor:, transfer_id:, deadline:)
60
+ deadline = [deadline, Process.clock_gettime(Process::CLOCK_MONOTONIC) + 30].min
61
+ marker = launch_prompt_marker(transfer_id)
62
+ receive_parts(socket, descriptor: descriptor, purpose: :prompt_text, deadline: deadline, eof: false) do |input|
63
+ boundary = Ace::Runtime::Molecules::ProtectedSocket.read(socket, deadline: deadline, limit: 16_384, with_size: true)
64
+ unless boundary.fetch(:data) == marker && boundary.fetch(:bytesize) == (JSON.generate(marker) + "\n").bytesize
65
+ reject!("Private prompt boundary differs")
66
+ end
67
+ yield input
68
+ end
69
+ end
70
+
71
+ def send_launch_prompt(socket, bytes:, descriptor:, transfer_id:, deadline:)
72
+ deadline = [deadline, Process.clock_gettime(Process::CLOCK_MONOTONIC) + 30].min
73
+ marker = launch_prompt_marker(transfer_id)
74
+ send(socket, parts: [bytes], descriptor: descriptor, purpose: :prompt_text, deadline: deadline)
75
+ Ace::Runtime::Molecules::ProtectedSocket.write(socket, marker, deadline: deadline, limit: 16_384)
76
+ end
77
+
78
+ def launch_prompt_marker(transfer_id)
79
+ unless transfer_id.is_a?(String) && transfer_id.match?(/\A[0-9a-f]{32}\z/)
80
+ raise ArgumentError, "invalid private prompt transfer identity"
81
+ end
82
+ {"type" => "launch_prompt_end", "transfer_id" => transfer_id}
83
+ end
84
+ private :launch_prompt_marker
85
+
86
+ def receive_parts(socket, descriptor:, purpose:, deadline:, eof:)
87
+ validate!(descriptor, purpose)
88
+ descriptor = descriptor.merge("parts" => descriptor.fetch("parts").map { |part| part.dup.freeze }.freeze).freeze
89
+ deadline = [deadline, Process.clock_gettime(Process::CLOCK_MONOTONIC) + 30].min
90
+ raise ArgumentError, "receive requires protected source scratch" unless @root
91
+ PrivateDirectory.verify!(@root)
92
+ Tempfile.create(["transfer-", ".bytes"], @root, binmode: true) do |file|
93
+ digest = Digest::SHA256.new
94
+ descriptor.fetch("parts").each do |part|
95
+ part_digest = Digest::SHA256.new
96
+ remaining = part.fetch("bytes")
97
+ while remaining.positive?
98
+ chunk = read(socket, [remaining, 65_536].min, deadline)
99
+ reject!("Transferred bytes ended early") if chunk.nil?
100
+ file.write(chunk)
101
+ digest.update(chunk)
102
+ part_digest.update(chunk)
103
+ remaining -= chunk.bytesize
104
+ end
105
+ reject!("Transferred part digest differs") unless part_digest.hexdigest == part["sha256"]
106
+ end
107
+ reject!("Transferred aggregate digest differs") unless digest.hexdigest == descriptor["sha256"]
108
+ reject!("Transferred bytes exceed declared size") if eof && !read(socket, 1, deadline).nil?
109
+ file.flush
110
+ file.rewind
111
+ yield Input.new(file, descriptor)
112
+ end
113
+ end
114
+ private :receive_parts
115
+
116
+ def descriptor(parts, purpose:)
117
+ reject!("Invalid transfer parts") unless parts.is_a?(Array) && parts.all? { |part| part.is_a?(String) }
118
+ digest = Digest::SHA256.new
119
+ parts.each { |part| digest.update(part.b) }
120
+ result = {"version" => 1, "bytes" => parts.sum(&:bytesize), "sha256" => digest.hexdigest,
121
+ "parts" => parts.map { |part| {"bytes" => part.bytesize, "sha256" => Digest::SHA256.hexdigest(part.b)} }}
122
+ validate!(result, purpose)
123
+ result
124
+ end
125
+
126
+ # The caller writes the bounded JSON descriptor first, then these raw
127
+ # bytes. Both directions share the Server's original monotonic deadline.
128
+ def send(socket, parts:, descriptor:, purpose:, deadline:)
129
+ expected = self.descriptor(parts, purpose: purpose)
130
+ reject!("Export descriptor differs from bytes") unless expected == descriptor
131
+ deadline = [deadline, Process.clock_gettime(Process::CLOCK_MONOTONIC) + 30].min
132
+ parts.each do |part|
133
+ offset = 0
134
+ while offset < part.bytesize
135
+ wait(socket, deadline, write: true)
136
+ written = socket.write_nonblock(part.byteslice(offset, [65_536, part.bytesize - offset].min), exception: false)
137
+ offset += written unless written == :wait_writable
138
+ end
139
+ end
140
+ end
141
+
142
+ def validate!(descriptor, purpose)
143
+ total, count, each = LIMITS.fetch(purpose) { reject!("Unknown source transfer purpose") }
144
+ unless descriptor.is_a?(Hash) && descriptor.keys.sort == %w[bytes parts sha256 version] &&
145
+ descriptor["version"] == 1 && descriptor["bytes"].is_a?(Integer) &&
146
+ descriptor["bytes"].between?(0, total) && descriptor["sha256"].is_a?(String) && descriptor["sha256"].match?(SHA256) &&
147
+ descriptor["parts"].is_a?(Array) && descriptor["parts"].length.between?(1, count) &&
148
+ descriptor["parts"].all? { |part| part.is_a?(Hash) && part.keys.sort == %w[bytes sha256] &&
149
+ part["bytes"].is_a?(Integer) && part["bytes"].between?(0, each) &&
150
+ part["sha256"].is_a?(String) && part["sha256"].match?(SHA256) } &&
151
+ descriptor["bytes"] == descriptor["parts"].sum { |part| part["bytes"] } &&
152
+ (!%i[candidate service_input prompt_text].include?(purpose) || descriptor["bytes"].positive?)
153
+ reject!("Invalid or oversized transfer descriptor")
154
+ end
155
+ if purpose == :receipt_artifacts
156
+ receipt, *evidence = descriptor.fetch("parts")
157
+ unless receipt.fetch("bytes").between?(1, 16 * 1024) && evidence.sum { |part| part.fetch("bytes") } <= 256 * 1024
158
+ reject!("Receipt or evidence exceeds its fixed transfer limit")
159
+ end
160
+ end
161
+ true
162
+ end
163
+
164
+ private
165
+
166
+ def read(socket, count, deadline)
167
+ loop do
168
+ wait(socket, deadline)
169
+ bytes = socket.read_nonblock(count, exception: false)
170
+ return bytes unless bytes == :wait_readable
171
+ end
172
+ end
173
+
174
+ def wait(socket, deadline, write: false)
175
+ remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
176
+ raise Timeout::Error, "Transfer deadline exceeded" unless remaining.positive?
177
+ ready = IO.select(write ? nil : [socket], write ? [socket] : nil, nil, remaining)
178
+ raise Timeout::Error, "Transfer deadline exceeded" unless ready
179
+ end
180
+
181
+ def reject!(message)
182
+ raise AttemptErrors::MalformedTransfer, message
183
+ end
184
+ end
185
+ end
186
+ end
187
+ end
@@ -1,4 +1,5 @@
1
1
  # frozen_string_literal: true
2
+ require_relative "../../authority/protected_assignment_context"
2
3
 
3
4
  module Ace
4
5
  module Assign
@@ -11,11 +12,16 @@ module Ace
11
12
  # - <assignment-id>@<step-number>
12
13
  module AssignmentTarget
13
14
  DEFAULT_TARGET_ENV = "ACE_ASSIGN_DEFAULT_TARGET"
14
- Target = Struct.new(:assignment_id, :scope, keyword_init: true)
15
+ Target = Struct.new(:assignment_id, :scope, :prepared_input, keyword_init: true)
15
16
  View = Struct.new(:assignment, :state, :scoped_state, :active_steps, :next_step, :focus_step, :scope_root, keyword_init: true)
16
17
 
17
18
  private
18
19
 
20
+ def refuse_protected_graph_mutation!(options)
21
+ context = @protected_assignment_context ||= Ace::Assign::Authority::ProtectedAssignmentContext.load
22
+ context.refuse_graph_mutation!(options: options)
23
+ end
24
+
19
25
  def resolve_assignment_target(options)
20
26
  assignment_raw = options[:assignment]
21
27
  explicit_target = unless assignment_raw.nil? || assignment_raw.to_s.strip.empty?
@@ -29,7 +35,13 @@ module Ace
29
35
  "does not match #{DEFAULT_TARGET_ENV}=#{target_identity(env_target)}"
30
36
  end
31
37
 
32
- explicit_target || env_target || Target.new(assignment_id: nil, scope: nil)
38
+ target = explicit_target || env_target || Target.new(assignment_id: nil, scope: nil)
39
+ context = @protected_assignment_context ||= Ace::Assign::Authority::ProtectedAssignmentContext.load
40
+ target.prepared_input = context.resolve(options: options, assignment_id: target.assignment_id, scope: target.scope)
41
+ if target.prepared_input && ![Status, Step, Start, Finish, Fail, Resume].any? { |command| is_a?(command) }
42
+ raise AttemptErrors::EvidenceUnavailable, "protected queue command requires a reviewed new prepared version and attempt"
43
+ end
44
+ target
33
45
  end
34
46
 
35
47
  def parse_assignment_target(raw)
@@ -63,6 +75,7 @@ module Ace
63
75
  end
64
76
 
65
77
  def build_executor_for_target(target)
78
+ return Ace::Assign::Authority::PreparedExecutor.new(target.prepared_input) if target.prepared_input
66
79
  return Organisms::AssignmentExecutor.new unless target.assignment_id
67
80
 
68
81
  manager = Molecules::AssignmentManager.new
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "json"
4
+ require_relative "../../../authority/protected_assignment_context"
4
5
 
5
6
  module Ace
6
7
  module Assign
@@ -13,6 +14,54 @@ module Ace
13
14
  module Base
14
15
  private
15
16
 
17
+ def protected_context(options)
18
+ keys = %i[mapping assignment result head candidate_generation mutation expected_generation inbox_context registration signature]
19
+ keys.each do |key|
20
+ if options.key?(key) && options[key].to_s.strip.empty?
21
+ raise Ace::Support::Cli::Error, "Empty protected --#{key.to_s.tr('_', '-')} selector"
22
+ end
23
+ end
24
+ context = @protected_assignment_context ||= Ace::Assign::Authority::ProtectedAssignmentContext.load
25
+ selected = keys.any? do |key|
26
+ options.key?(key)
27
+ end
28
+ context if context.protected_participant? || selected || context.mapping_hint?
29
+ rescue SecurityError, Ace::Runtime::RuntimeUnavailableError
30
+ protected_transport_unavailable!
31
+ end
32
+
33
+ def protected_attempt_request(context, options, usage)
34
+ params = {"assignment_id" => require_option(options, :assignment, usage),
35
+ "attempt_id" => require_option(options, :attempt, usage),
36
+ "expected_generation" => integer_option(options, :expected_generation, usage)}
37
+ context.verify_attempt_hints!(assignment_id: params.fetch("assignment_id"), attempt_id: params.fetch("attempt_id"))
38
+ [context.client(options: options), params, require_option(options, :mutation, usage)]
39
+ rescue SecurityError, Ace::Runtime::RuntimeUnavailableError
40
+ protected_transport_unavailable!
41
+ end
42
+
43
+ def protected_call(client, operation, params, mutation)
44
+ client.call(operation, params, mutation_id: mutation, timeout: 30).data
45
+ rescue SecurityError, Ace::Runtime::RuntimeUnavailableError
46
+ protected_transport_unavailable!
47
+ end
48
+
49
+ def protected_transport_unavailable!
50
+ raise AttemptErrors::EvidenceUnavailable, "Protected authority outcome unavailable; retain original selectors and mutation for reconciliation"
51
+ end
52
+
53
+ def integer_option(options, key, usage, positive: false)
54
+ value = options[key]
55
+ unless value.is_a?(Integer) || (value.is_a?(String) && value.match?(/\A(?:0|[1-9][0-9]*)\z/))
56
+ raise Ace::Support::Cli::Error, "--#{key.to_s.tr('_', '-')} requires an exact integer: #{usage}"
57
+ end
58
+ number = value.is_a?(Integer) ? value : Integer(value, 10)
59
+ unless positive ? number.positive? : number >= 0
60
+ raise Ace::Support::Cli::Error, "--#{key.to_s.tr('_', '-')} is out of range"
61
+ end
62
+ number
63
+ end
64
+
16
65
  def build_coordinator
17
66
  Organisms::AttemptCoordinator.new
18
67
  end
@@ -8,18 +8,34 @@ module Ace
8
8
  module CLI
9
9
  module Commands
10
10
  module Attempt
11
- # Accept a structured execution receipt and record the outcome.
12
- # Terminal and uncertain attempts refuse new receipts.
11
+ # Protected completion selects an already submitted canonical result.
12
+ # Ordinary local completion accepts its structured receipt file.
13
13
  class Finish < Ace::Support::Cli::Command
14
14
  include Ace::Support::Cli::Base
15
15
  include Attempt::Base
16
16
 
17
- desc "Finish an attempt with a structured execution receipt"
17
+ desc "Finish a protected canonical result or an ordinary local receipt"
18
18
 
19
19
  option :attempt, desc: "Attempt ID"
20
- option :receipt, desc: "Path to the receipt JSON file"
20
+ option :mapping, desc: "Exact installed protected mapping ID"
21
+ option :assignment, desc: "Exact protected assignment ID"
22
+ option :mutation, desc: "Stable original authority mutation ID"
23
+ option :expected_generation, desc: "Original authority mutation generation"
24
+ option :receipt, desc: "Path to the ordinary local receipt JSON file"
25
+ option :result, desc: "Already submitted canonical protected result ID"
26
+ option :head, desc: "Exact canonical candidate head"
27
+ option :candidate_generation, desc: "Original canonical candidate generation"
21
28
 
22
29
  def call(**options)
30
+ if (context = protected_context(options))
31
+ raise Ace::Support::Cli::Error, "Protected finish forbids --receipt" unless options[:receipt].to_s.empty?
32
+ usage = "finish --mapping MAP --assignment ID --attempt ID --result ID --head SHA --candidate-generation N --mutation ID --expected-generation N"
33
+ client, params, mutation = protected_attempt_request(context, options, usage)
34
+ params.merge!("result_id" => require_option(options, :result, usage), "head" => require_option(options, :head, usage),
35
+ "candidate_generation" => integer_option(options, :candidate_generation, usage, positive: true))
36
+ emit_json(protected_call(client, "finish", params, mutation))
37
+ return
38
+ end
23
39
  attempt_id = require_option(options, :attempt, "finish --attempt ID --receipt FILE")
24
40
  receipt = require_option(options, :receipt, "finish --attempt ID --receipt FILE")
25
41
 
@@ -17,9 +17,20 @@ module Ace
17
17
  desc "Reconcile an interrupted or uncertain attempt"
18
18
 
19
19
  option :attempt, desc: "Attempt ID"
20
+ option :mapping, desc: "Exact installed protected mapping ID"
21
+ option :assignment, desc: "Exact protected assignment ID"
22
+ option :mutation, desc: "Stable original authority mutation ID"
23
+ option :expected_generation, desc: "Original authority mutation generation"
20
24
  option :receipt, desc: "Path to the receipt JSON file (required to resolve uncertainty)"
21
25
 
22
26
  def call(**options)
27
+ if (context = protected_context(options))
28
+ raise Ace::Support::Cli::Error, "Protected recovery forbids --receipt" unless options[:receipt].to_s.empty?
29
+ usage = "reconcile --mapping MAP --assignment ID --attempt ID --mutation ID --expected-generation N"
30
+ client, params, mutation = protected_attempt_request(context, options, usage)
31
+ emit_json(protected_call(client, "recover", params, mutation))
32
+ return
33
+ end
23
34
  attempt_id = require_option(options, :attempt, "reconcile --attempt ID [--receipt FILE]")
24
35
  receipt = options[:receipt].to_s.strip
25
36
 
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+ require_relative "../../../authority/prepared_task_context"
3
+
4
+ module Ace
5
+ module Assign
6
+ module CLI
7
+ module Commands
8
+ module Authority
9
+ class InboxContextPrincipal < Ace::Support::Cli::Command
10
+ desc "Classify the actual caller through every retained protected owner"
11
+
12
+ def call(**options)
13
+ raise ArgumentError, "fixed inbox context options differ" unless options.empty?
14
+ consumer = Ace::Assign::Authority::PreparedTaskContext.new(context: @protected_assignment_context)
15
+ $stdout.write(consumer.inbox_principal())
16
+ 0
17
+ rescue ArgumentError, Ace::Runtime::Error, Ace::Assign::Error => error
18
+ raise Ace::Support::Cli::Error, error.message
19
+ end
20
+ end
21
+ end
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+ require_relative "../../../authority/prepared_task_context"
3
+
4
+ module Ace
5
+ module Assign
6
+ module CLI
7
+ module Commands
8
+ module Authority
9
+ class InboxContextSelection < Ace::Support::Cli::Command
10
+ desc "Read the fixed installed inbox context selection"
11
+ option :project, required: true, desc: "Installed project ID"
12
+ option :mapping, required: true, desc: "Installed mapping ID"
13
+ option :inbox_context, required: true, desc: "Installed inbox context ID"
14
+
15
+ def call(**options)
16
+ raise ArgumentError, "fixed inbox context options differ" unless options.keys.sort == %i[inbox_context mapping project]
17
+ consumer = Ace::Assign::Authority::PreparedTaskContext.new(context: @protected_assignment_context)
18
+ $stdout.write(consumer.inbox_selection(**options))
19
+ 0
20
+ rescue ArgumentError, Ace::Runtime::Error, Ace::Assign::Error => error
21
+ raise Ace::Support::Cli::Error, error.message
22
+ end
23
+ end
24
+ end
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,97 @@
1
+ # frozen_string_literal: true
2
+ require "json"
3
+ require_relative "../../../authority/launch_driver"
4
+ module Ace
5
+ module Assign
6
+ module CLI
7
+ module Commands
8
+ module Authority
9
+ class Launch < Ace::Support::Cli::Command
10
+ desc "Launch one mapped worker and retain its original foreground control channel"
11
+ READY_FIELDS = %w[assignment_id attempt_id generation journal_commit mapping_id original_binding_digest type version].freeze
12
+ READY_LIMIT = 16_384
13
+ option :mapping, required: true, desc: "Installed launch mapping ID"
14
+ option :assignment, desc: "Managed assignment ID"
15
+ option :definition, desc: "Retained final prepared assignment definition JSON file"
16
+ option :prepared_bundle, desc: "Retained original prepared input Git bundle"
17
+ option :step, desc: "Assignment subtree scope"
18
+ option :base_head, desc: "Exact base commit SHA"
19
+ option :mutation, desc: "Stable invocation ID for safe replay"
20
+ option :dry_run, type: :boolean, default: false, desc: "Verify installed boundary without mutation or creation"
21
+ def call(**options)
22
+ driver = build_driver(options.fetch(:mapping))
23
+ result = if options[:dry_run]
24
+ driver.preflight
25
+ else
26
+ %i[assignment definition prepared_bundle step base_head].each do |key|
27
+ raise ArgumentError, "Missing --#{key.to_s.tr('_', '-')}" unless options[key].is_a?(String) && !options[key].empty?
28
+ end
29
+ definition = retained_input(options[:definition], 32_768).force_encoding(Encoding::UTF_8)
30
+ raise ArgumentError, "assignment definition is not UTF-8" unless definition.valid_encoding?
31
+ bundle = retained_input(options[:prepared_bundle], Ace::Assign::Authority::CandidateTransfer::MAX_BYTES)
32
+ args = {prepared_bundle: bundle, assignment_id: options[:assignment], definition_bytes: definition,
33
+ scope: options[:step], base_head: options[:base_head]}
34
+ args[:mutation_id] = options[:mutation] if options[:mutation]
35
+ driver.launch(**args)
36
+ end
37
+ if !options[:dry_run] && result["phase"] == "issued"
38
+ retaining_control = true
39
+ announced = false
40
+ driver.serve_control!(state: result) do |ready|
41
+ raise ArgumentError, "original launch readiness was already announced" if announced
42
+ line = readiness_line(ready, result, options)
43
+ $stdout.write(line)
44
+ $stdout.flush
45
+ announced = true
46
+ end
47
+ raise ArgumentError, "original control ended before authenticated readiness" unless announced
48
+ else
49
+ puts JSON.generate(result)
50
+ end
51
+ rescue ArgumentError, SystemCallError, Ace::Runtime::Error, Ace::Assign::Error => error
52
+ raise Ace::Support::Cli::Error, error.message
53
+ ensure
54
+ # Local foreground-loop cancellation is not a stopped/settled proof.
55
+ driver.request_control_cancel if retaining_control
56
+ end
57
+ private
58
+ def retained_input(path, limit)
59
+ File.open(path, File::RDONLY | File::NOFOLLOW | File::NONBLOCK) do |file|
60
+ before = file.stat
61
+ unless before.file? && before.size.between?(1, limit)
62
+ raise ArgumentError, "retained launch input is not a bounded regular file"
63
+ end
64
+ bytes = (file.read(limit + 1) || "").b
65
+ after = file.stat
66
+ identity = ->(stat) { [stat.dev, stat.ino, stat.size, stat.mtime, stat.ctime] }
67
+ unless bytes.bytesize == before.size && bytes.bytesize <= limit && identity.call(before) == identity.call(after)
68
+ raise ArgumentError, "retained launch input changed while reading"
69
+ end
70
+ bytes
71
+ end
72
+ end
73
+
74
+ def readiness_line(ready, result, options)
75
+ unless ready.is_a?(Hash) && ready.keys.all? { |key| key.is_a?(String) } && ready.keys.sort == READY_FIELDS &&
76
+ ready["version"].is_a?(Integer) && ready["version"] == 1 && ready["type"] == "launch_ready" &&
77
+ ready.values_at("mapping_id", "assignment_id", "attempt_id") ==
78
+ [options.fetch(:mapping), options.fetch(:assignment), result.fetch("attempt_id")] &&
79
+ ready["generation"].is_a?(Integer) && ready["generation"].positive? &&
80
+ ready["journal_commit"].is_a?(String) && ready["journal_commit"].match?(/\A(?:[0-9a-f]{40}|[0-9a-f]{64})\z/) &&
81
+ ready["original_binding_digest"].is_a?(String) && ready["original_binding_digest"].match?(/\A[0-9a-f]{64}\z/)
82
+ raise ArgumentError, "original launch readiness is malformed or mismatched"
83
+ end
84
+ line = JSON.generate(ready) + "\n"
85
+ raise ArgumentError, "original launch readiness is oversized" if line.bytesize > READY_LIMIT
86
+ line
87
+ end
88
+
89
+ def build_driver(mapping)
90
+ Ace::Assign::Authority::LaunchDriver.new(mapping_id: mapping)
91
+ end
92
+ end
93
+ end
94
+ end
95
+ end
96
+ end
97
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+ require_relative "../../../authority/server"
3
+ require_relative "../../../authority/router"
4
+ require_relative "../../../authority/launch_lifecycle"
5
+ module Ace
6
+ module Assign
7
+ module CLI
8
+ module Commands
9
+ module Authority
10
+ class Serve < Ace::Support::Cli::Command
11
+ desc "Serve the installed protected assignment authority"
12
+ option :authority, required: true, desc: "Installed authority ID"
13
+ def call(**options)
14
+ deployment = Ace::Assign::Authority::Deployment.load
15
+ history = Ace::Assign::Authority::DeploymentHistory.load
16
+ launch = Ace::Assign::Authority::LaunchLifecycle.new(deployment: deployment, deployment_history: history)
17
+ router = Ace::Assign::Authority::Router.new(launch: launch)
18
+ server = Ace::Assign::Authority::Server.new(authority_id: options.fetch(:authority), deployment: deployment, lifecycle: router)
19
+ signals = %w[INT TERM].to_h { |signal| [signal, Signal.trap(signal) { server.request_stop }] }
20
+ server.serve
21
+ rescue ArgumentError, Ace::Runtime::Error, Ace::Assign::Error => error
22
+ raise Ace::Support::Cli::Error, error.message
23
+ ensure
24
+ signals&.each { |signal, handler| Signal.trap(signal, handler) }
25
+ end
26
+ end
27
+ end
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+ require "json"
3
+ require_relative "../../../authority/client"
4
+ module Ace
5
+ module Assign
6
+ module CLI
7
+ module Commands
8
+ module Authority
9
+ class Status < Ace::Support::Cli::Command
10
+ desc "Inspect canonical protected launch ownership"
11
+ option :mapping, required: true, desc: "Installed launch mapping ID"
12
+ option :assignment, required: true, desc: "Assignment ID"
13
+ option :attempt, required: true, desc: "Attempt ID"
14
+ def call(**options)
15
+ reply = Ace::Assign::Authority::Client.new(mapping_id: options.fetch(:mapping)).call("attempt_status",
16
+ {"assignment_id" => options.fetch(:assignment), "attempt_id" => options.fetch(:attempt), "result_candidate_generation" => nil})
17
+ puts JSON.generate(reply.data)
18
+ rescue ArgumentError, Ace::Runtime::Error, Ace::Assign::Error => error
19
+ raise Ace::Support::Cli::Error, error.message
20
+ end
21
+ end
22
+ end
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+ require_relative "../../../authority/prepared_task_context"
3
+
4
+ module Ace
5
+ module Assign
6
+ module CLI
7
+ module Commands
8
+ module Authority
9
+ class TaskContext < Ace::Support::Cli::Command
10
+ desc "Return the captured original task context through the fixed selected entry"
11
+ option :mapping, required: true, desc: "Installed original mapping ID"
12
+ option :assignment, required: true, desc: "Original assignment ID@SCOPE"
13
+ option :attempt, required: true, desc: "Original protected attempt ID"
14
+ option :task, required: true, desc: "Captured canonical task ID"
15
+
16
+ def call(**options)
17
+ unless options.keys.sort == %i[assignment attempt mapping task]
18
+ raise ArgumentError, "fixed task-context accepts only its four selectors"
19
+ end
20
+ consumer = Ace::Assign::Authority::PreparedTaskContext.new(context: @protected_assignment_context)
21
+ $stdout.write(consumer.call(**options))
22
+ 0
23
+ rescue ArgumentError, Ace::Runtime::Error, Ace::Assign::Error => error
24
+ raise Ace::Support::Cli::Error, error.message
25
+ end
26
+ end
27
+ end
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+ require_relative "../../../authority/prepared_task_context"
3
+
4
+ module Ace
5
+ module Assign
6
+ module CLI
7
+ module Commands
8
+ module Authority
9
+ class TaskContextPrincipal < Ace::Support::Cli::Command
10
+ desc "Classify this actual caller through the installed and retained protected owners"
11
+
12
+ def call(**options)
13
+ raise ArgumentError, "fixed task-context-principal accepts no options" unless options.empty?
14
+ consumer = Ace::Assign::Authority::PreparedTaskContext.new(context: @protected_assignment_context)
15
+ $stdout.write(consumer.principal)
16
+ 0
17
+ rescue ArgumentError, Ace::Runtime::Error, Ace::Assign::Error => error
18
+ raise Ace::Support::Cli::Error, error.message
19
+ end
20
+ end
21
+ end
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+ require_relative "../../../authority/prepared_task_context"
3
+
4
+ module Ace
5
+ module Assign
6
+ module CLI
7
+ module Commands
8
+ module Authority
9
+ class TaskContextSelection < Ace::Support::Cli::Command
10
+ desc "Return the authenticated original task-context entry without captured text"
11
+ option :mapping, required: true, desc: "Installed original mapping ID"
12
+ option :assignment, required: true, desc: "Original assignment ID@SCOPE"
13
+ option :attempt, required: true, desc: "Original protected attempt ID"
14
+
15
+ def call(**options)
16
+ unless options.keys.sort == %i[assignment attempt mapping]
17
+ raise ArgumentError, "fixed task-context-selection accepts only its three selectors"
18
+ end
19
+ consumer = Ace::Assign::Authority::PreparedTaskContext.new(context: @protected_assignment_context)
20
+ $stdout.write(consumer.selection(**options))
21
+ 0
22
+ rescue ArgumentError, Ace::Runtime::Error, Ace::Assign::Error => error
23
+ raise Ace::Support::Cli::Error, error.message
24
+ end
25
+ end
26
+ end
27
+ end
28
+ end
29
+ end
30
+ end