ace-assign 0.70.0 → 0.73.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 (181) hide show
  1. checksums.yaml +4 -4
  2. data/.ace-defaults/assign/catalog/steps/auto-merge.step.yml +8 -8
  3. data/.ace-defaults/assign/catalog/steps/create-pr.step.yml +1 -1
  4. data/.ace-defaults/assign/catalog/steps/mark-pr-ready.step.yml +1 -1
  5. data/.ace-defaults/assign/config.yml +0 -13
  6. data/.ace-defaults/assign/presets/fix-bug.yml +1 -1
  7. data/.ace-defaults/assign/presets/work-on-task-ace-development.yml +1 -1
  8. data/.ace-defaults/assign/presets/work-on-task-auto-merge.yml +1 -1
  9. data/.ace-defaults/assign/presets/work-on-task.yml +1 -1
  10. data/CHANGELOG.md +3 -2099
  11. data/README.md +58 -31
  12. data/docs/exit-codes.md +36 -44
  13. data/docs/getting-started.md +9 -4
  14. data/docs/usage.md +129 -522
  15. data/handbook/skills/as-assign-drive/SKILL.md +1 -1
  16. data/handbook/workflow-instructions/assign/drive.wf.md +47 -118
  17. data/handbook/workflow-instructions/assign/prepare.wf.md +1 -1
  18. data/handbook/workflow-instructions/assign/verify-test-suite.wf.md +8 -2
  19. data/lib/ace/assign/cli/commands/assignment_target.rb +2 -15
  20. data/lib/ace/assign/cli/commands/create.rb +1 -8
  21. data/lib/ace/assign/cli/commands/fail.rb +0 -6
  22. data/lib/ace/assign/cli/commands/finish.rb +2 -23
  23. data/lib/ace/assign/cli/commands/fork_session.rb +0 -1
  24. data/lib/ace/assign/cli/commands/select.rb +0 -1
  25. data/lib/ace/assign/cli/commands/start.rb +0 -2
  26. data/lib/ace/assign/cli/commands/status.rb +1 -31
  27. data/lib/ace/assign/cli/commands/step.rb +1 -3
  28. data/lib/ace/assign/cli.rb +1 -85
  29. data/lib/ace/assign/errors.rb +8 -80
  30. data/lib/ace/assign/models/assignment.rb +5 -28
  31. data/lib/ace/assign/molecules/assignment_manager.rb +0 -26
  32. data/lib/ace/assign/molecules/fork_session_launcher.rb +4 -12
  33. data/lib/ace/assign/molecules/lifecycle_exclusion.rb +15 -112
  34. data/lib/ace/assign/molecules/runtime_control_surface_runner.rb +2 -1
  35. data/lib/ace/assign/organisms/assignment_executor.rb +0 -6
  36. data/lib/ace/assign/organisms/task_assignment_creator.rb +3 -8
  37. data/lib/ace/assign/version.rb +1 -1
  38. data/lib/ace/assign.rb +0 -6
  39. metadata +4 -161
  40. data/.ace-defaults/assign/presets/close-delivery.yml +0 -57
  41. data/.ace-defaults/assign/presets/deliver-release.yml +0 -60
  42. data/.ace-defaults/assign/presets/integrate-batch.yml +0 -72
  43. data/.ace-defaults/assign/presets/supervise-delivery.yml +0 -69
  44. data/docs/protected-authority.md +0 -282
  45. data/exe/ace-scope-ready +0 -55
  46. data/lib/ace/assign/atoms/assignment_scope.rb +0 -58
  47. data/lib/ace/assign/atoms/attempt_state_machine.rb +0 -84
  48. data/lib/ace/assign/atoms/delivery_parameters.rb +0 -19
  49. data/lib/ace/assign/atoms/evidence_digest.rb +0 -78
  50. data/lib/ace/assign/atoms/fixed_merge_schemas.rb +0 -398
  51. data/lib/ace/assign/authority/assignment_definition.rb +0 -35
  52. data/lib/ace/assign/authority/assignment_inventory.rb +0 -290
  53. data/lib/ace/assign/authority/campaign_consumer_policy.rb +0 -100
  54. data/lib/ace/assign/authority/campaign_execution.rb +0 -119
  55. data/lib/ace/assign/authority/campaign_round_transfer.rb +0 -82
  56. data/lib/ace/assign/authority/candidate_transfer.rb +0 -327
  57. data/lib/ace/assign/authority/cleanup_result_protection.rb +0 -53
  58. data/lib/ace/assign/authority/client.rb +0 -257
  59. data/lib/ace/assign/authority/deployment.rb +0 -627
  60. data/lib/ace/assign/authority/deployment_history.rb +0 -146
  61. data/lib/ace/assign/authority/endcap.rb +0 -455
  62. data/lib/ace/assign/authority/endcap_attempts.rb +0 -274
  63. data/lib/ace/assign/authority/endcap_campaign_results.rb +0 -232
  64. data/lib/ace/assign/authority/endcap_campaign_rounds.rb +0 -266
  65. data/lib/ace/assign/authority/endcap_contexts.rb +0 -23
  66. data/lib/ace/assign/authority/endcap_inboxes.rb +0 -37
  67. data/lib/ace/assign/authority/endcap_prepared_work.rb +0 -80
  68. data/lib/ace/assign/authority/endcap_publication.rb +0 -207
  69. data/lib/ace/assign/authority/endcap_results.rb +0 -398
  70. data/lib/ace/assign/authority/endcap_review_requests.rb +0 -183
  71. data/lib/ace/assign/authority/endcap_reviews.rb +0 -130
  72. data/lib/ace/assign/authority/endcap_service_settlement.rb +0 -152
  73. data/lib/ace/assign/authority/endcap_services.rb +0 -598
  74. data/lib/ace/assign/authority/evidence_transfer.rb +0 -67
  75. data/lib/ace/assign/authority/execution_scope_observation.rb +0 -903
  76. data/lib/ace/assign/authority/inbox_context_original.rb +0 -113
  77. data/lib/ace/assign/authority/launch_attempt_consumers.rb +0 -51
  78. data/lib/ace/assign/authority/launch_campaign_registration.rb +0 -214
  79. data/lib/ace/assign/authority/launch_control_channel.rb +0 -313
  80. data/lib/ace/assign/authority/launch_control_exclusions.rb +0 -127
  81. data/lib/ace/assign/authority/launch_driver.rb +0 -393
  82. data/lib/ace/assign/authority/launch_input_inhibition.rb +0 -132
  83. data/lib/ace/assign/authority/launch_lifecycle.rb +0 -997
  84. data/lib/ace/assign/authority/launch_prepared_work.rb +0 -122
  85. data/lib/ace/assign/authority/launch_review.rb +0 -91
  86. data/lib/ace/assign/authority/launch_scope_abort.rb +0 -81
  87. data/lib/ace/assign/authority/launch_scope_admission.rb +0 -215
  88. data/lib/ace/assign/authority/launch_scope_close.rb +0 -146
  89. data/lib/ace/assign/authority/launch_scope_parent.rb +0 -44
  90. data/lib/ace/assign/authority/launch_scope_release.rb +0 -859
  91. data/lib/ace/assign/authority/launch_steering.rb +0 -353
  92. data/lib/ace/assign/authority/launch_stop.rb +0 -254
  93. data/lib/ace/assign/authority/maintenance_workspace_retirement.rb +0 -229
  94. data/lib/ace/assign/authority/ordinary_attempt_start.rb +0 -137
  95. data/lib/ace/assign/authority/ordinary_client.rb +0 -70
  96. data/lib/ace/assign/authority/ordinary_completion.rb +0 -94
  97. data/lib/ace/assign/authority/ordinary_effect_settlement.rb +0 -118
  98. data/lib/ace/assign/authority/ordinary_lifecycle.rb +0 -191
  99. data/lib/ace/assign/authority/ordinary_peer.rb +0 -54
  100. data/lib/ace/assign/authority/ordinary_progress.rb +0 -162
  101. data/lib/ace/assign/authority/ordinary_queue.rb +0 -115
  102. data/lib/ace/assign/authority/ordinary_results.rb +0 -196
  103. data/lib/ace/assign/authority/ordinary_server.rb +0 -75
  104. data/lib/ace/assign/authority/original_inbox_selection.rb +0 -41
  105. data/lib/ace/assign/authority/posix_acl.rb +0 -59
  106. data/lib/ace/assign/authority/prepared_input.rb +0 -130
  107. data/lib/ace/assign/authority/prepared_queue.rb +0 -185
  108. data/lib/ace/assign/authority/prepared_task_context.rb +0 -116
  109. data/lib/ace/assign/authority/prepared_work.rb +0 -304
  110. data/lib/ace/assign/authority/prepared_worker.rb +0 -69
  111. data/lib/ace/assign/authority/private_directory.rb +0 -33
  112. data/lib/ace/assign/authority/protected_assignment_context.rb +0 -226
  113. data/lib/ace/assign/authority/readiness_hook.rb +0 -120
  114. data/lib/ace/assign/authority/receipt_transfer.rb +0 -48
  115. data/lib/ace/assign/authority/router.rb +0 -81
  116. data/lib/ace/assign/authority/server.rb +0 -131
  117. data/lib/ace/assign/authority/service_cleanup_dispatch.rb +0 -88
  118. data/lib/ace/assign/authority/service_cleanup_evidence.rb +0 -180
  119. data/lib/ace/assign/authority/service_delivery_evidence.rb +0 -101
  120. data/lib/ace/assign/authority/service_evidence.rb +0 -286
  121. data/lib/ace/assign/authority/service_publication_evidence.rb +0 -110
  122. data/lib/ace/assign/authority/socket_server.rb +0 -255
  123. data/lib/ace/assign/authority/task_context_entry.rb +0 -32
  124. data/lib/ace/assign/authority/transfer_codec.rb +0 -187
  125. data/lib/ace/assign/cli/commands/attempt/base.rb +0 -84
  126. data/lib/ace/assign/cli/commands/attempt/evidence.rb +0 -28
  127. data/lib/ace/assign/cli/commands/attempt/finish.rb +0 -51
  128. data/lib/ace/assign/cli/commands/attempt/reconcile.rb +0 -49
  129. data/lib/ace/assign/cli/commands/attempt/start.rb +0 -45
  130. data/lib/ace/assign/cli/commands/attempt/status.rb +0 -54
  131. data/lib/ace/assign/cli/commands/authority/inbox_context_principal.rb +0 -25
  132. data/lib/ace/assign/cli/commands/authority/inbox_context_selection.rb +0 -28
  133. data/lib/ace/assign/cli/commands/authority/launch.rb +0 -97
  134. data/lib/ace/assign/cli/commands/authority/serve.rb +0 -31
  135. data/lib/ace/assign/cli/commands/authority/status.rb +0 -26
  136. data/lib/ace/assign/cli/commands/authority/task_context.rb +0 -31
  137. data/lib/ace/assign/cli/commands/authority/task_context_principal.rb +0 -25
  138. data/lib/ace/assign/cli/commands/authority/task_context_selection.rb +0 -30
  139. data/lib/ace/assign/cli/commands/authority/terminate.rb +0 -29
  140. data/lib/ace/assign/cli/commands/authority/worker.rb +0 -23
  141. data/lib/ace/assign/cli/commands/campaign_export_result.rb +0 -55
  142. data/lib/ace/assign/cli/commands/campaign_record_round.rb +0 -57
  143. data/lib/ace/assign/cli/commands/delivery.rb +0 -107
  144. data/lib/ace/assign/cli/commands/inbox_bind.rb +0 -34
  145. data/lib/ace/assign/cli/commands/protected_submission.rb +0 -61
  146. data/lib/ace/assign/cli/commands/resume.rb +0 -38
  147. data/lib/ace/assign/cli/commands/submit_candidate.rb +0 -34
  148. data/lib/ace/assign/cli/commands/submit_result.rb +0 -61
  149. data/lib/ace/assign/models/attempt.rb +0 -178
  150. data/lib/ace/assign/models/attempt_binding.rb +0 -116
  151. data/lib/ace/assign/models/evidence_event.rb +0 -86
  152. data/lib/ace/assign/models/execution_receipt.rb +0 -139
  153. data/lib/ace/assign/molecules/attempt_reconciler.rb +0 -127
  154. data/lib/ace/assign/molecules/attempt_store.rb +0 -201
  155. data/lib/ace/assign/molecules/canonical_attempt_state.rb +0 -151
  156. data/lib/ace/assign/molecules/canonical_evidence.rb +0 -153
  157. data/lib/ace/assign/molecules/canonical_read_commands.rb +0 -84
  158. data/lib/ace/assign/molecules/canonical_read_snapshot.rb +0 -313
  159. data/lib/ace/assign/molecules/evidence_calculator.rb +0 -237
  160. data/lib/ace/assign/molecules/evidence_journal.rb +0 -1912
  161. data/lib/ace/assign/molecules/execution_identity_resolver.rb +0 -137
  162. data/lib/ace/assign/molecules/execution_scope_lineage.rb +0 -345
  163. data/lib/ace/assign/molecules/fixed_effect_reference.rb +0 -31
  164. data/lib/ace/assign/molecules/installed_journal_read.rb +0 -143
  165. data/lib/ace/assign/molecules/journal_input_inhibition.rb +0 -78
  166. data/lib/ace/assign/molecules/journal_mutation.rb +0 -273
  167. data/lib/ace/assign/molecules/journal_prompt_mutation.rb +0 -241
  168. data/lib/ace/assign/molecules/ordinary_merge_cli.rb +0 -68
  169. data/lib/ace/assign/molecules/ordinary_merge_contract.rb +0 -410
  170. data/lib/ace/assign/molecules/original_launch_binding.rb +0 -37
  171. data/lib/ace/assign/molecules/proposal_journal.rb +0 -179
  172. data/lib/ace/assign/molecules/protected_control_exclusion.rb +0 -303
  173. data/lib/ace/assign/molecules/protected_workspace_exclusion.rb +0 -330
  174. data/lib/ace/assign/molecules/protected_workspace_provisioner.rb +0 -164
  175. data/lib/ace/assign/molecules/receipt_verifier.rb +0 -314
  176. data/lib/ace/assign/molecules/terminal_scope_receipt.rb +0 -88
  177. data/lib/ace/assign/organisms/attempt_coordinator.rb +0 -1337
  178. data/lib/ace/assign/organisms/delivery_coordinator.rb +0 -342
  179. data/lib/ace/assign/organisms/ordinary_merge_consumer.rb +0 -365
  180. data/lib/ace/assign/organisms/prepared_work_builder.rb +0 -194
  181. data/lib/ace/assign/organisms/protected_delivery_coordinator.rb +0 -100
@@ -1,185 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "fileutils"
4
- require "securerandom"
5
- require "yaml"
6
- require_relative "prepared_work"
7
- require_relative "private_directory"
8
- require_relative "../molecules/lifecycle_exclusion"
9
- require_relative "../organisms/assignment_executor"
10
-
11
- module Ace
12
- module Assign
13
- module Authority
14
- # Existing assignment/progress files, rooted only in the authenticated
15
- # original worker scratch. This cache never supplies authority or a graph.
16
- class PreparedQueue
17
- attr_reader :cache_base, :directory, :work, :descriptor
18
-
19
- def initialize(work:, descriptor:)
20
- @work, @descriptor = work, descriptor
21
- @scratch = PrivateDirectory.verify!(descriptor.fetch("original_worker_scratch_root"))
22
- %w[mapping_id attempt_id assignment_id].each do |field|
23
- value = descriptor.fetch(field)
24
- unavailable!("queue identity") unless value.is_a?(String) && value.match?(PreparedWork::TOKEN)
25
- end
26
- @cache_base = File.join(@scratch, "prepared-queues", descriptor.fetch("mapping_id"), descriptor.fetch("attempt_id"))
27
- @directory = File.join(cache_base, descriptor.fetch("assignment_id"))
28
- definition = JSON.parse(work.definition_bytes(head: descriptor.fetch("prepared_head"), tree: descriptor.fetch("prepared_tree")))
29
- unavailable!("captured assignment metadata") unless %w[name source_config created_at].all? do |field|
30
- definition[field].is_a?(String) && !definition[field].empty?
31
- end
32
- validate_timestamp!(definition.fetch("created_at"))
33
- validate_timestamp!(definition["updated_at"]) if definition.key?("updated_at")
34
- @metadata = Models::Assignment.from_h(definition, cache_dir: directory).to_h
35
- lock_root = File.join(@scratch, "prepared-queue-exclusion")
36
- private_directory!(lock_root, create: true)
37
- @exclusion = Molecules::LifecycleExclusion.new(root: lock_root)
38
- @key = @exclusion.assignment_key(descriptor.values_at("mapping_id", "attempt_id", "assignment_id").join(":"))
39
- end
40
-
41
- # Called ONLY after the fixed adapter proves actual original self. Later
42
- # CLI consumers use with_executor and cannot recreate a missing queue.
43
- def activate_original!
44
- @exclusion.with_exclusive(@key) do
45
- publish_initial! unless File.exist?(directory) || File.symlink?(directory)
46
- executor = verified_executor
47
- result = executor.status
48
- state = result.fetch(:state)
49
- unavailable!("selected queue has failed progress") unless state.failed.empty?
50
- return nil if state.subtree_complete?(descriptor.fetch("scope"))
51
- unless state.steps.all? { |step| step.status == :pending }
52
- unavailable!("original adapter has already activated queue progress")
53
- end
54
- root = state.find_by_number(descriptor.fetch("scope"))
55
- unavailable!("original selected fork root") unless root && root.fork?
56
- executor.step_writer.mark_active(root.file_path)
57
- # Same held-byte consumer validates the resulting progress before
58
- # returning to the adapter that may now start its provider.
59
- executor.status
60
- root
61
- end
62
- end
63
-
64
- def with_executor
65
- @exclusion.with_exclusive(@key) do
66
- unavailable!("published queue is unavailable; no reconstruction") unless File.directory?(directory)
67
- yield verified_executor
68
- end
69
- end
70
-
71
- private
72
-
73
- def verified_executor
74
- private_directory!(directory)
75
- private_directory!(File.join(directory, "steps"))
76
- private_directory!(File.join(directory, "reports"))
77
- metadata = work.parse_queue_metadata!(held_read(File.join(directory, "assignment.yaml"), PreparedWork::MAX_MANIFEST))
78
- unavailable!("queue assignment metadata differs") unless metadata.is_a?(Hash) &&
79
- metadata.reject { |key, _| key == "updated_at" } == @metadata.reject { |key, _| key == "updated_at" }
80
- validate_timestamp!(metadata["updated_at"]) if metadata.key?("updated_at")
81
- unavailable!("queue job differs") unless held_read(File.join(directory, "job.yaml"), PreparedWork::MAX_TEXT) == work.files.fetch("job.yaml")
82
- owner = self
83
- scanner = Molecules::QueueScanner.new
84
- parser = scanner.method(:scan_captured)
85
- scanner.define_singleton_method(:scan) do |steps_dir, assignment:|
86
- raise AttemptErrors::EvidenceUnavailable, "prepared queue scanner path differs" unless steps_dir == File.join(owner.directory, "steps")
87
- steps, reports = owner.send(:captured_queue)
88
- parser.call(steps: steps, assignment: assignment, reports: reports)
89
- end
90
- Organisms::AssignmentExecutor.new(cache_base: cache_base,
91
- selected_assignment: Models::Assignment.from_h(metadata, cache_dir: directory), queue_scanner: scanner)
92
- end
93
-
94
- def captured_queue
95
- expected = work.manifest.fetch("steps").map { |entry| entry.fetch("filename") }
96
- steps_dir = File.join(directory, "steps")
97
- unavailable!("queue step inventory differs") unless Dir.children(steps_dir).sort == expected.sort
98
- steps = expected.to_h do |filename|
99
- bytes = held_read(File.join(steps_dir, filename), PreparedWork::MAX_TEXT)
100
- parsed = work.parse_queue_step!(bytes)
101
- accepted = work.parse_queue_step!(work.files.fetch("steps/" + filename))
102
- projection = ->(value) { value.fetch(:frontmatter).reject { |key, _| PreparedWork::PROGRESS.include?(key) } }
103
- unless projection.call(parsed) == projection.call(accepted) && parsed.fetch(:body) == accepted.fetch(:body) &&
104
- %w[pending active done failed].include?(parsed.fetch(:frontmatter).fetch("status", "pending"))
105
- unavailable!("queue work-bearing input differs")
106
- end
107
- [filename, bytes]
108
- end
109
- reports_dir = File.join(directory, "reports")
110
- allowed = work.manifest.fetch("steps").map do |entry|
111
- name = work.parse_queue_step!(work.files.fetch("steps/" + entry.fetch("filename"))).fetch(:frontmatter).fetch("name")
112
- Atoms::StepFileParser.generate_report_filename(entry.fetch("number"), name)
113
- end
114
- reports = Dir.children(reports_dir).to_h do |filename|
115
- unavailable!("queue report inventory differs") unless allowed.include?(filename)
116
- bytes = held_read(File.join(reports_dir, filename), PreparedWork::MAX_TEXT)
117
- work.parse_queue_step!(bytes)
118
- [filename, bytes]
119
- end
120
- [steps.freeze, reports.freeze]
121
- end
122
-
123
- def publish_initial!
124
- private_directory!(File.join(@scratch, "prepared-queues"), create: true)
125
- private_directory!(File.dirname(cache_base), create: true)
126
- private_directory!(cache_base, create: true)
127
- stage = File.join(cache_base, ".prepared-#{SecureRandom.hex(12)}")
128
- Dir.mkdir(stage, 0700)
129
- %w[steps reports].each { |name| Dir.mkdir(File.join(stage, name), 0700) }
130
- durable_write(File.join(stage, "assignment.yaml"), @metadata.to_yaml)
131
- durable_write(File.join(stage, "job.yaml"), work.files.fetch("job.yaml"))
132
- work.manifest.fetch("steps").each do |entry|
133
- name = entry.fetch("filename")
134
- durable_write(File.join(stage, "steps", name), work.files.fetch("steps/" + name))
135
- end
136
- %w[steps reports].each { |name| File.open(File.join(stage, name), File::RDONLY) { |file| file.fsync } }
137
- File.open(stage, File::RDONLY) { |file| file.fsync }
138
- unavailable!("queue publication collision") if File.exist?(directory) || File.symlink?(directory)
139
- File.rename(stage, directory)
140
- File.open(cache_base, File::RDONLY) { |file| file.fsync }
141
- ensure
142
- FileUtils.remove_entry(stage) if stage && File.directory?(stage) && !File.symlink?(stage)
143
- end
144
-
145
- def private_directory!(path, create: false)
146
- Dir.mkdir(path, 0700) if create && !File.exist?(path) && !File.symlink?(path)
147
- PrivateDirectory.verify!(path)
148
- end
149
-
150
- def durable_write(path, bytes)
151
- File.open(path, File::WRONLY | File::CREAT | File::EXCL | File::NOFOLLOW, 0600) do |file|
152
- file.write(bytes); file.flush; file.fsync
153
- end
154
- end
155
-
156
- def held_read(path, limit)
157
- File.open(path, File::RDONLY | File::NOFOLLOW | File::NONBLOCK) do |file|
158
- before = file.stat
159
- unavailable!("queue held file kind/size") unless before.file? && before.uid == Process.uid &&
160
- (before.mode & 0077).zero? && before.size.between?(1, limit)
161
- bytes = file.read(limit + 1).force_encoding(Encoding::UTF_8)
162
- after = file.stat
163
- unavailable!("queue held file changed") unless bytes.valid_encoding? && !bytes.include?("\0") &&
164
- bytes.bytesize == before.size && [before.dev, before.ino, before.size, before.mtime, before.ctime] ==
165
- [after.dev, after.ino, after.size, after.mtime, after.ctime]
166
- bytes.freeze
167
- end
168
- rescue SystemCallError
169
- unavailable!("queue held file unavailable")
170
- end
171
-
172
- def unavailable!(reason)
173
- raise AttemptErrors::EvidenceUnavailable, "prepared_input_unavailable: #{reason}"
174
- end
175
-
176
- def validate_timestamp!(value)
177
- unavailable!("captured assignment timestamp") unless value.is_a?(String)
178
- Time.iso8601(value)
179
- rescue ArgumentError
180
- unavailable!("captured assignment timestamp")
181
- end
182
- end
183
- end
184
- end
185
- end
@@ -1,116 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require_relative "protected_assignment_context"
4
- require_relative "task_context_entry"
5
-
6
- module Ace
7
- module Assign
8
- module Authority
9
- # The selected immutable Lab owner sets the hook before entry evaluation.
10
- # Its presence alone never grants scope; original fetch admission decides.
11
- class PreparedTaskContext
12
- SCHEMA = "ace.assign.prepared-task-context/v1"
13
- MAX_RESPONSE = 6_307_840
14
- PRINCIPAL_SCHEMA = "ace.assign.task-context-principal/v1"
15
- SELECTION_SCHEMA = "ace.assign.task-context-selection/v1"
16
- MAX_PRINCIPAL = 1_024
17
- MAX_SELECTION = 65_536
18
-
19
- def initialize(context: nil)
20
- @context = context
21
- end
22
-
23
- def call(mapping:, assignment:, attempt:, task:)
24
- unavailable!("task-context task selector") unless task.is_a?(String) && task.match?(PreparedWork::TOKEN)
25
- selected_entry = selected_entry!
26
- input = resolved_input(mapping: mapping, assignment: assignment, attempt: attempt)
27
- unavailable!("original protected task-context admission") unless input.descriptor.fetch("task_context_entry") == selected_entry
28
- item = input.work.manifest.fetch("context").find { |entry| entry.fetch("task_id") == task }
29
- unavailable!("task is outside captured original context") unless item
30
- text = input.work.files.fetch(item.fetch("text").fetch("filename"))
31
- record = input.descriptor.slice("mapping_id", "assignment_id", "attempt_id", "scope", "definition_digest", "selection_sha256")
32
- .merge("schema" => SCHEMA, "task_id" => task, "text" => text)
33
- response!(record, MAX_RESPONSE)
34
- rescue ArgumentError, KeyError
35
- unavailable!("task-context input or original entry differs")
36
- end
37
-
38
- def principal
39
- selected_entry!
40
- context = installed_context!
41
- response!({"schema" => PRINCIPAL_SCHEMA, "uid" => context.uid, "protected_worker" => context.protected_worker?}, MAX_PRINCIPAL)
42
- end
43
-
44
- def selection(mapping:, assignment:, attempt:)
45
- selected_entry!
46
- # Current discovery selects a trusted classifier only. Original fetch
47
- # independently selects the retained entry; no text leaves this mode.
48
- input = resolved_input(mapping: mapping, assignment: assignment, attempt: attempt)
49
- response!(input.descriptor.slice("mapping_id", "assignment_id", "attempt_id", "scope", "definition_digest", "selection_sha256", "task_context_entry")
50
- .merge("schema" => SELECTION_SCHEMA), MAX_SELECTION)
51
- rescue ArgumentError, KeyError
52
- unavailable!("task-context original selection differs")
53
- end
54
-
55
- def inbox_principal
56
- selected_entry!
57
- context = installed_context!
58
- response!({"schema" => "ace.assign.inbox-context-principal/v1", "uid" => context.uid,
59
- "protected_participant" => context.protected_participant?}, MAX_PRINCIPAL)
60
- end
61
-
62
- def inbox_selection(project:, mapping:, inbox_context:)
63
- selected_entry!
64
- installed_context!.inbox_context_selection(project: project, mapping: mapping, inbox_context: inbox_context) do |record|
65
- response!(record.merge("schema" => "ace.assign.inbox-context-selection/v1"), 16_384)
66
- end
67
- end
68
-
69
- private
70
-
71
- def selected_entry!
72
- unless Object.const_defined?(:ACE_PROTECTED_TASK_CONTEXT_ENTRY, false)
73
- unavailable!("selected original task-context entry is absent")
74
- end
75
- selected_entry = Object.const_get(:ACE_PROTECTED_TASK_CONTEXT_ENTRY, false)
76
- TaskContextEntry.validate!(selected_entry)
77
- objects = [selected_entry, *selected_entry.keys, *selected_entry.values]
78
- selected_entry.values.each { |reference| objects.concat(reference.keys); objects.concat(reference.values) }
79
- unavailable!("selected task-context entry is mutable") unless objects.all?(&:frozen?)
80
- selected_entry
81
- rescue ArgumentError, KeyError, TypeError
82
- unavailable!("task-context selected entry differs")
83
- end
84
-
85
- def installed_context!
86
- @context ||= ProtectedAssignmentContext.load
87
- unavailable!("published task-context installed owner is absent") unless @context.installed?
88
- @context
89
- end
90
-
91
- def resolved_input(mapping:, assignment:, attempt:)
92
- unless [mapping, attempt].all? { |value| value.is_a?(String) && value.match?(PreparedWork::TOKEN) } && assignment.is_a?(String)
93
- unavailable!("task-context selectors")
94
- end
95
- assignment_id, scope = assignment.split("@", 2)
96
- unless assignment_id&.match?(PreparedWork::TOKEN) && scope&.match?(PreparedWork::SCOPE)
97
- unavailable!("task-context scoped assignment")
98
- end
99
- input = installed_context!.resolve(options: {mapping: mapping, attempt: attempt}, assignment_id: assignment_id, scope: scope)
100
- unavailable!("original protected task-context admission") unless input
101
- input
102
- end
103
-
104
- def response!(record, limit)
105
- response = JSON.generate(record) + "\n"
106
- unavailable!("task-context response bound") if response.bytesize > limit
107
- response.freeze
108
- end
109
-
110
- def unavailable!(reason)
111
- raise AttemptErrors::EvidenceUnavailable, "prepared_input_unavailable: #{reason}"
112
- end
113
- end
114
- end
115
- end
116
- end
@@ -1,304 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "json"
4
- require "digest"
5
- require "yaml"
6
- require "date"
7
- require_relative "../atoms/step_file_parser"
8
- require_relative "candidate_transfer"
9
-
10
- module Ace
11
- module Assign
12
- module Authority
13
- # Closed prepared-input tree. Git transport and canonical publication are
14
- # owned by CandidateTransfer/LaunchLifecycle; this object owns their shared
15
- # byte contract, never a registration, attempt or progress ledger.
16
- class PreparedWork
17
- MAX_TEXT = 1024 * 1024
18
- MAX_TOTAL = 64 * 1024 * 1024
19
- MAX_MANIFEST = 32_768
20
- TOKEN = /\A[a-zA-Z0-9][a-zA-Z0-9._-]{0,199}\z/
21
- SCOPE = /\A[0-9]+(?:\.[0-9]+)*\z/
22
- SHA = /\A[0-9a-f]{64}\z/
23
- TOP = %w[version assignment_id project_id task_id scope job steps context].freeze
24
- RECORD = %w[bytes filename sha256].freeze
25
- PROGRESS = %w[status started_at completed_at error stall_reason fork_launch_pid fork_tracked_pids fork_pid_updated_at fork_pid_file].freeze
26
- attr_reader :manifest, :files, :definition, :selection_sha256, :manifest_sha256
27
-
28
- def initialize(files:)
29
- invalid!("file inventory") unless files.is_a?(Hash) && files.size.between?(4, 4096)
30
- @files = files.to_h do |path, bytes|
31
- invalid!("file path") unless path.is_a?(String) && !path.start_with?("/") && path.split("/", -1).all? { |part| !part.empty? && !%w[. .. .git].include?(part.downcase) }
32
- invalid!("text size") unless bytes.is_a?(String) && bytes.bytesize.between?(1, MAX_TEXT)
33
- text = bytes.dup.force_encoding(Encoding::UTF_8)
34
- invalid!("UTF-8") unless text.valid_encoding? && !text.include?("\0")
35
- [path.dup.freeze, text.freeze]
36
- end.freeze
37
- invalid!("expanded size") if @files.values.sum(&:bytesize) > MAX_TOTAL
38
- manifest_bytes = @files.fetch("manifest.json")
39
- invalid!("manifest size") if manifest_bytes.bytesize > MAX_MANIFEST
40
- @manifest = parse_json(manifest_bytes)
41
- closed!(@manifest, TOP)
42
- invalid!("version") unless @manifest["version"].is_a?(Integer) && @manifest["version"] == 1
43
- %w[assignment_id project_id task_id].each { |key| token!(@manifest[key]) }
44
- scope!(@manifest["scope"])
45
- canonical = self.class.canonical_manifest(@manifest)
46
- invalid!("manifest serialization") unless manifest_bytes == canonical + "\n"
47
- @selection_sha256 = Digest::SHA256.hexdigest(canonical).freeze
48
- @manifest_sha256 = Digest::SHA256.hexdigest(manifest_bytes).freeze
49
- @used = %w[manifest.json definition.json]
50
- record!(@manifest["job"], "job.yaml")
51
- @job = yaml!(@files.fetch("job.yaml"))
52
- steps!
53
- context!
54
- task_closure!
55
- selected_root!
56
- invalid!("undeclared file") unless @used.sort == @files.keys.sort
57
- definition_bytes = @files.fetch("definition.json")
58
- invalid!("definition size") if definition_bytes.bytesize > MAX_MANIFEST
59
- @definition = parse_json(definition_bytes)
60
- invalid!("base definition") unless @definition.is_a?(Hash) && !@definition.key?("prepared_work") &&
61
- @definition["session_id"] == @manifest["assignment_id"] && @definition["project_id"] == @manifest["project_id"] && @definition["task_id"] == @manifest["task_id"]
62
- deep_freeze(@definition)
63
- deep_freeze(@manifest)
64
- freeze
65
- rescue KeyError, JSON::ParserError, Psych::Exception => error
66
- invalid!(error.message)
67
- end
68
-
69
- # The existing transport verifies complete Git objects before this
70
- # shared byte contract parses anything. Preserve the original input for
71
- # the eventual registration journal; CandidateTransfer may reexport it.
72
- def self.admit(bytes:, head:, tree:, sha256:, size:, root:, deadline: nil)
73
- transfer = CandidateTransfer.new(root: root)
74
- transfer.admit(bytes: bytes, head: head, sha256: sha256, size: size, deadline: deadline) do |repository, deadline, result|
75
- raise ArgumentError, "prepared_input_invalid: Git tree differs" unless result.fetch("tree") == tree
76
- new(files: transfer.prepared_files(repository: repository, deadline: deadline, head: head))
77
- end
78
- end
79
-
80
- def reference(head:, tree:)
81
- invalid!("Git identity") unless [head, tree].all? { |id| id.is_a?(String) && id.match?(/\A[0-9a-f]{40}\z/) }
82
- {"version" => 1, "task_id" => manifest.fetch("task_id"), "scope" => manifest.fetch("scope"),
83
- "prepared_head" => head, "prepared_tree" => tree, "manifest_bytes" => files.fetch("manifest.json").bytesize,
84
- "manifest_sha256" => manifest_sha256, "selection_sha256" => selection_sha256}.freeze
85
- end
86
-
87
- def definition_bytes(head:, tree:)
88
- bytes = JSON.generate(definition.merge("prepared_work" => reference(head: head, tree: tree)))
89
- invalid!("final definition size") if bytes.bytesize > MAX_MANIFEST
90
- bytes.freeze
91
- end
92
-
93
- # Mutable queue progress still uses the accepted YAML grammar/bounds.
94
- # Return the same parsed bytes that the queue decision will consume.
95
- def parse_queue_step!(bytes)
96
- invalid!("queue step size") unless bytes.is_a?(String) && bytes.bytesize.between?(1, MAX_TEXT)
97
- match = Atoms::StepFileParser::FRONTMATTER_REGEX.match(bytes)
98
- invalid!("queue step frontmatter") unless match
99
- frontmatter = yaml!(match[1])
100
- invalid!("queue step frontmatter") unless frontmatter.is_a?(Hash)
101
- {frontmatter: frontmatter, body: bytes[match.end(0)..].strip}
102
- rescue Psych::Exception => error
103
- invalid!(error.message)
104
- end
105
-
106
- def parse_queue_metadata!(bytes)
107
- invalid!("queue metadata size") unless bytes.is_a?(String) && bytes.bytesize.between?(1, MAX_MANIFEST)
108
- yaml!(bytes)
109
- rescue Psych::Exception => error
110
- invalid!(error.message)
111
- end
112
-
113
- # StepFileParser owns body/frontmatter semantics; only these progress
114
- # fields differ from accepted work. Reports are separate mutable output.
115
- def self.work_projection(bytes)
116
- parsed = Atoms::StepFileParser.parse(bytes)
117
- {"frontmatter" => parsed.fetch(:frontmatter).reject { |key, _| PROGRESS.include?(key) }, "body" => parsed.fetch(:body)}
118
- end
119
-
120
- def self.canonical_manifest(value)
121
- sorted = lambda do |item|
122
- case item
123
- when Hash then item.keys.sort.to_h { |key| [key, sorted.call(item.fetch(key))] }
124
- when Array then item.map { |entry| sorted.call(entry) }
125
- else item
126
- end
127
- end
128
- JSON.generate(TOP.to_h { |key| [key, sorted.call(value.fetch(key))] })
129
- end
130
-
131
- private
132
-
133
- def steps!
134
- steps = @manifest["steps"]
135
- invalid!("steps") unless steps.is_a?(Array) && steps.size.between?(1, 256)
136
- @step_frontmatter = {}
137
- numbers = steps.map do |step|
138
- closed!(step, %w[number filename bytes sha256])
139
- number = step.fetch("number"); scope!(number)
140
- invalid!("step scope") unless number == @manifest["scope"] || number.start_with?(@manifest["scope"] + ".")
141
- filename = step.fetch("filename")
142
- record!(step.reject { |key, _| key == "number" }, "steps/" + filename)
143
- # Step record filenames are basenames; inventory paths add steps/.
144
- content = @files.fetch("steps/" + filename)
145
- match = Atoms::StepFileParser::FRONTMATTER_REGEX.match(content)
146
- invalid!("step frontmatter") unless match
147
- fm = yaml!(match[1])
148
- invalid!("initial status") unless fm.is_a?(Hash) && fm.fetch("status", "pending") == "pending"
149
- @step_frontmatter[number] = fm
150
- invalid!("step name") unless fm["name"].is_a?(String) && !fm["name"].empty?
151
- invalid!("step filename") unless filename == Atoms::StepFileParser.generate_filename(number, fm["name"])
152
- number
153
- end
154
- invalid!("step ordering/root") unless numbers.uniq == numbers && numbers == numbers.sort_by { |n| n.split(".").map(&:to_i) } && numbers.first == @manifest["scope"]
155
- end
156
-
157
- def context!
158
- context = @manifest["context"]
159
- invalid!("context") unless context.is_a?(Array) && context.size.between?(1, 256)
160
- uris = context.map do |entry|
161
- closed!(entry, %w[uri task_id spec text reports]); token!(entry["task_id"])
162
- uri = "task://" + entry.fetch("task_id")
163
- invalid!("context URI") unless entry["uri"] == uri
164
- prefix = "context/" + entry.fetch("task_id")
165
- record!(entry["spec"], prefix + "/spec.md")
166
- record!(entry["text"], prefix + "/bundle.txt")
167
- reports = entry["reports"]
168
- invalid!("reports") unless reports.is_a?(Array)
169
- identities = reports.map do |report|
170
- closed!(report, %w[assignment_id number file]); token!(report["assignment_id"]); scope!(report["number"])
171
- record!(report["file"], prefix + "/reports/" + report.fetch("assignment_id") + "/" + report.fetch("number") + ".r.md")
172
- [report["assignment_id"], report["number"].split(".").map(&:to_i)]
173
- end
174
- invalid!("report ordering") unless identities.uniq == identities && identities == identities.sort
175
- uri
176
- end
177
- invalid!("context ordering/selected task") unless uris.uniq == uris && uris == uris.sort && uris.include?("task://" + @manifest["task_id"])
178
- end
179
-
180
- def selected_root!
181
- scope = @manifest.fetch("scope"); task = @manifest.fetch("task_id")
182
- root = @step_frontmatter.fetch(scope)
183
- invalid!("selected fork root") unless root["context"] == "fork" && root["taskref"] == task
184
- @step_frontmatter.each do |number, fm|
185
- invalid!("selected taskref") if fm.key?("taskref") && fm["taskref"] != task
186
- next if number == scope
187
- parent = number.split(".")[0...-1].join(".")
188
- invalid!("selected parent association") unless fm["parent"] == parent && @step_frontmatter.key?(parent)
189
- end
190
- invalid!("job steps") unless @job.is_a?(Hash) && @job["steps"].is_a?(Array)
191
- roots = @job.fetch("steps").select { |step| step.is_a?(Hash) && step["context"] == "fork" && step["taskref"] == task }
192
- invalid!("job selected root") unless roots.one? && roots.first.values_at("number", "taskref") == [scope, task]
193
- end
194
-
195
- def task_closure!
196
- specs = @manifest.fetch("context").to_h do |entry|
197
- content = @files.fetch(entry.fetch("spec").fetch("filename"))
198
- match = Atoms::StepFileParser::FRONTMATTER_REGEX.match(content)
199
- invalid!("task frontmatter") unless match
200
- task = yaml!(match[1])
201
- invalid!("task association") unless task.is_a?(Hash) && task["id"] == entry["task_id"] && task.fetch("needs_review", false) == false
202
- dependencies = task.fetch("dependencies", [])
203
- invalid!("task dependencies") unless dependencies.is_a?(Array) && dependencies.uniq == dependencies
204
- dependencies.each { |dependency| token!(dependency) }
205
- [entry.fetch("task_id"), task]
206
- end
207
- selected = specs.fetch(@manifest.fetch("task_id"))
208
- invalid!("selected reviewed status") unless %w[pending in-progress].include?(selected["status"])
209
- visiting = []; visited = []
210
- walk = lambda do |id|
211
- invalid!("dependency cycle: " + (visiting + [id]).join(" -> ")) if visiting.include?(id)
212
- return if visited.include?(id)
213
- visiting << id
214
- specs.fetch(id).fetch("dependencies", []).each { |dependency| walk.call(dependency) }
215
- visiting.pop; visited << id
216
- end
217
- walk.call(@manifest.fetch("task_id"))
218
- invalid!("undeclared context") unless visited.sort == specs.keys.sort
219
- end
220
-
221
- def record!(record, path)
222
- closed!(record, RECORD)
223
- # Selected step record uses an exact basename; other records use the
224
- # exact canonical relative artifact path.
225
- expected = path.start_with?("steps/") ? path.delete_prefix("steps/") : path
226
- invalid!("content reference") unless record["filename"] == expected && record["bytes"].is_a?(Integer) && record["bytes"].positive? && record["sha256"].is_a?(String) && record["sha256"].match?(SHA)
227
- bytes = @files.fetch(path)
228
- invalid!("content digest") unless record["bytes"] == bytes.bytesize && record["sha256"] == Digest::SHA256.hexdigest(bytes)
229
- invalid!("duplicate file") if @used.include?(path)
230
- @used << path
231
- end
232
-
233
- def yaml!(bytes)
234
- ast = Psych.parse_stream(bytes)
235
- invalid!("YAML requires one document") unless ast.children.size == 1
236
- scanner = Psych::ScalarScanner.new(Psych::ClassLoader::Restricted.new([], []))
237
- pending = [[ast, 0]]
238
- until pending.empty?
239
- node, depth = pending.pop
240
- invalid!("YAML nesting exceeds 32") if depth > 32
241
- invalid!("YAML alias") if node.is_a?(Psych::Nodes::Alias)
242
- if node.is_a?(Psych::Nodes::Mapping)
243
- keys = node.children.each_slice(2).map(&:first)
244
- decoded = keys.map do |key|
245
- invalid!("YAML mapping keys") unless key.is_a?(Psych::Nodes::Scalar) && [nil, "tag:yaml.org,2002:str"].include?(key.tag)
246
- value = key.plain && key.tag.nil? ? scanner.tokenize(key.value) : key.value
247
- invalid!("YAML mapping keys must be strings") unless value.is_a?(String)
248
- value
249
- end
250
- invalid!("duplicate YAML key") unless decoded.uniq.size == decoded.size
251
- end
252
- (node.children || []).each { |child| pending << [child, depth + 1] }
253
- end
254
- value = YAML.safe_load(bytes, permitted_classes: [Time, Date], aliases: false)
255
- values = [value]
256
- until values.empty?
257
- item = values.pop
258
- case item
259
- when Hash
260
- invalid!("YAML mapping keys must be strings") unless item.keys.all? { |key| key.is_a?(String) }
261
- values.concat(item.values)
262
- when Array then values.concat(item)
263
- when String
264
- invalid!("YAML string encoding") unless item.valid_encoding? && !item.include?("\0")
265
- when Float then invalid!("YAML nonfinite number") unless item.finite?
266
- when Integer, TrueClass, FalseClass, NilClass, Time, Date
267
- # Managed files retain StepFileParser's standard timestamp types.
268
- else invalid!("unsupported YAML value")
269
- end
270
- end
271
- value
272
- end
273
-
274
- def parse_json(bytes)
275
- JSON.parse(bytes, create_additions: false, max_nesting: 32, allow_duplicate_key: false, allow_comments: false)
276
- end
277
-
278
- def closed!(value, keys)
279
- invalid!("closed fields") unless value.is_a?(Hash) && value.keys.sort == keys.sort
280
- end
281
-
282
- def token!(value)
283
- invalid!("identity") unless value.is_a?(String) && value.match?(TOKEN)
284
- end
285
-
286
- def scope!(value)
287
- invalid!("scope") unless value.is_a?(String) && value.match?(SCOPE)
288
- end
289
-
290
- def deep_freeze(value)
291
- case value
292
- when Hash then value.each { |key, item| deep_freeze(key); deep_freeze(item) }
293
- when Array then value.each { |item| deep_freeze(item) }
294
- end
295
- value.freeze
296
- end
297
-
298
- def invalid!(reason)
299
- raise ArgumentError, "prepared_input_invalid: #{reason}"
300
- end
301
- end
302
- end
303
- end
304
- end