ace-assign 0.71.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 (195) 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 -2119
  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 -4
  16. data/handbook/workflow-instructions/assign/drive.wf.md +47 -195
  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/organisms/assignment_executor.rb +0 -6
  35. data/lib/ace/assign/organisms/task_assignment_creator.rb +3 -8
  36. data/lib/ace/assign/version.rb +1 -1
  37. data/lib/ace/assign.rb +0 -6
  38. metadata +4 -176
  39. data/.ace-defaults/assign/presets/close-delivery.yml +0 -57
  40. data/.ace-defaults/assign/presets/deliver-release.yml +0 -60
  41. data/.ace-defaults/assign/presets/integrate-batch.yml +0 -72
  42. data/.ace-defaults/assign/presets/supervise-delivery.yml +0 -69
  43. data/docs/protected-authority.md +0 -282
  44. data/exe/ace-scope-ready +0 -55
  45. data/lib/ace/assign/atoms/assignment_scope.rb +0 -58
  46. data/lib/ace/assign/atoms/attempt_state_machine.rb +0 -84
  47. data/lib/ace/assign/atoms/candidate_limits.rb +0 -20
  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/atoms/git_blob_batch.rb +0 -59
  52. data/lib/ace/assign/authority/assignment_definition.rb +0 -35
  53. data/lib/ace/assign/authority/assignment_inventory.rb +0 -290
  54. data/lib/ace/assign/authority/campaign_consumer_policy.rb +0 -100
  55. data/lib/ace/assign/authority/campaign_execution.rb +0 -119
  56. data/lib/ace/assign/authority/campaign_round_transfer.rb +0 -82
  57. data/lib/ace/assign/authority/candidate_blob_batch.rb +0 -55
  58. data/lib/ace/assign/authority/candidate_head_comparison.rb +0 -139
  59. data/lib/ace/assign/authority/candidate_transfer.rb +0 -357
  60. data/lib/ace/assign/authority/cleanup_result_protection.rb +0 -53
  61. data/lib/ace/assign/authority/client.rb +0 -257
  62. data/lib/ace/assign/authority/command_execution.rb +0 -180
  63. data/lib/ace/assign/authority/deployment.rb +0 -627
  64. data/lib/ace/assign/authority/deployment_history.rb +0 -146
  65. data/lib/ace/assign/authority/endcap.rb +0 -457
  66. data/lib/ace/assign/authority/endcap_attempts.rb +0 -274
  67. data/lib/ace/assign/authority/endcap_campaign_results.rb +0 -232
  68. data/lib/ace/assign/authority/endcap_campaign_rounds.rb +0 -266
  69. data/lib/ace/assign/authority/endcap_contexts.rb +0 -23
  70. data/lib/ace/assign/authority/endcap_inboxes.rb +0 -37
  71. data/lib/ace/assign/authority/endcap_prepared_work.rb +0 -80
  72. data/lib/ace/assign/authority/endcap_publication.rb +0 -41
  73. data/lib/ace/assign/authority/endcap_results.rb +0 -398
  74. data/lib/ace/assign/authority/endcap_review_requests.rb +0 -183
  75. data/lib/ace/assign/authority/endcap_reviews.rb +0 -130
  76. data/lib/ace/assign/authority/endcap_service_context.rb +0 -38
  77. data/lib/ace/assign/authority/endcap_service_settlement.rb +0 -152
  78. data/lib/ace/assign/authority/evidence_transfer.rb +0 -67
  79. data/lib/ace/assign/authority/execution_scope_observation.rb +0 -903
  80. data/lib/ace/assign/authority/fixed_publication_lifecycle.rb +0 -176
  81. data/lib/ace/assign/authority/fixed_service_lifecycle.rb +0 -594
  82. data/lib/ace/assign/authority/inbox_context_original.rb +0 -113
  83. data/lib/ace/assign/authority/launch_attempt_consumers.rb +0 -51
  84. data/lib/ace/assign/authority/launch_campaign_registration.rb +0 -214
  85. data/lib/ace/assign/authority/launch_control_channel.rb +0 -313
  86. data/lib/ace/assign/authority/launch_control_exclusions.rb +0 -127
  87. data/lib/ace/assign/authority/launch_driver.rb +0 -393
  88. data/lib/ace/assign/authority/launch_input_inhibition.rb +0 -132
  89. data/lib/ace/assign/authority/launch_lifecycle.rb +0 -997
  90. data/lib/ace/assign/authority/launch_prepared_work.rb +0 -122
  91. data/lib/ace/assign/authority/launch_review.rb +0 -91
  92. data/lib/ace/assign/authority/launch_scope_abort.rb +0 -81
  93. data/lib/ace/assign/authority/launch_scope_admission.rb +0 -215
  94. data/lib/ace/assign/authority/launch_scope_close.rb +0 -146
  95. data/lib/ace/assign/authority/launch_scope_parent.rb +0 -44
  96. data/lib/ace/assign/authority/launch_scope_release.rb +0 -859
  97. data/lib/ace/assign/authority/launch_steering.rb +0 -353
  98. data/lib/ace/assign/authority/launch_stop.rb +0 -254
  99. data/lib/ace/assign/authority/maintenance_workspace_retirement.rb +0 -229
  100. data/lib/ace/assign/authority/ordinary_attempt_start.rb +0 -137
  101. data/lib/ace/assign/authority/ordinary_client.rb +0 -70
  102. data/lib/ace/assign/authority/ordinary_completion.rb +0 -104
  103. data/lib/ace/assign/authority/ordinary_effect_settlement.rb +0 -118
  104. data/lib/ace/assign/authority/ordinary_lifecycle.rb +0 -227
  105. data/lib/ace/assign/authority/ordinary_merge_evidence.rb +0 -81
  106. data/lib/ace/assign/authority/ordinary_peer.rb +0 -54
  107. data/lib/ace/assign/authority/ordinary_progress.rb +0 -162
  108. data/lib/ace/assign/authority/ordinary_queue.rb +0 -115
  109. data/lib/ace/assign/authority/ordinary_release_artifacts.rb +0 -143
  110. data/lib/ace/assign/authority/ordinary_results.rb +0 -206
  111. data/lib/ace/assign/authority/ordinary_review_evidence.rb +0 -180
  112. data/lib/ace/assign/authority/ordinary_reviews.rb +0 -214
  113. data/lib/ace/assign/authority/ordinary_server.rb +0 -75
  114. data/lib/ace/assign/authority/ordinary_supervisor.rb +0 -99
  115. data/lib/ace/assign/authority/original_inbox_selection.rb +0 -41
  116. data/lib/ace/assign/authority/posix_acl.rb +0 -59
  117. data/lib/ace/assign/authority/prepared_input.rb +0 -130
  118. data/lib/ace/assign/authority/prepared_queue.rb +0 -185
  119. data/lib/ace/assign/authority/prepared_task_context.rb +0 -116
  120. data/lib/ace/assign/authority/prepared_work.rb +0 -309
  121. data/lib/ace/assign/authority/prepared_worker.rb +0 -69
  122. data/lib/ace/assign/authority/private_directory.rb +0 -33
  123. data/lib/ace/assign/authority/protected_assignment_context.rb +0 -226
  124. data/lib/ace/assign/authority/readiness_hook.rb +0 -120
  125. data/lib/ace/assign/authority/receipt_transfer.rb +0 -48
  126. data/lib/ace/assign/authority/release_artifact_manifest.rb +0 -91
  127. data/lib/ace/assign/authority/retained_input.rb +0 -53
  128. data/lib/ace/assign/authority/router.rb +0 -81
  129. data/lib/ace/assign/authority/server.rb +0 -131
  130. data/lib/ace/assign/authority/service_cleanup_dispatch.rb +0 -88
  131. data/lib/ace/assign/authority/service_cleanup_evidence.rb +0 -180
  132. data/lib/ace/assign/authority/service_delivery_evidence.rb +0 -101
  133. data/lib/ace/assign/authority/service_evidence.rb +0 -286
  134. data/lib/ace/assign/authority/service_publication_evidence.rb +0 -110
  135. data/lib/ace/assign/authority/socket_server.rb +0 -255
  136. data/lib/ace/assign/authority/task_context_entry.rb +0 -32
  137. data/lib/ace/assign/authority/transfer_codec.rb +0 -189
  138. data/lib/ace/assign/cli/commands/attempt/base.rb +0 -84
  139. data/lib/ace/assign/cli/commands/attempt/evidence.rb +0 -28
  140. data/lib/ace/assign/cli/commands/attempt/finish.rb +0 -51
  141. data/lib/ace/assign/cli/commands/attempt/reconcile.rb +0 -49
  142. data/lib/ace/assign/cli/commands/attempt/start.rb +0 -45
  143. data/lib/ace/assign/cli/commands/attempt/status.rb +0 -54
  144. data/lib/ace/assign/cli/commands/authority/inbox_context_principal.rb +0 -25
  145. data/lib/ace/assign/cli/commands/authority/inbox_context_selection.rb +0 -28
  146. data/lib/ace/assign/cli/commands/authority/launch.rb +0 -97
  147. data/lib/ace/assign/cli/commands/authority/serve.rb +0 -31
  148. data/lib/ace/assign/cli/commands/authority/status.rb +0 -26
  149. data/lib/ace/assign/cli/commands/authority/task_context.rb +0 -31
  150. data/lib/ace/assign/cli/commands/authority/task_context_principal.rb +0 -25
  151. data/lib/ace/assign/cli/commands/authority/task_context_selection.rb +0 -30
  152. data/lib/ace/assign/cli/commands/authority/terminate.rb +0 -29
  153. data/lib/ace/assign/cli/commands/authority/worker.rb +0 -23
  154. data/lib/ace/assign/cli/commands/campaign_export_result.rb +0 -55
  155. data/lib/ace/assign/cli/commands/campaign_record_round.rb +0 -57
  156. data/lib/ace/assign/cli/commands/delivery.rb +0 -107
  157. data/lib/ace/assign/cli/commands/inbox_bind.rb +0 -34
  158. data/lib/ace/assign/cli/commands/protected_submission.rb +0 -61
  159. data/lib/ace/assign/cli/commands/resume.rb +0 -38
  160. data/lib/ace/assign/cli/commands/submit_candidate.rb +0 -34
  161. data/lib/ace/assign/cli/commands/submit_result.rb +0 -61
  162. data/lib/ace/assign/models/attempt.rb +0 -178
  163. data/lib/ace/assign/models/attempt_binding.rb +0 -116
  164. data/lib/ace/assign/models/evidence_event.rb +0 -86
  165. data/lib/ace/assign/models/execution_receipt.rb +0 -139
  166. data/lib/ace/assign/molecules/attempt_reconciler.rb +0 -127
  167. data/lib/ace/assign/molecules/attempt_store.rb +0 -201
  168. data/lib/ace/assign/molecules/canonical_attempt_state.rb +0 -151
  169. data/lib/ace/assign/molecules/canonical_evidence.rb +0 -170
  170. data/lib/ace/assign/molecules/canonical_read_commands.rb +0 -84
  171. data/lib/ace/assign/molecules/canonical_read_snapshot.rb +0 -313
  172. data/lib/ace/assign/molecules/evidence_calculator.rb +0 -237
  173. data/lib/ace/assign/molecules/evidence_journal.rb +0 -1899
  174. data/lib/ace/assign/molecules/execution_identity_resolver.rb +0 -137
  175. data/lib/ace/assign/molecules/execution_scope_lineage.rb +0 -345
  176. data/lib/ace/assign/molecules/fixed_effect_reference.rb +0 -31
  177. data/lib/ace/assign/molecules/installed_journal_read.rb +0 -143
  178. data/lib/ace/assign/molecules/journal_input_inhibition.rb +0 -78
  179. data/lib/ace/assign/molecules/journal_mutation.rb +0 -274
  180. data/lib/ace/assign/molecules/journal_prompt_mutation.rb +0 -241
  181. data/lib/ace/assign/molecules/ordinary_merge_cli.rb +0 -68
  182. data/lib/ace/assign/molecules/ordinary_merge_contract.rb +0 -410
  183. data/lib/ace/assign/molecules/original_launch_binding.rb +0 -37
  184. data/lib/ace/assign/molecules/proposal_journal.rb +0 -179
  185. data/lib/ace/assign/molecules/protected_control_exclusion.rb +0 -303
  186. data/lib/ace/assign/molecules/protected_workspace_exclusion.rb +0 -330
  187. data/lib/ace/assign/molecules/protected_workspace_provisioner.rb +0 -164
  188. data/lib/ace/assign/molecules/receipt_verifier.rb +0 -320
  189. data/lib/ace/assign/molecules/terminal_scope_receipt.rb +0 -88
  190. data/lib/ace/assign/organisms/attempt_coordinator.rb +0 -1374
  191. data/lib/ace/assign/organisms/delivery_coordinator.rb +0 -342
  192. data/lib/ace/assign/organisms/ordinary_driver.rb +0 -310
  193. data/lib/ace/assign/organisms/ordinary_merge_consumer.rb +0 -365
  194. data/lib/ace/assign/organisms/prepared_work_builder.rb +0 -194
  195. data/lib/ace/assign/organisms/protected_delivery_coordinator.rb +0 -100
@@ -1,179 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "securerandom"
4
-
5
- module Ace
6
- module Assign
7
- module Molecules
8
- # A decision projection of the existing qjl event chain. No effect
9
- # executor, extra ref, file registry, or claim ledger lives here.
10
- module ProposalJournal
11
- PROPOSAL_REFERENCE = /\Aproposal-[0-9a-f]{24}-r[1-9][0-9]*\z/
12
- PROPOSAL_BINDING = %w[operation project_id assignment_id attempt_id input_digest target candidate_head caller_uid].freeze
13
-
14
- PROPOSAL_IMMUTABLE = (PROPOSAL_BINDING + %w[schema proposal_id revision revision_id request_id requester content_digest created_at authorization context options recommendation rationale prerequisites lifecycle_request revision_operation_id expected_source_revision silence_delegated]).freeze
15
-
16
- def proposal_history(id)
17
- assignment_ids.flat_map do |assignment|
18
- events = read_events(assignment)
19
- events.group_by { |event| event["attempt_id"] }.each_value do |chain|
20
- unless Models::EvidenceEvent.chain_valid?(chain)
21
- raise AttemptErrors::EvidenceUnavailable, "Proposal evidence chain is corrupt"
22
- end
23
- end
24
- events.select { |event| event["type"] == "proposal_state" && event.dig("payload", "record", "proposal_id") == id }
25
- .map { |event| event.fetch("payload").fetch("record") }
26
- end
27
- end
28
-
29
- def proposal_record(id)
30
- proposal_history(id).last
31
- end
32
-
33
- def proposals
34
- assignment_ids.flat_map do |assignment|
35
- read_events(assignment).select { |event| event["type"] == "proposal_state" }
36
- .map { |event| event.dig("payload", "record", "proposal_id") }
37
- end.uniq.map { |id| proposal_record(id) }
38
- end
39
-
40
- def proposal_operation_record(operation_id)
41
- proposals.each do |record|
42
- found = proposal_history(record["proposal_id"]).reverse.find do |entry|
43
- entry["revision_operation_id"] == operation_id
44
- end
45
- return found if found
46
- end
47
- nil
48
- end
49
-
50
- # Admitted HITL policy runs this block while the same canonical
51
- # mutation lock/CAS boundary excludes service claims and other replies.
52
- def change_proposal(id, assignment_id:, attempt_id:, &policy)
53
- 3.times do
54
- events = read_events(assignment_id).select { |event| event["attempt_id"] == attempt_id }
55
- generation = events.count { |event| event["type"] == "authority_mutation" }
56
- begin
57
- return mutate(assignment_id: assignment_id, attempt_id: attempt_id,
58
- mutation_id: "proposal-#{SecureRandom.hex(16)}", operation: "proposal-decision",
59
- parameters_digest: Digest::SHA256.hexdigest(id), expected_generation: generation) do |current, _commit, _generation|
60
- record = current.reverse.find do |event|
61
- event["type"] == "proposal_state" && event.dig("payload", "record", "proposal_id") == id
62
- end&.dig("payload", "record")
63
- global = proposal_record(id)
64
- if global && (global["assignment_id"] != assignment_id || global["attempt_id"] != attempt_id)
65
- raise AttemptErrors::ReceiptRejected, "Proposal identity belongs to another assignment or attempt"
66
- end
67
- updated = policy.call(record)
68
- return record if updated == record
69
- unless updated.is_a?(Hash) && updated["proposal_id"] == id &&
70
- updated["assignment_id"] == assignment_id && updated["attempt_id"] == attempt_id
71
- raise AttemptErrors::ReceiptRejected, "Proposal policy changed immutable ownership"
72
- end
73
- validate_proposal_revision!(record, updated)
74
- entries = []
75
- if record && record["revision_id"] != updated["revision_id"]
76
- entries << {type: "proposal_state", payload: {"record" => record.merge("state" => "superseded", "resolution" => "revised")}}
77
- end
78
- if record.nil? || record["revision_id"] != updated["revision_id"]
79
- entries << {type: "proposal_state", payload: {"record" => updated.merge("state" => "proposed")}}
80
- end
81
- entries << {type: "proposal_state", payload: {"record" => updated}}
82
- {events: entries, data: {"record" => updated}}
83
- end.fetch("record")
84
- rescue AttemptErrors::Conflict => e
85
- raise unless e.message == "Authority registration generation changed"
86
- end
87
- end
88
- raise AttemptErrors::EvidenceUnavailable, "Proposal generation stayed conflicting"
89
- end
90
-
91
- def validate_proposal_revision!(previous, updated)
92
- if previous && previous["revision_id"] == updated["revision_id"]
93
- unless PROPOSAL_IMMUTABLE.all? { |field| previous[field] == updated[field] }
94
- raise AttemptErrors::ReceiptRejected, "Proposal revision content is immutable"
95
- end
96
- return
97
- end
98
- expected = previous ? previous.fetch("revision") + 1 : 1
99
- reference = "#{updated.fetch("proposal_id")}-r#{expected}"
100
- valid = updated["revision"] == expected && updated["revision_id"] == reference &&
101
- updated["request_id"] == reference && updated["authorization"] == reference &&
102
- updated["state"] == "awaiting-delivery" && PROPOSAL_REFERENCE.match?(reference)
103
- if previous
104
- valid &&= updated["expected_source_revision"] == previous["revision"] &&
105
- updated["revision_operation_id"].is_a?(String) &&
106
- updated["revision_operation_id"].match?(/\Arevision-[0-9a-f]{24}\z/) &&
107
- proposal_operation_record(updated["revision_operation_id"]).nil? &&
108
- %w[assignment_id attempt_id project_id requester caller_uid].all? { |field| previous[field] == updated[field] }
109
- else
110
- valid &&= updated["revision_operation_id"].nil? && updated["expected_source_revision"].nil?
111
- end
112
- unresolved = previous && proposal_claims(previous["revision_id"]).any? do |claim|
113
- claim["state"] != "succeeded" && !proposal_no_effect?(claim)
114
- end
115
- raise AttemptErrors::ReceiptRejected, "Claimed proposal cannot be revised; reconcile the existing effect" if unresolved
116
- raise AttemptErrors::ReceiptRejected, "Proposal revision must begin with exact immutable delivery scope" unless valid
117
- end
118
- private :validate_proposal_revision!
119
-
120
- def proposal_claims(reference)
121
- claims = (service_request_records + fixed_merge_request_records).select do |request|
122
- request["authorization"] == reference &&
123
- !(request["state"] == "rejected" && request["consumed"] == false)
124
- end
125
- return claims if claims.empty?
126
- events = read_events(claims.first.fetch("assignment_id"))
127
- claims.sort_by do |claim|
128
- kind = claim.key?("queue_entry") ? "merge_admission" : "service_claim"
129
- events.index do |event|
130
- event["type"] == kind && event["attempt_id"] == claim.fetch("attempt_id") &&
131
- event.dig("payload", "request_id") == claim.fetch("request_id")
132
- end || raise(AttemptErrors::EvidenceUnavailable, "Proposal claim has no canonical admission event")
133
- end
134
- end
135
-
136
- # Request IDs are scoped by record family. A same-text ID in the other
137
- # family is another effect, never an idempotent replay.
138
- def proposal_claim_conflict(reference, request_id:, fixed:)
139
- [[service_request_records, false], [fixed_merge_request_records, true]].each do |records, family|
140
- conflict = records.find do |claim|
141
- claim["authorization"] == reference &&
142
- !(family == fixed && claim["request_id"] == request_id) &&
143
- !(claim["state"] == "rejected" && claim["consumed"] == false) &&
144
- !proposal_no_effect?(claim)
145
- end
146
- return conflict if conflict
147
- end
148
- nil
149
- end
150
-
151
- def proposal_no_effect?(claim)
152
- (claim["state"] == "failed-settled" && settlement_evidence_intact?(claim)) ||
153
- (claim["state"] == "failed" && claim.key?("queue_entry") && fixed_no_effect_intact?(claim))
154
- end
155
-
156
- def proposal_authorize!(reference, binding)
157
- unless reference.is_a?(String) && PROPOSAL_REFERENCE.match?(reference)
158
- raise AttemptErrors::ReceiptRejected, "Invalid proposal authorization reference"
159
- end
160
- id = reference.sub(/-r[1-9][0-9]*\z/, "")
161
- record = proposal_record(id)
162
- unless record && record["revision_id"] == reference &&
163
- record["stop_requested"] != true &&
164
- %w[approved-explicitly approved-by-silence].include?(record["state"]) &&
165
- PROPOSAL_BINDING.all? { |field| record[field] == binding[field] }
166
- raise AttemptErrors::ReceiptRejected, "Proposal is not authorized for this exact effect"
167
- end
168
- attempt = derived_attempts(binding.fetch("assignment_id")).find do |candidate|
169
- candidate.attempt_id == binding.fetch("attempt_id")
170
- end
171
- unless attempt && attempt.state == "running"
172
- raise AttemptErrors::ReceiptRejected, "Proposal effect needs an authoritative bound running attempt"
173
- end
174
- record
175
- end
176
- end
177
- end
178
- end
179
- end
@@ -1,303 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require_relative "protected_workspace_exclusion"
4
-
5
- module Ace
6
- module Assign
7
- module Molecules
8
- class LifecycleExclusion
9
- # Authority-side canonical guards. This owner never selects a Git/cache
10
- # root, resets a fence, or grants the lifetime workspace writer lease.
11
- class ControlExclusion
12
- class Protection < Ace::Runtime::Molecules::ProtectedArtifactSet::Protection
13
- def initialize(authority_uid:, acl: Authority::PosixAcl.new, **options)
14
- super(**options)
15
- @authority_uid, @acl = authority_uid, acl
16
- end
17
-
18
- def verify!(path, handle, directory:)
19
- super
20
- unless (handle.stat.mode & 0o7000).zero? && @acl.entries(path).nil? &&
21
- (!directory || @acl.entries(path, attribute: "system.posix_acl_default").nil?)
22
- raise AttemptErrors::EvidenceUnavailable, "Lifecycle control protection differs"
23
- end
24
- end
25
-
26
- private
27
- def trusted_owner?(stat) = [0, @authority_uid].include?(stat.uid)
28
- end
29
-
30
- def initialize(authority:, project_id:, descriptor_sha256:, root_identity: nil, files: File, protection: nil)
31
- unless authority.is_a?(Hash) && project_id.is_a?(String) && token?(project_id) &&
32
- descriptor_sha256.is_a?(String) && descriptor_sha256.match?(/\A[0-9a-f]{64}\z/) &&
33
- %w[uid gid].all? { |key| authority[key].is_a?(Integer) && authority[key].positive? } &&
34
- authority["state_root"].is_a?(String) && authority["state_root"].valid_encoding? &&
35
- authority["state_root"].bytesize.between?(1, 4096) && !authority["state_root"].include?("\0") &&
36
- authority["state_root"].start_with?("/") && File.expand_path(authority["state_root"]) == authority["state_root"]
37
- unavailable!("Lifecycle control selection is malformed")
38
- end
39
- @root = File.join(authority.fetch("state_root"), "lifecycle-exclusion", project_id, "control")
40
- unavailable!("Lifecycle control path is oversized") if @root.bytesize > 4096
41
- @uid, @gid, @descriptor = authority.values_at("uid", "gid") + [descriptor_sha256.dup.freeze]
42
- if root_identity
43
- unless root_identity.is_a?(Hash) && root_identity.keys.sort == %w[device gid inode uid] &&
44
- root_identity.values.all? { |value| value.is_a?(Integer) && value >= 0 } &&
45
- root_identity.values_at("uid", "gid") == [@uid, @gid]
46
- unavailable!("Lifecycle control original identity differs")
47
- end
48
- @identity = root_identity.dup.freeze
49
- end
50
- @files = files
51
- @protection = protection || Protection.new(authority_uid: @uid)
52
- end
53
-
54
- def task_key(id) = key!("task", id)
55
- def assignment_key(id) = key!("assignment", id)
56
-
57
- def selection!
58
- with_root do |handles, identities|
59
- stat = handles.fetch(@root).stat
60
- @identity ||= {"device" => stat.dev, "inode" => stat.ino, "uid" => stat.uid, "gid" => stat.gid}.freeze
61
- {"descriptor_sha256" => @descriptor, "root_identity" => @identity}.freeze
62
- end
63
- end
64
-
65
- # Invoked only after the existing complete registration admission.
66
- # A partial creation is unavailable, not a missing-fence reset grant.
67
- def provision_keys!(keys:)
68
- ordered = keys!(keys)
69
- with_root do |handles, identities|
70
- root = handles.fetch(@root)
71
- ordered.each do |key|
72
- lock_path, marker_path = paths(key)
73
- lock, created = create_or_open!(lock_path, "", root)
74
- begin
75
- validate_file!(lock_path, lock, empty: true)
76
- marker = if created
77
- create_or_open!(marker_path, Atoms::EvidenceDigest.canonical_json(
78
- {"key" => key, "removed" => false, "removed_at" => nil}), root).first
79
- else
80
- @files.open(marker_path, File::RDONLY | File::NOFOLLOW | File::NONBLOCK)
81
- end
82
- begin
83
- live_marker!(key, marker_path, marker)
84
- ensure
85
- marker&.close
86
- end
87
- ensure
88
- lock&.close
89
- end
90
- unchanged!(handles, identities)
91
- end
92
- end
93
- self
94
- rescue SystemCallError, IOError, Ace::Runtime::RuntimeUnavailableError
95
- unavailable!("Lifecycle control provisioning is unavailable")
96
- end
97
-
98
- def with_shared_multi(keys, &block) = with_locks(keys, File::LOCK_SH, &block)
99
- def with_shared(key, &block) = with_shared_multi([key], &block)
100
- def with_exclusive(key, deadline: nil, &block) = with_locks([key], File::LOCK_EX, deadline: deadline, &block)
101
-
102
- # The canonical writer calls this again immediately before CAS. It
103
- # authenticates the same held keys, not freshly reopened substitutes.
104
- def verify_unchanged!
105
- checks = Thread.current[:ace_assign_control_checks]&.fetch(object_id, nil)
106
- unavailable!("Lifecycle control is not held") unless checks && !checks.empty?
107
- checks.each(&:call)
108
- true
109
- end
110
-
111
- private
112
-
113
- def with_locks(keys, mode, deadline: nil)
114
- callback_error = nil
115
- ordered = keys!(keys)
116
- deadline!(deadline) if deadline
117
- with_root do |handles, identities|
118
- locks, markers, marker_facts, lock_facts = [], [], [], []
119
- begin
120
- ordered.each do |key|
121
- path, marker_path = paths(key)
122
- lock = @files.open(path, File::RDONLY | File::NOFOLLOW | File::NONBLOCK)
123
- locks << lock
124
- validate_file!(path, lock, empty: true)
125
- lock_facts << file_metadata(lock.stat)
126
- unless lock.flock(mode | File::LOCK_NB)
127
- raise AttemptErrors::MaintenanceBusy, "Lifecycle control is under maintenance"
128
- end
129
- marker = @files.open(marker_path, File::RDONLY | File::NOFOLLOW | File::NONBLOCK)
130
- markers << marker
131
- live_marker!(key, marker_path, marker)
132
- marker_facts << file_metadata(marker.stat)
133
- end
134
- deadline!(deadline) if deadline
135
- unchanged!(handles, identities)
136
- check = proc do
137
- ordered.each_with_index do |key, index|
138
- path, marker_path = paths(key)
139
- lock, marker = locks.fetch(index), markers.fetch(index)
140
- validate_file!(path, lock, empty: true)
141
- unless file_metadata(lock.stat) == lock_facts.fetch(index) &&
142
- file_metadata(marker.stat) == marker_facts.fetch(index)
143
- unavailable!("Lifecycle control key changed")
144
- end
145
- marker.rewind
146
- live_marker!(key, marker_path, marker)
147
- end
148
- unchanged!(handles, identities)
149
- end
150
- registry = Thread.current[:ace_assign_control_checks] ||= {}
151
- checks = registry[object_id] ||= []
152
- checks << check
153
- begin
154
- begin
155
- result = yield
156
- rescue Exception => error
157
- callback_error = error
158
- raise
159
- end
160
- verify_unchanged!
161
- ensure
162
- checks.pop
163
- registry.delete(object_id) if checks.empty?
164
- end
165
- deadline!(deadline) if deadline
166
- result
167
- ensure
168
- close_all!(locks + markers)
169
- end
170
- end
171
- rescue SystemCallError, IOError, Ace::Runtime::RuntimeUnavailableError
172
- raise if callback_error
173
- unavailable!("Lifecycle control admission is unavailable")
174
- end
175
-
176
- def with_root
177
- callback_error = nil
178
- handles, identities = {}, {}
179
- begin
180
- parts = @root.split("/").reject(&:empty?)
181
- (["/"] + parts.each_index.map { |index| "/" + parts.take(index + 1).join("/") }).each do |path|
182
- handle = @files.open(path, File::RDONLY | File::NOFOLLOW | File::NONBLOCK)
183
- handles[path] = handle
184
- unless handle.stat.directory? && file_identity(handle.stat) == file_identity(@files.lstat(path))
185
- unavailable!("Lifecycle control directory changed")
186
- end
187
- @protection.verify!(path, handle, directory: true)
188
- identities[path] = file_identity(handle.stat)
189
- end
190
- root = handles.fetch(@root).stat
191
- unless [root.uid, root.gid, root.mode & 0o7777] == [@uid, @gid, 0o700] &&
192
- (!@identity || [root.dev, root.ino, root.uid, root.gid] == @identity.values_at("device", "inode", "uid", "gid"))
193
- unavailable!("Lifecycle control original root differs")
194
- end
195
- begin
196
- result = yield handles, identities
197
- rescue Exception => error
198
- callback_error = error
199
- raise
200
- end
201
- unchanged!(handles, identities)
202
- result
203
- ensure
204
- close_all!(handles.values)
205
- end
206
- rescue SystemCallError, IOError, Ace::Runtime::RuntimeUnavailableError
207
- raise if callback_error
208
- unavailable!("Lifecycle control root is unavailable")
209
- end
210
-
211
- def unchanged!(handles, identities)
212
- handles.each do |path, handle|
213
- unless file_identity(handle.stat) == identities.fetch(path) &&
214
- identities.fetch(path) == file_identity(@files.lstat(path))
215
- unavailable!("Lifecycle control directory changed")
216
- end
217
- @protection.verify!(path, handle, directory: true)
218
- end
219
- end
220
-
221
- def create_or_open!(path, bytes, root)
222
- created = true
223
- file = begin
224
- @files.open(path, File::RDWR | File::CREAT | File::EXCL | File::NOFOLLOW | File::NONBLOCK, 0o600)
225
- rescue Errno::EEXIST
226
- created = false
227
- @files.open(path, File::RDONLY | File::NOFOLLOW | File::NONBLOCK)
228
- end
229
- if created
230
- file.write(bytes)
231
- file.flush
232
- file.fsync
233
- root.fsync
234
- end
235
- file.rewind
236
- [file, created]
237
- rescue Exception
238
- file&.close
239
- raise
240
- end
241
-
242
- def validate_file!(path, file, empty: false)
243
- stat = file.stat
244
- unless stat.file? && stat.nlink == 1 && [stat.uid, stat.gid, stat.mode & 0o7777] == [@uid, @gid, 0o600] &&
245
- file_identity(stat) == file_identity(@files.lstat(path)) && (empty ? stat.size.zero? : stat.size.between?(1, 8192))
246
- unavailable!("Lifecycle control file differs")
247
- end
248
- @protection.verify!(path, file, directory: false)
249
- end
250
-
251
- def live_marker!(key, path, file)
252
- validate_file!(path, file)
253
- before = file.stat
254
- bytes = file.read(8193)
255
- state = LifecycleExclusion.workspace_marker!(bytes: bytes, key: key)
256
- unless bytes.bytesize == before.size && file_metadata(before) == file_metadata(file.stat) &&
257
- file_metadata(before) == file_metadata(@files.lstat(path))
258
- unavailable!("Lifecycle control marker changed")
259
- end
260
- raise AttemptErrors::Conflict, "Lifecycle control identity is fenced" if state.fetch("removed")
261
- end
262
-
263
- def keys!(keys)
264
- values = Array(keys).uniq
265
- unless values.any? && values.all? { |key| key.is_a?(String) && key.match?(/\A(?:task|assignment):[A-Za-z0-9][A-Za-z0-9._-]{0,127}\z/) }
266
- unavailable!("Lifecycle control keys differ")
267
- end
268
- values.sort_by { |key| [key.start_with?("task:") ? 0 : 1, key] }
269
- end
270
-
271
- def key!(kind, id)
272
- unavailable!("Lifecycle control key differs") unless id.is_a?(String) && token?(id)
273
- "#{kind}:#{id}"
274
- end
275
- def token?(value) = value.match?(/\A[A-Za-z0-9][A-Za-z0-9._-]{0,127}\z/)
276
- def paths(key)
277
- stem = File.join(@root, Digest::SHA256.hexdigest(key))
278
- [stem + ".lock", stem + ".state.json"]
279
- end
280
- def file_identity(stat) = [stat.dev, stat.ino, stat.uid, stat.gid, stat.mode]
281
- def file_metadata(stat) = file_identity(stat) + [stat.size, stat.mtime, stat.ctime]
282
- def unavailable!(message) = raise(AttemptErrors::EvidenceUnavailable, message)
283
- def deadline!(deadline)
284
- unless deadline.is_a?(Numeric) && deadline.finite? && deadline > Process.clock_gettime(Process::CLOCK_MONOTONIC)
285
- raise AttemptErrors::MaintenanceBusy, "Lifecycle control admission deadline elapsed"
286
- end
287
- end
288
- def close_all!(files)
289
- error = nil
290
- files.reverse_each do |file|
291
- begin
292
- file.close unless file.closed?
293
- rescue SystemCallError, IOError => caught
294
- error ||= caught
295
- end
296
- end
297
- raise error if error
298
- end
299
- end
300
- end
301
- end
302
- end
303
- end