ace-assign 0.64.0 → 0.66.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.ace-defaults/assign/catalog/steps/auto-merge.step.yml +11 -0
- data/CHANGELOG.md +25 -0
- data/docs/protected-authority.md +23 -7
- data/docs/usage.md +117 -4
- data/exe/ace-scope-ready +55 -0
- data/handbook/workflow-instructions/assign/drive.wf.md +26 -1
- data/lib/ace/assign/atoms/attempt_state_machine.rb +8 -0
- data/lib/ace/assign/atoms/delivery_parameters.rb +4 -39
- data/lib/ace/assign/authority/assignment_inventory.rb +290 -0
- data/lib/ace/assign/authority/campaign_consumer_policy.rb +100 -0
- data/lib/ace/assign/authority/campaign_execution.rb +119 -0
- data/lib/ace/assign/authority/campaign_round_transfer.rb +82 -0
- data/lib/ace/assign/authority/candidate_transfer.rb +169 -2
- data/lib/ace/assign/authority/cleanup_result_protection.rb +53 -0
- data/lib/ace/assign/authority/client.rb +156 -6
- data/lib/ace/assign/authority/deployment.rb +201 -39
- data/lib/ace/assign/authority/deployment_history.rb +146 -0
- data/lib/ace/assign/authority/endcap.rb +131 -18
- data/lib/ace/assign/authority/endcap_attempts.rb +274 -0
- data/lib/ace/assign/authority/endcap_campaign_results.rb +232 -0
- data/lib/ace/assign/authority/endcap_campaign_rounds.rb +266 -0
- data/lib/ace/assign/authority/endcap_contexts.rb +23 -0
- data/lib/ace/assign/authority/endcap_inboxes.rb +3 -280
- data/lib/ace/assign/authority/endcap_prepared_work.rb +80 -0
- data/lib/ace/assign/authority/endcap_publication.rb +207 -0
- data/lib/ace/assign/authority/endcap_results.rb +36 -18
- data/lib/ace/assign/authority/endcap_review_requests.rb +183 -0
- data/lib/ace/assign/authority/endcap_reviews.rb +130 -0
- data/lib/ace/assign/authority/endcap_service_settlement.rb +152 -0
- data/lib/ace/assign/authority/endcap_services.rb +221 -18
- data/lib/ace/assign/authority/execution_scope_observation.rb +468 -41
- data/lib/ace/assign/authority/inbox_context_original.rb +113 -0
- data/lib/ace/assign/authority/launch_attempt_consumers.rb +51 -0
- data/lib/ace/assign/authority/launch_campaign_registration.rb +214 -0
- data/lib/ace/assign/authority/launch_control_channel.rb +313 -0
- data/lib/ace/assign/authority/launch_control_exclusions.rb +141 -0
- data/lib/ace/assign/authority/launch_driver.rb +298 -19
- data/lib/ace/assign/authority/launch_input_inhibition.rb +132 -0
- data/lib/ace/assign/authority/launch_lifecycle.rb +275 -100
- data/lib/ace/assign/authority/launch_prepared_work.rb +122 -0
- data/lib/ace/assign/authority/launch_review.rb +91 -0
- data/lib/ace/assign/authority/launch_scope_abort.rb +0 -7
- data/lib/ace/assign/authority/launch_scope_admission.rb +136 -5
- data/lib/ace/assign/authority/launch_scope_close.rb +68 -37
- data/lib/ace/assign/authority/launch_scope_release.rb +696 -17
- data/lib/ace/assign/authority/launch_steering.rb +353 -0
- data/lib/ace/assign/authority/launch_stop.rb +254 -0
- data/lib/ace/assign/authority/maintenance_workspace_retirement.rb +229 -0
- data/lib/ace/assign/authority/original_inbox_selection.rb +41 -0
- data/lib/ace/assign/authority/posix_acl.rb +5 -2
- data/lib/ace/assign/authority/prepared_input.rb +130 -0
- data/lib/ace/assign/authority/prepared_queue.rb +185 -0
- data/lib/ace/assign/authority/prepared_task_context.rb +116 -0
- data/lib/ace/assign/authority/prepared_work.rb +304 -0
- data/lib/ace/assign/authority/prepared_worker.rb +69 -0
- data/lib/ace/assign/authority/protected_assignment_context.rb +226 -0
- data/lib/ace/assign/authority/readiness_hook.rb +120 -0
- data/lib/ace/assign/authority/router.rb +24 -2
- data/lib/ace/assign/authority/server.rb +103 -33
- data/lib/ace/assign/authority/service_cleanup_dispatch.rb +88 -0
- data/lib/ace/assign/authority/service_cleanup_evidence.rb +180 -0
- data/lib/ace/assign/authority/service_delivery_evidence.rb +101 -0
- data/lib/ace/assign/authority/service_evidence.rb +243 -12
- data/lib/ace/assign/authority/service_publication_evidence.rb +110 -0
- data/lib/ace/assign/authority/task_context_entry.rb +32 -0
- data/lib/ace/assign/authority/transfer_codec.rb +44 -12
- data/lib/ace/assign/cli/commands/assignment_target.rb +15 -2
- data/lib/ace/assign/cli/commands/attempt/base.rb +49 -0
- data/lib/ace/assign/cli/commands/attempt/finish.rb +20 -4
- data/lib/ace/assign/cli/commands/attempt/reconcile.rb +11 -0
- data/lib/ace/assign/cli/commands/authority/inbox_context_principal.rb +25 -0
- data/lib/ace/assign/cli/commands/authority/inbox_context_selection.rb +28 -0
- data/lib/ace/assign/cli/commands/authority/launch.rb +58 -6
- data/lib/ace/assign/cli/commands/authority/serve.rb +2 -1
- data/lib/ace/assign/cli/commands/authority/status.rb +1 -1
- data/lib/ace/assign/cli/commands/authority/task_context.rb +31 -0
- data/lib/ace/assign/cli/commands/authority/task_context_principal.rb +25 -0
- data/lib/ace/assign/cli/commands/authority/task_context_selection.rb +30 -0
- data/lib/ace/assign/cli/commands/authority/worker.rb +23 -0
- data/lib/ace/assign/cli/commands/campaign_export_result.rb +55 -0
- data/lib/ace/assign/cli/commands/campaign_record_round.rb +57 -0
- data/lib/ace/assign/cli/commands/create.rb +2 -0
- data/lib/ace/assign/cli/commands/delivery.rb +54 -4
- data/lib/ace/assign/cli/commands/fail.rb +6 -0
- data/lib/ace/assign/cli/commands/finish.rb +23 -2
- data/lib/ace/assign/cli/commands/fork_session.rb +6 -2
- data/lib/ace/assign/cli/commands/inbox_bind.rb +34 -0
- data/lib/ace/assign/cli/commands/protected_submission.rb +61 -0
- data/lib/ace/assign/cli/commands/resume.rb +12 -0
- data/lib/ace/assign/cli/commands/select.rb +2 -0
- data/lib/ace/assign/cli/commands/start.rb +2 -0
- data/lib/ace/assign/cli/commands/status.rb +4 -2
- data/lib/ace/assign/cli/commands/step.rb +3 -1
- data/lib/ace/assign/cli/commands/submit_candidate.rb +34 -0
- data/lib/ace/assign/cli/commands/submit_result.rb +61 -0
- data/lib/ace/assign/cli.rb +36 -5
- data/lib/ace/assign/errors.rb +139 -0
- data/lib/ace/assign/models/assignment.rb +26 -4
- data/lib/ace/assign/models/evidence_event.rb +3 -3
- data/lib/ace/assign/molecules/assignment_manager.rb +12 -2
- data/lib/ace/assign/molecules/attempt_reconciler.rb +20 -0
- data/lib/ace/assign/molecules/canonical_attempt_state.rb +151 -0
- data/lib/ace/assign/molecules/canonical_evidence.rb +5 -2
- data/lib/ace/assign/molecules/canonical_read_snapshot.rb +374 -0
- data/lib/ace/assign/molecules/evidence_journal.rb +720 -44
- data/lib/ace/assign/molecules/execution_scope_lineage.rb +27 -9
- data/lib/ace/assign/molecules/fork_session_launcher.rb +16 -6
- data/lib/ace/assign/molecules/journal_input_inhibition.rb +78 -0
- data/lib/ace/assign/molecules/journal_mutation.rb +82 -13
- data/lib/ace/assign/molecules/journal_prompt_mutation.rb +241 -0
- data/lib/ace/assign/molecules/lifecycle_exclusion.rb +178 -26
- data/lib/ace/assign/molecules/original_launch_binding.rb +37 -0
- data/lib/ace/assign/molecules/protected_control_exclusion.rb +303 -0
- data/lib/ace/assign/molecules/protected_workspace_exclusion.rb +330 -0
- data/lib/ace/assign/molecules/protected_workspace_provisioner.rb +164 -0
- data/lib/ace/assign/molecules/queue_scanner.rb +26 -11
- data/lib/ace/assign/molecules/receipt_verifier.rb +21 -15
- data/lib/ace/assign/molecules/step_writer.rb +27 -5
- data/lib/ace/assign/molecules/terminal_scope_receipt.rb +88 -0
- data/lib/ace/assign/organisms/assignment_executor.rb +4 -4
- data/lib/ace/assign/organisms/attempt_coordinator.rb +66 -76
- data/lib/ace/assign/organisms/prepared_work_builder.rb +194 -0
- data/lib/ace/assign/organisms/protected_delivery_coordinator.rb +100 -0
- data/lib/ace/assign/organisms/task_assignment_creator.rb +4 -2
- data/lib/ace/assign/version.rb +1 -1
- data/lib/ace/assign.rb +1 -112
- metadata +90 -11
- data/lib/ace/assign/cli/commands/inbox_reconcile.rb +0 -36
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
require_relative "../molecules/execution_scope_lineage"
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Ace
|
|
6
|
+
module Assign
|
|
7
|
+
module Authority
|
|
8
|
+
# Source capabilities only. A serialized descriptor is not admission.
|
|
9
|
+
module MaintenanceWorkspaceRetirement
|
|
10
|
+
SCHEMA = "ace.maintenance-workspace-retirement/v1"
|
|
11
|
+
TARGET_FIELDS = %w[project_id mapping_id assignment_id attempt_id resource journal_commit binding_event_digest release_event_digest descriptor_sha256].freeze
|
|
12
|
+
INVOCATION_FIELDS = %w[request_id input_digest operation_owner_binding_digest dispatch_event_digest].freeze
|
|
13
|
+
REFERENCES = {
|
|
14
|
+
"captured" => %w[intent captured preservation],
|
|
15
|
+
"removed" => %w[intent captured preservation removal fence],
|
|
16
|
+
"completed" => %w[intent captured preservation removal fence receipt]
|
|
17
|
+
}.freeze
|
|
18
|
+
|
|
19
|
+
def self.immutable(value)
|
|
20
|
+
case value
|
|
21
|
+
when Hash then value.to_h { |key, item| [key.dup.freeze, immutable(item)] }.freeze
|
|
22
|
+
when Array then value.map { |item| immutable(item) }.freeze
|
|
23
|
+
when String then value.dup.freeze
|
|
24
|
+
else value.freeze
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def self.unavailable!(message)
|
|
29
|
+
raise AttemptErrors::EvidenceUnavailable, message
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
class Evidence
|
|
33
|
+
attr_reader :descriptor, :owner
|
|
34
|
+
|
|
35
|
+
# Only the installed adapter mints this after validating its complete
|
|
36
|
+
# collection. Consumers additionally bind the exact adapter instance,
|
|
37
|
+
# original canonical projection and held operation before using it.
|
|
38
|
+
def self.mint!(owner:, descriptor:)
|
|
39
|
+
new(owner: owner, descriptor: descriptor)
|
|
40
|
+
end
|
|
41
|
+
private_class_method :new
|
|
42
|
+
|
|
43
|
+
def initialize(owner:, descriptor:)
|
|
44
|
+
@owner = owner
|
|
45
|
+
MaintenanceWorkspaceRetirement.unavailable!("retirement evidence descriptor is not an object") unless descriptor.is_a?(Hash)
|
|
46
|
+
@descriptor = MaintenanceWorkspaceRetirement.immutable(descriptor)
|
|
47
|
+
@thread = Thread.current
|
|
48
|
+
@consumption = nil
|
|
49
|
+
@expired = false
|
|
50
|
+
validate_descriptor!
|
|
51
|
+
rescue KeyError, TypeError, ArgumentError
|
|
52
|
+
MaintenanceWorkspaceRetirement.unavailable!("retirement evidence descriptor differs")
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def verify!(owner:, target:, projection:, phase:, deadline:)
|
|
56
|
+
unless !@expired && @thread.equal?(Thread.current) && @owner.equal?(owner) && descriptor.fetch("target") == target &&
|
|
57
|
+
descriptor.fetch("phase") == phase && descriptor.fetch("resource") == projection.fetch("workspace_resource")
|
|
58
|
+
MaintenanceWorkspaceRetirement.unavailable!("retirement evidence original owner or selection differs")
|
|
59
|
+
end
|
|
60
|
+
identities = projection.fetch("resource_identities")
|
|
61
|
+
root = projection.fetch("worker_cwd")
|
|
62
|
+
expected = projection.fetch("parent_resource_declarations").filter_map do |declaration|
|
|
63
|
+
path = declaration.fetch("host_path")
|
|
64
|
+
next unless declaration.fetch("stage") == "parent" && (path == root || path.start_with?(root + "/"))
|
|
65
|
+
matches = identities.select { |row| row.values_at("host_path", "view_path") == declaration.values_at("host_path", "view_path") }
|
|
66
|
+
MaintenanceWorkspaceRetirement.unavailable!("retirement declared row has no unique original identity") unless matches.one?
|
|
67
|
+
matches.first
|
|
68
|
+
end.sort_by { |row| row.values_at("host_path", "view_path") }
|
|
69
|
+
unless descriptor.fetch("covered_resources").map { |row| row.fetch("original") } == expected
|
|
70
|
+
MaintenanceWorkspaceRetirement.unavailable!("retirement evidence covered resources are not exhaustive")
|
|
71
|
+
end
|
|
72
|
+
@owner.verify_retirement_evidence!(evidence: self, projection: projection, deadline: deadline)
|
|
73
|
+
true
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def expire! = @expired = true
|
|
77
|
+
|
|
78
|
+
def covered_resource(path)
|
|
79
|
+
descriptor.fetch("covered_resources").find { |entry| entry.fetch("original").fetch("host_path") == path }
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def verify_consumption!
|
|
83
|
+
unless !@expired && @consumption && @thread.equal?(Thread.current)
|
|
84
|
+
MaintenanceWorkspaceRetirement.unavailable!("retirement evidence is outside its held observation")
|
|
85
|
+
end
|
|
86
|
+
true
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def with_consumption(verification)
|
|
90
|
+
MaintenanceWorkspaceRetirement.unavailable!("retirement observation cannot be nested") if @consumption
|
|
91
|
+
installed = true
|
|
92
|
+
@consumption = verification
|
|
93
|
+
verify_consumption!
|
|
94
|
+
verification.call
|
|
95
|
+
result = yield
|
|
96
|
+
verify_consumption!
|
|
97
|
+
verification.call
|
|
98
|
+
result
|
|
99
|
+
ensure
|
|
100
|
+
@consumption = nil if installed
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
private
|
|
104
|
+
|
|
105
|
+
def validate_descriptor!
|
|
106
|
+
fields = %w[schema phase target resource covered_resources invocation refs]
|
|
107
|
+
target = descriptor.fetch("target")
|
|
108
|
+
invocation = descriptor.fetch("invocation")
|
|
109
|
+
phase = descriptor.fetch("phase")
|
|
110
|
+
unless descriptor.keys.sort == fields.sort && descriptor.fetch("schema") == SCHEMA && REFERENCES.key?(phase) &&
|
|
111
|
+
target.is_a?(Hash) && target.keys.sort == TARGET_FIELDS.sort &&
|
|
112
|
+
invocation.is_a?(Hash) && invocation.keys.sort == INVOCATION_FIELDS.sort
|
|
113
|
+
MaintenanceWorkspaceRetirement.unavailable!("retirement evidence is not a closed supported descriptor")
|
|
114
|
+
end
|
|
115
|
+
resource!(descriptor.fetch("resource"))
|
|
116
|
+
unless target.fetch("resource") == "workspace:#{target.fetch('project_id')}:#{target.fetch('mapping_id')}:#{target.fetch('assignment_id')}" &&
|
|
117
|
+
%w[project_id mapping_id assignment_id attempt_id].all? { |key| token?(target[key]) } &&
|
|
118
|
+
target.fetch("journal_commit").is_a?(String) && target.fetch("journal_commit").match?(/\A[0-9a-f]{40}\z/) &&
|
|
119
|
+
%w[binding_event_digest release_event_digest descriptor_sha256].all? { |key| digest?(target[key]) } &&
|
|
120
|
+
token?(invocation.fetch("request_id")) && (INVOCATION_FIELDS - ["request_id"]).all? { |key| digest?(invocation[key]) }
|
|
121
|
+
MaintenanceWorkspaceRetirement.unavailable!("retirement evidence selectors differ")
|
|
122
|
+
end
|
|
123
|
+
covered = descriptor.fetch("covered_resources")
|
|
124
|
+
unless covered.is_a?(Array) && covered.size.between?(1, 64)
|
|
125
|
+
MaintenanceWorkspaceRetirement.unavailable!("retirement evidence covered inventory differs")
|
|
126
|
+
end
|
|
127
|
+
covered.each do |entry|
|
|
128
|
+
unless entry.is_a?(Hash) && entry.keys.sort == %w[captured_host_path original] && path?(entry["captured_host_path"])
|
|
129
|
+
MaintenanceWorkspaceRetirement.unavailable!("retirement evidence covered row differs")
|
|
130
|
+
end
|
|
131
|
+
resource!(entry.fetch("original"))
|
|
132
|
+
end
|
|
133
|
+
unless covered.map { |entry| entry.fetch("original") }.uniq.size == covered.size &&
|
|
134
|
+
covered.map { |entry| entry.fetch("captured_host_path") }.uniq.size == covered.size
|
|
135
|
+
MaintenanceWorkspaceRetirement.unavailable!("retirement evidence covered rows repeat")
|
|
136
|
+
end
|
|
137
|
+
roots = covered.select { |entry| entry.fetch("original") == descriptor.fetch("resource") }
|
|
138
|
+
unless roots.one? && covered.all? { |entry|
|
|
139
|
+
original = entry.fetch("original").fetch("host_path")
|
|
140
|
+
root = descriptor.fetch("resource").fetch("host_path")
|
|
141
|
+
(original == root || original.start_with?(root + "/")) &&
|
|
142
|
+
entry.fetch("captured_host_path") == roots.first.fetch("captured_host_path") + original.delete_prefix(root) }
|
|
143
|
+
MaintenanceWorkspaceRetirement.unavailable!("retirement evidence captured paths do not match exact relative resources")
|
|
144
|
+
end
|
|
145
|
+
refs = descriptor.fetch("refs")
|
|
146
|
+
unless refs.is_a?(Hash) && refs.keys.sort == REFERENCES.fetch(phase).sort
|
|
147
|
+
MaintenanceWorkspaceRetirement.unavailable!("retirement evidence reference set differs")
|
|
148
|
+
end
|
|
149
|
+
refs.each_value do |ref|
|
|
150
|
+
unless ref.is_a?(Hash) && ref.keys.sort == %w[bytes path sha256] && path?(ref["path"]) && digest?(ref["sha256"]) &&
|
|
151
|
+
ref["bytes"].is_a?(Integer) && ref["bytes"].between?(1, 64 * 1024 * 1024)
|
|
152
|
+
MaintenanceWorkspaceRetirement.unavailable!("retirement evidence reference differs")
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def token?(value) = value.is_a?(String) && value.match?(/\A[A-Za-z0-9][A-Za-z0-9._:-]{0,127}\z/)
|
|
158
|
+
def digest?(value) = value.is_a?(String) && value.match?(/\A[0-9a-f]{64}\z/)
|
|
159
|
+
def path?(value)
|
|
160
|
+
value.is_a?(String) && value.valid_encoding? && value.bytesize.between?(1, 4096) &&
|
|
161
|
+
value.start_with?("/") && !value.include?("\0") && File.expand_path(value) == value
|
|
162
|
+
end
|
|
163
|
+
def resource!(value)
|
|
164
|
+
unless value.is_a?(Hash) && value.keys.sort == Molecules::ExecutionScopeLineage::RESOURCE_FIELDS.sort &&
|
|
165
|
+
%w[host_path view_path].all? { |key| path?(value[key]) } &&
|
|
166
|
+
%w[mount_id device inode].all? { |key| value[key].is_a?(Integer) && value[key].positive? } &&
|
|
167
|
+
%w[uid gid].all? { |key| value[key].is_a?(Integer) && value[key] >= 0 } &&
|
|
168
|
+
%w[ext4 xfs btrfs tmpfs].include?(value["filesystem_type"])
|
|
169
|
+
MaintenanceWorkspaceRetirement.unavailable!("retirement evidence resource identity differs")
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
class Session
|
|
174
|
+
attr_reader :projection
|
|
175
|
+
def self.open!(**arguments) = new(**arguments)
|
|
176
|
+
private_class_method :new
|
|
177
|
+
|
|
178
|
+
def initialize(lifecycle:, mapping_id:, journal:, commit:, target:, projection:, evidence_owner:, deadline:, contexts:)
|
|
179
|
+
@lifecycle, @mapping_id, @journal, @commit = lifecycle, mapping_id, journal, commit
|
|
180
|
+
@target, @projection = MaintenanceWorkspaceRetirement.immutable(target), projection
|
|
181
|
+
@owner, @deadline, @contexts = evidence_owner, deadline, contexts
|
|
182
|
+
@thread, @active, @phase = Thread.current, true, nil
|
|
183
|
+
@evidences = []
|
|
184
|
+
@writer = @owner.retirement_workspace_writer!
|
|
185
|
+
unless @writer.is_a?(Molecules::LifecycleExclusion::WorkspaceWriter) && @writer.projection == projection.fetch("workspace_exclusion")
|
|
186
|
+
MaintenanceWorkspaceRetirement.unavailable!("retirement session requires the original maintained workspace writer")
|
|
187
|
+
end
|
|
188
|
+
verify_held!
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
def verify_captured!(evidence:) = verify_phase!("captured", evidence)
|
|
192
|
+
def verify_removed!(evidence:) = verify_phase!("removed", evidence)
|
|
193
|
+
def close!
|
|
194
|
+
@active = false
|
|
195
|
+
@evidences.each(&:expire!)
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
private
|
|
199
|
+
|
|
200
|
+
def verify_held!
|
|
201
|
+
unless @active && @thread.equal?(Thread.current) &&
|
|
202
|
+
Thread.current[:ace_assign_maintenance_contexts]&.[](@lifecycle.object_id).equal?(@contexts)
|
|
203
|
+
MaintenanceWorkspaceRetirement.unavailable!("retirement session no longer owns complete maintenance contexts")
|
|
204
|
+
end
|
|
205
|
+
@lifecycle.send(:maintenance_deadline!, @deadline)
|
|
206
|
+
@lifecycle.send(:require_maintenance_context!, @mapping_id, @journal, @commit)
|
|
207
|
+
@writer.verify_unchanged!
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
def verify_phase!(phase, evidence)
|
|
211
|
+
unless evidence.is_a?(Evidence) && (phase == "captured" && @phase.nil? || phase == "removed" && @phase == "captured")
|
|
212
|
+
MaintenanceWorkspaceRetirement.unavailable!("retirement session phase or evidence differs")
|
|
213
|
+
end
|
|
214
|
+
@evidences << evidence
|
|
215
|
+
verify = -> {
|
|
216
|
+
verify_held!
|
|
217
|
+
evidence.verify!(owner: @owner, target: @target, projection: @projection, phase: phase, deadline: @deadline)
|
|
218
|
+
}
|
|
219
|
+
evidence.with_consumption(verify) do
|
|
220
|
+
@lifecycle.send(:verify_retirement_contexts!, evidence: evidence, deadline: @deadline)
|
|
221
|
+
end
|
|
222
|
+
@phase = phase
|
|
223
|
+
true
|
|
224
|
+
end
|
|
225
|
+
end
|
|
226
|
+
end
|
|
227
|
+
end
|
|
228
|
+
end
|
|
229
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ace
|
|
4
|
+
module Assign
|
|
5
|
+
module Authority
|
|
6
|
+
# Original descriptor selection shared by canonical Inbox consumers.
|
|
7
|
+
# The held historical manifest/key owner supplies the exact artifact.
|
|
8
|
+
class OriginalInboxSelection
|
|
9
|
+
def initialize(history:, authority_id:)
|
|
10
|
+
@history, @authority_id = history, authority_id
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def load!(events:, params:, map:, journal:, commit:)
|
|
14
|
+
provisioning = events.select { |event| event["type"] == "scope_provisioning" }
|
|
15
|
+
reservations = events.select { |event| event["type"] == "authority_mutation" && event.dig("payload", "operation") == "reserve_attempt" }
|
|
16
|
+
unless provisioning.one? && reservations.one?
|
|
17
|
+
raise AttemptErrors::EvidenceUnavailable, "context completion original selection is ambiguous"
|
|
18
|
+
end
|
|
19
|
+
identity = provisioning.first.fetch("payload")
|
|
20
|
+
reserved = reservations.first.fetch("payload").fetch("data")
|
|
21
|
+
original = @history.descriptor!(sha256: identity.fetch("descriptor_sha256"))
|
|
22
|
+
selected = original.mapping(params.fetch("mapping_id"))
|
|
23
|
+
unless identity.keys.sort == %w[deployment_digest descriptor_sha256 reservation_generation slot_id] &&
|
|
24
|
+
reserved.values_at("project_id", "assignment_id", "attempt_id", "mapping_id") ==
|
|
25
|
+
[map.fetch("project_id"), params.fetch("assignment_id"), params.fetch("attempt_id"), params.fetch("mapping_id")] &&
|
|
26
|
+
identity.fetch("reservation_generation") == reserved.fetch("reservation_generation") &&
|
|
27
|
+
identity.fetch("deployment_digest") == original.mapping_digest(params.fetch("mapping_id")) &&
|
|
28
|
+
selected.fetch("project_id") == map.fetch("project_id") && selected.fetch("authority_id") == @authority_id &&
|
|
29
|
+
identity.fetch("slot_id") == selected.fetch("execution_scope").fetch("slot_id") &&
|
|
30
|
+
original.project(selected.fetch("project_id")).values_at("journal_repository", "evidence_git_ref", "evidence_checkout_root") ==
|
|
31
|
+
[journal.repo_root, journal.ref, journal.checkout_root]
|
|
32
|
+
raise AttemptErrors::EvidenceUnavailable, "context completion original association differs"
|
|
33
|
+
end
|
|
34
|
+
journal.event_commit!(assignment_id: params.fetch("assignment_id"), event_digest: provisioning.first.fetch("digest"), commit: commit)
|
|
35
|
+
[original, selected]
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -7,12 +7,15 @@ module Ace
|
|
|
7
7
|
# Linux POSIX access ACL evaluation for a fixed unprivileged principal.
|
|
8
8
|
# No credential switching; unsupported or unreadable policy refuses admission.
|
|
9
9
|
class PosixAcl
|
|
10
|
-
def entries(path)
|
|
10
|
+
def entries(path, attribute: "system.posix_acl_access")
|
|
11
|
+
unless %w[system.posix_acl_access system.posix_acl_default].include?(attribute)
|
|
12
|
+
raise ArgumentError, "unsupported POSIX ACL attribute"
|
|
13
|
+
end
|
|
11
14
|
raise Ace::Runtime::RuntimeUnavailableError, "receiver ACL inspection requires Linux" unless RUBY_PLATFORM.include?("linux")
|
|
12
15
|
function = Fiddle::Function.new(Fiddle::Handle::DEFAULT["lgetxattr"],
|
|
13
16
|
[Fiddle::TYPE_VOIDP, Fiddle::TYPE_VOIDP, Fiddle::TYPE_VOIDP, Fiddle::TYPE_SIZE_T], Fiddle::TYPE_SSIZE_T)
|
|
14
17
|
buffer = Fiddle::Pointer.malloc(8192, Fiddle::RUBY_FREE)
|
|
15
|
-
count = function.call(path,
|
|
18
|
+
count = function.call(path, attribute, buffer, 8192)
|
|
16
19
|
return nil if count == -1 && Fiddle.last_error == Errno::ENODATA::Errno
|
|
17
20
|
raise Ace::Runtime::RuntimeUnavailableError, "receiver access ACL is unreadable" if count < 4 || (count - 4) % 8 != 0
|
|
18
21
|
bytes = buffer[0, count]
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "shellwords"
|
|
4
|
+
require_relative "client"
|
|
5
|
+
require_relative "prepared_work"
|
|
6
|
+
|
|
7
|
+
module Ace
|
|
8
|
+
module Assign
|
|
9
|
+
module Authority
|
|
10
|
+
# One authenticated original fetch and one immutable verified work graph.
|
|
11
|
+
# Caller identifiers select; the maintained Client/Endcap authenticates.
|
|
12
|
+
class PreparedInput
|
|
13
|
+
attr_reader :descriptor, :work, :working_directory
|
|
14
|
+
|
|
15
|
+
def self.fetch(client:, assignment_id:, attempt_id:)
|
|
16
|
+
reply = client.call("evidence_fetch", {"assignment_id" => assignment_id, "attempt_id" => attempt_id,
|
|
17
|
+
"kind" => "prepared_work", "purpose_id" => "original_prepared_work", "artifact_id" => "prepared_bundle"},
|
|
18
|
+
download: true, purpose: :candidate, timeout: 30)
|
|
19
|
+
new(reply: reply, candidate_loader: -> {
|
|
20
|
+
client.call("evidence_fetch", {"assignment_id" => assignment_id, "attempt_id" => attempt_id,
|
|
21
|
+
"kind" => "campaign_candidate", "purpose_id" => "original_campaign_candidate", "artifact_id" => "candidate_bundle"},
|
|
22
|
+
download: true, purpose: :candidate, timeout: 30)
|
|
23
|
+
})
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def initialize(reply:, candidate_reply: nil, candidate_loader: nil)
|
|
27
|
+
@descriptor = reply.data.fetch("descriptor")
|
|
28
|
+
Molecules::LifecycleExclusion.workspace_reader(projection: descriptor.fetch("workspace_exclusion"))
|
|
29
|
+
unless descriptor.dig("workspace_exclusion", "root_resource", "view_path") ==
|
|
30
|
+
"/run/ace/lifecycle-exclusion/#{descriptor.fetch('mapping_id')}"
|
|
31
|
+
raise AttemptErrors::EvidenceUnavailable, "prepared_input_unavailable: original lifecycle mapping differs"
|
|
32
|
+
end
|
|
33
|
+
unless reply.parts.is_a?(Array) && reply.parts.one?
|
|
34
|
+
raise AttemptErrors::EvidenceUnavailable, "prepared_input_unavailable: one original bundle required"
|
|
35
|
+
end
|
|
36
|
+
root = PrivateDirectory.verify!(descriptor.fetch("original_worker_scratch_root"))
|
|
37
|
+
@work = PreparedWork.admit(bytes: reply.parts.first, head: descriptor.fetch("prepared_head"),
|
|
38
|
+
tree: descriptor.fetch("prepared_tree"), sha256: descriptor.fetch("sha256"), size: descriptor.fetch("bytes"), root: root)
|
|
39
|
+
expected = work.reference(head: descriptor.fetch("prepared_head"), tree: descriptor.fetch("prepared_tree"))
|
|
40
|
+
definition = work.definition_bytes(head: descriptor.fetch("prepared_head"), tree: descriptor.fetch("prepared_tree"))
|
|
41
|
+
unless expected == descriptor.slice(*expected.keys) &&
|
|
42
|
+
descriptor.values_at("assignment_id", "project_id") == work.manifest.values_at("assignment_id", "project_id") &&
|
|
43
|
+
Digest::SHA256.hexdigest(definition) == descriptor.fetch("definition_digest")
|
|
44
|
+
raise AttemptErrors::EvidenceUnavailable, "prepared_input_mismatch: original graph association"
|
|
45
|
+
end
|
|
46
|
+
execution = work.definition["campaign_execution"]
|
|
47
|
+
if execution
|
|
48
|
+
candidate_reply ||= candidate_loader&.call
|
|
49
|
+
candidate = candidate_reply&.data&.fetch("descriptor")
|
|
50
|
+
unless candidate.is_a?(Hash) && candidate.keys.sort == %w[head tree bytes sha256 candidate_generation campaign_execution mapping_id assignment_id attempt_id project_id original_worker_identity original_worker_scratch_root original_binding_digest].sort &&
|
|
51
|
+
candidate.slice("mapping_id", "assignment_id", "attempt_id", "project_id", "original_worker_identity", "original_worker_scratch_root", "original_binding_digest") ==
|
|
52
|
+
descriptor.slice("mapping_id", "assignment_id", "attempt_id", "project_id", "original_worker_identity", "original_worker_scratch_root", "original_binding_digest") &&
|
|
53
|
+
candidate.fetch("campaign_execution") == execution && candidate.fetch("head") == execution.fetch("head") &&
|
|
54
|
+
candidate.fetch("candidate_generation") == execution.fetch("parent_candidate_generation") &&
|
|
55
|
+
candidate_reply.parts.is_a?(Array) && candidate_reply.parts.one?
|
|
56
|
+
raise AttemptErrors::EvidenceUnavailable, "campaign original candidate input differs"
|
|
57
|
+
end
|
|
58
|
+
materialized = CandidateTransfer.new(root: root).materialize_repository(bytes: candidate_reply.parts.first,
|
|
59
|
+
sha256: candidate.fetch("sha256"), size: candidate.fetch("bytes"), head: candidate.fetch("head"),
|
|
60
|
+
tree: candidate.fetch("tree"), root: root)
|
|
61
|
+
@working_directory = materialized.fetch("directory").freeze
|
|
62
|
+
elsif candidate_reply
|
|
63
|
+
raise AttemptErrors::EvidenceUnavailable, "ordinary prepared input forbids a campaign candidate"
|
|
64
|
+
end
|
|
65
|
+
@descriptor = JSON.parse(JSON.generate(descriptor))
|
|
66
|
+
pending = [@descriptor]
|
|
67
|
+
until pending.empty?
|
|
68
|
+
value = pending.pop
|
|
69
|
+
pending.concat(value.values) if value.is_a?(Hash)
|
|
70
|
+
pending.concat(value) if value.is_a?(Array)
|
|
71
|
+
value.freeze
|
|
72
|
+
end
|
|
73
|
+
freeze
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def render(text)
|
|
77
|
+
values = descriptor.slice("mapping_id", "assignment_id", "scope", "attempt_id")
|
|
78
|
+
rendered = +""
|
|
79
|
+
cursor = 0
|
|
80
|
+
# Only an opening delimiter introduces a token. Closing braces in
|
|
81
|
+
# literal JSON are preserved; inserted selector values are not parsed.
|
|
82
|
+
while (opening = text.index("{{", cursor))
|
|
83
|
+
rendered << text[cursor...opening]
|
|
84
|
+
closing = text.index("}}", opening + 2)
|
|
85
|
+
unless closing
|
|
86
|
+
raise AttemptErrors::EvidenceUnavailable, "prepared_input_invalid: unresolved instruction token"
|
|
87
|
+
end
|
|
88
|
+
token = text[(opening + 2)...closing]
|
|
89
|
+
if token.include?("{") || token.include?("}")
|
|
90
|
+
raise AttemptErrors::EvidenceUnavailable, "prepared_input_invalid: unresolved instruction token"
|
|
91
|
+
end
|
|
92
|
+
field = token.delete_prefix("admitted.")
|
|
93
|
+
unless token == "admitted." + field && values.key?(field)
|
|
94
|
+
raise AttemptErrors::EvidenceUnavailable, "prepared_input_invalid: unknown instruction token"
|
|
95
|
+
end
|
|
96
|
+
rendered << Shellwords.escape(values.fetch(field))
|
|
97
|
+
cursor = closing + 2
|
|
98
|
+
end
|
|
99
|
+
rendered << text[cursor..]
|
|
100
|
+
rendered.freeze
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def drive_prompt
|
|
104
|
+
target = Shellwords.escape(descriptor.fetch("assignment_id") + "@" + descriptor.fetch("scope"))
|
|
105
|
+
selectors = "--assignment #{target} --mapping #{Shellwords.escape(descriptor.fetch('mapping_id'))} --attempt #{Shellwords.escape(descriptor.fetch('attempt_id'))}"
|
|
106
|
+
instructions = ["Drive the authenticated captured assignment subtree. Use ace-assign status #{selectors}, step #{selectors}, start #{selectors}, finish #{selectors} --message REPORT, and fail #{selectors} --message REASON. Do not fork-run, add, retry, renumber, or expand another graph."]
|
|
107
|
+
if (execution = work.definition["campaign_execution"])
|
|
108
|
+
instructions << "This is a registered campaign #{execution.fetch('phase')} child. The provider working directory is the private authenticated parent candidate repository at #{Shellwords.escape(execution.fetch('head'))}, with original base #{Shellwords.escape(execution.fetch('base'))}. Do not edit or advance that candidate. Execute the captured #{Shellwords.escape(execution.fetch('operation'))} phase and retain its actual #{Shellwords.escape(execution.fetch('check_name'))} check and report artifacts. Submit an ordinary campaign-free result (campaign null), operation #{Shellwords.escape(execution.fetch('operation'))}, exact original head/base, and actual check outcome. The existing independent reviewer must validate those same artifact bytes; an approval child must retain the exact canonical independent review actor/verdict. No session metadata or completed queue step counts as a settled R1 campaign round."
|
|
109
|
+
end
|
|
110
|
+
original = "--mapping #{Shellwords.escape(descriptor.fetch('mapping_id'))} --assignment #{Shellwords.escape(descriptor.fetch('assignment_id'))} --attempt #{Shellwords.escape(descriptor.fetch('attempt_id'))}"
|
|
111
|
+
instructions << "Before the original worker exits, explicitly submit its tested candidate and real execution result through the protected authority. Queue completion is not candidate/result acceptance. Read ace-assign authority status #{original}; persist its authority_generation with the complete next command arguments and ordered byte inputs. For each NEW operation use a new explicit mutation and a newly selected generation; for exact retries preserve the original generation, mutation and bytes."
|
|
112
|
+
instructions << "Submit one prepared Git bundle: ace-assign submit-candidate #{original} --head HEAD --candidate-generation CURRENT_CANDIDATE --expected-generation G --mutation CANDIDATE_MUTATION --bundle FILE. Select CURRENT_CANDIDATE from status result_candidate_generation, using 0 when null. Persist the returned candidate_generation and head as REVIEWED_CANDIDATE and HEAD; never derive the next counter by arithmetic or from queue completion."
|
|
113
|
+
instructions << "Have an independent installed reviewer run ace-overseer review --project #{Shellwords.escape(descriptor.fetch('project_id'))} --agent #{Shellwords.escape(descriptor.fetch('mapping_id'))} --assignment #{Shellwords.escape(descriptor.fetch('assignment_id'))} --attempt #{Shellwords.escape(descriptor.fetch('attempt_id'))} --head HEAD --candidate-generation REVIEWED_CANDIDATE --expected-generation G --mutation REVIEW_REQUEST --accept-mutation REVIEW_ACCEPT. Do not impersonate that principal. The review consumer owns actual candidate export, independent checks and canonical acceptance. After review, read authority status again before the first result submission."
|
|
114
|
+
instructions << "Produce the real receipt with Ace::Assign::Models::ExecutionReceipt.new/to_h: original assignment/attempt/project and scope #{Shellwords.escape(descriptor.fetch('scope'))}, exact tested candidate head, installed worker actor/role worker/runtime herdr, actual operation/verdict/executed checks and ordered artifact path/SHA256 pairs. No invented successful check or queue-to-receipt conversion. Submit JSON bytes and matching ordered artifact files with ace-assign submit-result #{original} --head HEAD --candidate-generation REVIEWED_CANDIDATE --expected-generation G --mutation RESULT_MUTATION --receipt FILE --artifact FILE ... . Failed empty artifacts use no --artifact; succeeded requires evidence. Supervisor finish is separate and revalidates result, independent review and no-writer/settlement proof."
|
|
115
|
+
instructions << "For an authorized merge step captured INSIDE this subtree, use the protected delivery rule in wfi://assign/drive. Never execute an outer preset step or expand this graph. Retain the exact approved candidate, structured input bytes, authorization and request ID before the first request; select authority_generation for that new request only."
|
|
116
|
+
service = "--project #{Shellwords.escape(descriptor.fetch('project_id'))} #{original} --scope #{Shellwords.escape(descriptor.fetch('scope'))} --candidate-head HEAD --candidate-generation REVIEWED_CANDIDATE"
|
|
117
|
+
instructions << "Request the uniquely installed authorized merge receiver: ace-lab service request #{service} --service SERVICE --operation merge --authorization AUTHORIZATION --request-id REQUEST --expected-generation G --input INPUT_FILE. Credentials alone are not authorization. Persist the returned original input_digest and target with all request arguments. Lost/uncertain replies never permit another effect or refreshed-generation retry."
|
|
118
|
+
instructions << "Inspect the same request: ace-lab service status #{service} --request REQUEST --input-digest INPUT_DIGEST --target TARGET. Only canonical succeeded evidence permits ace-assign delivery #{original} --scope #{Shellwords.escape(descriptor.fetch('scope'))} --operation merge --service-request REQUEST --candidate-head HEAD --candidate-generation REVIEWED_CANDIDATE --input-digest INPUT_DIGEST --target TARGET. This consumption verifies the imported receipt and never executes merge. Refusal, unavailable or uncertain leaves the active delivery step unfinished; report its exact blocker. Finish the queue step only after succeeded canonical delivery."
|
|
119
|
+
work.manifest.fetch("context").each do |entry|
|
|
120
|
+
instructions << render(work.files.fetch(entry.fetch("text").fetch("filename")))
|
|
121
|
+
end
|
|
122
|
+
work.manifest.fetch("steps").each do |entry|
|
|
123
|
+
instructions << "Step #{entry.fetch('number')}:\n" + render(work.parse_queue_step!(work.files.fetch("steps/" + entry.fetch("filename"))).fetch(:body))
|
|
124
|
+
end
|
|
125
|
+
instructions.join("\n\n").freeze
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
end
|
|
@@ -0,0 +1,185 @@
|
|
|
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
|