ace-assign 0.64.0 → 0.66.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.ace-defaults/assign/catalog/steps/auto-merge.step.yml +11 -0
- data/CHANGELOG.md +19 -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 +88 -9
- data/lib/ace/assign/cli/commands/inbox_reconcile.rb +0 -36
|
@@ -1,9 +1,13 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative "canonical_attempt_state"
|
|
4
|
+
|
|
3
5
|
require "digest"
|
|
4
6
|
require "fileutils"
|
|
5
7
|
require "json"
|
|
6
8
|
require "open3"
|
|
9
|
+
require "pathname"
|
|
10
|
+
require "ace/herdr/molecules/bounded_process"
|
|
7
11
|
require_relative "proposal_journal"
|
|
8
12
|
require_relative "journal_mutation"
|
|
9
13
|
|
|
@@ -29,6 +33,20 @@ module Ace
|
|
|
29
33
|
include JournalMutation
|
|
30
34
|
include ProposalJournal
|
|
31
35
|
CAS_ATTEMPTS = 3
|
|
36
|
+
HISTORY_LIMIT = 100_000
|
|
37
|
+
EVENT_BATCH_BYTES = 1024 * 1024
|
|
38
|
+
|
|
39
|
+
OPERATION_INVENTORY_LIMIT = 8
|
|
40
|
+
|
|
41
|
+
# A wire operation may retain one event/raw service selection and bounded immutable inventories, never admission
|
|
42
|
+
# or current-ref truth. Nested operations own independent selections.
|
|
43
|
+
def self.with_event_read_operation
|
|
44
|
+
previous = Thread.current[:ace_assign_event_read_operation]
|
|
45
|
+
Thread.current[:ace_assign_event_read_operation] = {}
|
|
46
|
+
yield
|
|
47
|
+
ensure
|
|
48
|
+
Thread.current[:ace_assign_event_read_operation] = previous
|
|
49
|
+
end
|
|
32
50
|
|
|
33
51
|
# Selected by source composition, never by receipt or wire parameters.
|
|
34
52
|
def evidence_mode
|
|
@@ -39,12 +57,16 @@ module Ace
|
|
|
39
57
|
# @param ref [String, nil] Evidence ref (default from config)
|
|
40
58
|
# @param checkout_root [String, nil] Isolated checkout root (default from config)
|
|
41
59
|
def initialize(repo_root:, ref: nil, checkout_root: nil, mode: :local,
|
|
42
|
-
evidence_reader: nil, service_authorizer: nil)
|
|
60
|
+
evidence_reader: nil, service_authorizer: nil, read_boundary: nil)
|
|
43
61
|
unless %i[local protected].include?(mode) &&
|
|
44
62
|
(mode != :protected || [evidence_reader, service_authorizer].all? { |owner| owner.respond_to?(:call) })
|
|
45
63
|
raise ArgumentError, "protected journal requires source-owned evidence and service boundaries"
|
|
46
64
|
end
|
|
47
65
|
@mode, @evidence_reader, @service_authorizer = mode, evidence_reader, service_authorizer
|
|
66
|
+
if read_boundary && !%i[call blob_batch blob history_diff blob_sizes].all? { |method| read_boundary.respond_to?(method) }
|
|
67
|
+
raise ArgumentError, "read-only journal boundary is malformed"
|
|
68
|
+
end
|
|
69
|
+
@read_boundary = read_boundary
|
|
48
70
|
@repo_root = repo_root
|
|
49
71
|
@ref = ref || default_config("evidence_git_ref") || "refs/ace/execution"
|
|
50
72
|
root = checkout_root || default_config("evidence_checkout_root") || ".ace-local/assign/evidence-checkout"
|
|
@@ -58,6 +80,71 @@ module Ace
|
|
|
58
80
|
git_ok?("rev-parse", "--git-dir")
|
|
59
81
|
end
|
|
60
82
|
|
|
83
|
+
# Original operator preparation only. Reuse the journal's existing lock
|
|
84
|
+
# and absent-ref CAS; never manufacture an event or reset existing history.
|
|
85
|
+
def initialize_canonical!(owner_uid:, owner_gid:, owner_groups:)
|
|
86
|
+
raise AttemptErrors::EvidenceUnavailable, "read-only journal cannot initialize canonical evidence" if @read_boundary
|
|
87
|
+
unless [owner_uid, owner_gid].all? { |id| id.is_a?(Integer) && id.between?(0, 2**31 - 1) } && @repo_root.is_a?(String) && @checkout_root.is_a?(String) &&
|
|
88
|
+
[@repo_root, @checkout_root].all? { |path| Pathname.new(path).absolute? && File.expand_path(path) == path } &&
|
|
89
|
+
File.directory?(@repo_root) && !File.symlink?(@repo_root) &&
|
|
90
|
+
git_ok?("check-ref-format", @ref)
|
|
91
|
+
raise AttemptErrors::EvidenceUnavailable, "canonical initialization selection is invalid"
|
|
92
|
+
end
|
|
93
|
+
unless owner_groups.is_a?(Array) && owner_groups.all? { |id| id.is_a?(Integer) && id.between?(0, 2**31 - 1) } &&
|
|
94
|
+
owner_groups.uniq.size == owner_groups.size &&
|
|
95
|
+
[Process.uid, Process.euid, Process.gid, Process.egid] == [owner_uid, owner_uid, owner_gid, owner_gid] &&
|
|
96
|
+
Process.groups.sort == owner_groups.sort
|
|
97
|
+
raise AttemptErrors::EvidenceUnavailable, "canonical initialization process identity differs"
|
|
98
|
+
end
|
|
99
|
+
selected = File.stat(@repo_root)
|
|
100
|
+
raise AttemptErrors::EvidenceUnavailable, "canonical initialization repository owner differs" unless selected.uid == owner_uid
|
|
101
|
+
identity = [selected.dev, selected.ino, selected.uid, selected.gid, selected.mode]
|
|
102
|
+
with_lock(cleanup_on_failure: false, owner_uid: owner_uid, owner_gid: owner_gid) do
|
|
103
|
+
value = ref_value
|
|
104
|
+
unless value
|
|
105
|
+
if File.exist?(checkout_dir) || File.symlink?(checkout_dir)
|
|
106
|
+
raise AttemptErrors::EvidenceUnavailable, "uninitialized journal has foreign checkout state"
|
|
107
|
+
end
|
|
108
|
+
seed_ref
|
|
109
|
+
value = ref_value
|
|
110
|
+
end
|
|
111
|
+
nodes = canonical_history_nodes!(value)
|
|
112
|
+
seed = nodes.last.first
|
|
113
|
+
tree = git!("rev-parse", "#{seed}^{tree}").first
|
|
114
|
+
message = git!("show", "-s", "--format=%B", seed).first
|
|
115
|
+
unless tree == git!("hash-object", "-t", "tree", "--stdin").first && message == "seed: ace-assign execution evidence"
|
|
116
|
+
raise AttemptErrors::EvidenceUnavailable, "canonical initialization has foreign root history"
|
|
117
|
+
end
|
|
118
|
+
canonical_event_inventory!(commit: value)
|
|
119
|
+
raw_paths = bounded_history_read!(["ls-tree", "-r", "-z", "--name-only", value], "", HISTORY_DIFF_BYTES)
|
|
120
|
+
paths = raw_paths.empty? ? [] : raw_paths.split("\0", -1)
|
|
121
|
+
unless paths.empty? || paths.pop == ""
|
|
122
|
+
raise AttemptErrors::EvidenceUnavailable, "canonical initialization tree inventory is incomplete"
|
|
123
|
+
end
|
|
124
|
+
raise AttemptErrors::EvidenceUnavailable, "canonical initialization tree inventory is oversized" if paths.size > HISTORY_LIMIT
|
|
125
|
+
paths.each do |path|
|
|
126
|
+
parts = path.split("/", -1)
|
|
127
|
+
event = parts.size == 4 && parts[0] == "execution" && parts[2] == "events" && JournalMutation::ID.match?(parts[1]) && parts[3].end_with?(".json")
|
|
128
|
+
request = parts.size == 3 && parts[0, 2] == %w[execution requests] && parts[2].end_with?(".json") && JournalMutation::ID.match?(parts[2].delete_suffix(".json"))
|
|
129
|
+
# Definitions, prepared packets, candidate bundles and imports
|
|
130
|
+
# retain the SAME mutation owner's closed immutable blob dialect.
|
|
131
|
+
validate_blob_path!(path) unless event || request
|
|
132
|
+
end
|
|
133
|
+
records = service_request_records(commit: value)
|
|
134
|
+
expected_requests = paths.select { |path| path.start_with?("execution/requests/") }.map { |path| File.basename(path, ".json") }.sort
|
|
135
|
+
unless records.map { |record| record.fetch("request_id") }.sort == expected_requests
|
|
136
|
+
raise AttemptErrors::EvidenceUnavailable, "canonical initialization service inventory differs"
|
|
137
|
+
end
|
|
138
|
+
current = File.stat(@repo_root)
|
|
139
|
+
unless !File.symlink?(@repo_root) && [current.dev, current.ino, current.uid, current.gid, current.mode] == identity && ref_value == value
|
|
140
|
+
raise AttemptErrors::EvidenceUnavailable, "canonical initialization selection changed"
|
|
141
|
+
end
|
|
142
|
+
value.dup.freeze
|
|
143
|
+
end
|
|
144
|
+
rescue SystemCallError, ArgumentError
|
|
145
|
+
raise AttemptErrors::EvidenceUnavailable, "canonical initialization repository is unavailable"
|
|
146
|
+
end
|
|
147
|
+
|
|
61
148
|
# Current value of the evidence ref.
|
|
62
149
|
#
|
|
63
150
|
# @return [String, nil] Commit SHA or nil when the ref does not exist
|
|
@@ -70,6 +157,376 @@ module Ace
|
|
|
70
157
|
nil
|
|
71
158
|
end
|
|
72
159
|
|
|
160
|
+
# Read-only verification for a fixed immutable maintenance snapshot.
|
|
161
|
+
def verify_commit!(commit)
|
|
162
|
+
unless commit.is_a?(String) && commit.match?(/\A[0-9a-f]{40}\z/)
|
|
163
|
+
raise AttemptErrors::EvidenceUnavailable, "canonical journal commit is invalid"
|
|
164
|
+
end
|
|
165
|
+
kind, error, status = git("cat-file", "-t", commit)
|
|
166
|
+
unless status.success? && kind == "commit"
|
|
167
|
+
raise AttemptErrors::EvidenceUnavailable, "canonical journal commit is unavailable: #{error}"
|
|
168
|
+
end
|
|
169
|
+
true
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
# All immutable owner queries select retained canonical evidence, not
|
|
173
|
+
# arbitrary Git objects or valid-looking abandoned CAS candidates.
|
|
174
|
+
def verify_canonical_prefix!(commit:, canonical_commit: ref_value)
|
|
175
|
+
verify_commit!(commit)
|
|
176
|
+
verify_commit!(canonical_commit)
|
|
177
|
+
text, _error, status = git("rev-list", "--first-parent", "--parents",
|
|
178
|
+
"--max-count=#{self.class::HISTORY_LIMIT + 1}", canonical_commit)
|
|
179
|
+
nodes = text.lines.map(&:split)
|
|
180
|
+
unless status.success? && nodes.size <= self.class::HISTORY_LIMIT && !nodes.empty? &&
|
|
181
|
+
nodes.all? { |node| node.size.between?(1, 2) && node.all? { |sha| sha.match?(/\A[0-9a-f]{40}\z/) } } &&
|
|
182
|
+
nodes.each_cons(2).all? { |left, right| left[1] == right[0] } && nodes.last.size == 1 &&
|
|
183
|
+
nodes.any? { |node| node.first == commit }
|
|
184
|
+
raise AttemptErrors::EvidenceUnavailable, "Evidence prefix is not retained canonical first-parent history"
|
|
185
|
+
end
|
|
186
|
+
true
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
# Normal append commits inherit the event unchanged. Locate its one
|
|
190
|
+
# introduction; inherited appearances are not competing provenance.
|
|
191
|
+
def event_commit!(assignment_id:, event_digest:, commit:)
|
|
192
|
+
event_commits!(assignment_id: assignment_id, event_digests: [event_digest], commit: commit).fetch(event_digest)
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
# Operation-scoped batch of the same provenance proof. Reuse only the
|
|
196
|
+
# already authenticated exact wire inventory; never an owner cache.
|
|
197
|
+
def event_commits!(assignment_id:, event_digests:, commit:)
|
|
198
|
+
event_commits_for_assignments!(selectors: {assignment_id => event_digests}, commit: commit).fetch(assignment_id)
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
# One immutable walk for an operation selecting several assignments.
|
|
202
|
+
# Every selected assignment retains its complete chain and exact raw
|
|
203
|
+
# blob proof; the result is returned, never cached on this owner.
|
|
204
|
+
def event_commits_for_assignments!(selectors:, commit:)
|
|
205
|
+
unless selectors.is_a?(Hash) && !selectors.empty? && selectors.size <= HISTORY_LIMIT &&
|
|
206
|
+
selectors.keys.all? { |id| id.is_a?(String) && id.match?(JournalMutation::ID) } &&
|
|
207
|
+
selectors.values.all? { |digests| digests.is_a?(Array) && !digests.empty? &&
|
|
208
|
+
digests.uniq.size == digests.size && digests.all? { |digest| digest.is_a?(String) && digest.match?(/\A[0-9a-f]{64}\z/) } } &&
|
|
209
|
+
selectors.values.sum(&:size) <= HISTORY_LIMIT
|
|
210
|
+
raise AttemptErrors::EvidenceUnavailable, "historical event selectors are invalid"
|
|
211
|
+
end
|
|
212
|
+
if (inventory = retained_operation_inventory(commit))
|
|
213
|
+
return selectors.to_h do |id, digests|
|
|
214
|
+
introductions = inventory.fetch("introductions").fetch(id)
|
|
215
|
+
[id.dup.freeze, digests.to_h { |digest| [digest.dup.freeze, introductions.fetch(digest)] }.freeze]
|
|
216
|
+
end.freeze
|
|
217
|
+
end
|
|
218
|
+
nodes = canonical_history_nodes!(commit)
|
|
219
|
+
states = selectors.to_h do |id, digests|
|
|
220
|
+
[id, digests.to_h { |digest| [digest, {retained: nil, blob: nil, absent: false, introduction: nil}] }]
|
|
221
|
+
end
|
|
222
|
+
each_history_event_snapshot!(states.keys, nodes: nodes) do |node, snapshots, files|
|
|
223
|
+
states.each do |assignment_id, selected_states|
|
|
224
|
+
events = snapshots.fetch(assignment_id)
|
|
225
|
+
validated_attempt_chains!(events)
|
|
226
|
+
matches = events.select { |event| selected_states.key?(event["digest"]) }.group_by { |event| event.fetch("digest") }
|
|
227
|
+
selected_states.each do |digest, state|
|
|
228
|
+
selected = matches.fetch(digest, [])
|
|
229
|
+
raise AttemptErrors::EvidenceUnavailable, "historical event selector is ambiguous" if selected.size > 1
|
|
230
|
+
if selected.empty?
|
|
231
|
+
raise AttemptErrors::EvidenceUnavailable, "historical event disappeared" if state[:retained]
|
|
232
|
+
next
|
|
233
|
+
end
|
|
234
|
+
event = selected.first
|
|
235
|
+
blob = files.fetch(assignment_id).fetch(event_filename(event))
|
|
236
|
+
if (state[:retained] && state[:retained] != event) || (state[:blob] && state[:blob] != blob)
|
|
237
|
+
raise AttemptErrors::EvidenceUnavailable, "historical event changed"
|
|
238
|
+
end
|
|
239
|
+
state[:blob] ||= blob
|
|
240
|
+
state[:retained] ||= event
|
|
241
|
+
state[:introduction] ||= node.first
|
|
242
|
+
end
|
|
243
|
+
end
|
|
244
|
+
end
|
|
245
|
+
unless states.values.all? { |selected_states| selected_states.values.all? { |state| state[:introduction] } }
|
|
246
|
+
raise AttemptErrors::EvidenceUnavailable, "historical canonical event is unavailable"
|
|
247
|
+
end
|
|
248
|
+
states.to_h do |id, selected_states|
|
|
249
|
+
[id.dup.freeze, selected_states.to_h { |digest, state| [digest.dup.freeze, state.fetch(:introduction).dup.freeze] }.freeze]
|
|
250
|
+
end.freeze
|
|
251
|
+
rescue KeyError, TypeError, ArgumentError, NoMethodError
|
|
252
|
+
raise AttemptErrors::EvidenceUnavailable, "historical canonical event selectors are unverifiable"
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
HISTORY_DIFF_BYTES = 256 * 1024 * 1024
|
|
256
|
+
HISTORY_DIFF_FLAGS = %w[diff-tree --stdin --always --root -r --raw -z --no-renames
|
|
257
|
+
--no-ext-diff --no-textconv --no-abbrev --].freeze
|
|
258
|
+
|
|
259
|
+
# The existing history owner still validates every prefix. Git returns
|
|
260
|
+
# all requested headers, including unchanged commits; only immutable
|
|
261
|
+
# changed blob bytes are batched rather than respawned at each node.
|
|
262
|
+
def each_history_event_snapshot!(ids, nodes:)
|
|
263
|
+
inventory = ids.nil?
|
|
264
|
+
ids = [] if inventory
|
|
265
|
+
files = ids.to_h { |id| [id, {}] }
|
|
266
|
+
contents = ids.to_h { |id| [id, {}] }
|
|
267
|
+
paths = inventory ? ["execution/"] : ids.map { |id| "execution/#{id}/events/" }
|
|
268
|
+
nodes.reverse.each_slice(64) do |chunk|
|
|
269
|
+
commits = chunk.map(&:first)
|
|
270
|
+
raw = history_diff!(commits, paths)
|
|
271
|
+
changes = decode_history_diff!(commits, paths, raw, all_paths: inventory)
|
|
272
|
+
if inventory
|
|
273
|
+
changes.each_value do |entries|
|
|
274
|
+
entries.each do |path, *_|
|
|
275
|
+
id = path.split("/")[1]
|
|
276
|
+
next if id == "requests"
|
|
277
|
+
unless id && id.match?(JournalMutation::ID)
|
|
278
|
+
raise AttemptErrors::EvidenceUnavailable, "historical assignment path is invalid"
|
|
279
|
+
end
|
|
280
|
+
unless files.key?(id)
|
|
281
|
+
raise AttemptErrors::EvidenceUnavailable, "historical assignment inventory exceeds bound" if ids.size >= HISTORY_LIMIT
|
|
282
|
+
ids << id
|
|
283
|
+
files[id], contents[id] = {}, {}
|
|
284
|
+
end
|
|
285
|
+
end
|
|
286
|
+
entries.select! do |path, *_|
|
|
287
|
+
id = path.split("/")[1]
|
|
288
|
+
id != "requests" && path.start_with?("execution/#{id}/events/") && path.end_with?(".json")
|
|
289
|
+
end
|
|
290
|
+
end
|
|
291
|
+
end
|
|
292
|
+
oids = changes.values.flatten(1).filter_map { |change| change[3] unless change[3] == "0" * 40 }.uniq
|
|
293
|
+
decoded = {}
|
|
294
|
+
oids.each_slice(64) do |batch|
|
|
295
|
+
sizes = history_blob_sizes!(batch)
|
|
296
|
+
entries = batch.zip(sizes)
|
|
297
|
+
groups = []
|
|
298
|
+
entries.each do |entry|
|
|
299
|
+
groups << [] if groups.empty? || (!groups.last.empty? && groups.last.sum(&:last) + entry.last > EVENT_BATCH_BYTES)
|
|
300
|
+
groups.last << entry
|
|
301
|
+
end
|
|
302
|
+
groups.each do |group|
|
|
303
|
+
decode_event_blobs!(group, read_event_blobs!(group)).each_with_index do |bytes, index|
|
|
304
|
+
decoded[group.fetch(index).first] = JSON.parse(bytes)
|
|
305
|
+
end
|
|
306
|
+
end
|
|
307
|
+
end
|
|
308
|
+
chunk.each do |node|
|
|
309
|
+
changes.fetch(node.first).each do |path, status, old, fresh|
|
|
310
|
+
id = ids.find { |selected| path.start_with?("execution/#{selected}/events/") }
|
|
311
|
+
name = path.delete_prefix("execution/#{id}/events/")
|
|
312
|
+
current = files.fetch(id)[name]
|
|
313
|
+
unless (status == "A" && old == "0" * 40 && current.nil?) ||
|
|
314
|
+
(%w[M D T].include?(status) && current == old)
|
|
315
|
+
raise AttemptErrors::EvidenceUnavailable, "historical tree change differs from previous immutable snapshot"
|
|
316
|
+
end
|
|
317
|
+
if status == "D"
|
|
318
|
+
files.fetch(id).delete(name)
|
|
319
|
+
contents.fetch(id).delete(name)
|
|
320
|
+
else
|
|
321
|
+
files.fetch(id)[name] = fresh
|
|
322
|
+
contents.fetch(id)[name] = decoded.fetch(fresh)
|
|
323
|
+
end
|
|
324
|
+
end
|
|
325
|
+
snapshots = contents.transform_values do |selected|
|
|
326
|
+
events = selected.values
|
|
327
|
+
digests = events.map { |event| event.fetch("digest") }
|
|
328
|
+
raise AttemptErrors::EvidenceUnavailable, "historical event digest is ambiguous" unless digests.uniq.size == digests.size
|
|
329
|
+
order_by_chain(events)
|
|
330
|
+
end
|
|
331
|
+
yield node, snapshots, files
|
|
332
|
+
end
|
|
333
|
+
end
|
|
334
|
+
_events, actual = ids.empty? ? [{}, {}] : read_event_snapshots!(ids, commit: nodes.first.first)
|
|
335
|
+
raise AttemptErrors::EvidenceUnavailable, "historical reconstructed tip differs from its immutable tree" unless files == actual
|
|
336
|
+
rescue JSON::ParserError, KeyError, TypeError, ArgumentError
|
|
337
|
+
raise AttemptErrors::EvidenceUnavailable, "historical batched snapshots are unverifiable"
|
|
338
|
+
end
|
|
339
|
+
|
|
340
|
+
def history_diff!(commits, paths)
|
|
341
|
+
return @read_boundary.history_diff(commits, paths, output_limit: HISTORY_DIFF_BYTES) if @read_boundary
|
|
342
|
+
bounded_history_read!(HISTORY_DIFF_FLAGS + paths, commits.join("\n") + "\n", HISTORY_DIFF_BYTES)
|
|
343
|
+
end
|
|
344
|
+
|
|
345
|
+
def history_blob_sizes!(oids)
|
|
346
|
+
raw = if @read_boundary
|
|
347
|
+
@read_boundary.blob_sizes(oids)
|
|
348
|
+
else
|
|
349
|
+
bounded_history_read!(%w[cat-file --batch-check], oids.join("\n") + "\n", oids.size * 128)
|
|
350
|
+
end
|
|
351
|
+
lines = raw.lines
|
|
352
|
+
raise AttemptErrors::EvidenceUnavailable, "historical blob metadata count differs" unless lines.size == oids.size
|
|
353
|
+
lines.zip(oids).map do |line, oid|
|
|
354
|
+
match = /\A([0-9a-f]{40}) blob (0|[1-9][0-9]*)\n\z/.match(line)
|
|
355
|
+
raise AttemptErrors::EvidenceUnavailable, "historical blob metadata differs" unless match && match[1] == oid
|
|
356
|
+
match[2].to_i
|
|
357
|
+
end
|
|
358
|
+
end
|
|
359
|
+
|
|
360
|
+
def bounded_history_read!(argv, stdin, limit)
|
|
361
|
+
result = Herdr::Molecules::BoundedProcess.call(["git", *argv], chdir: File.expand_path(@repo_root),
|
|
362
|
+
stdin_data: stdin, timeout_s: 30, output_limit: limit)
|
|
363
|
+
raise AttemptErrors::EvidenceUnavailable, "historical batch unavailable or exceeds bound" unless result.status.success? && !result.oversized
|
|
364
|
+
result.stdout.to_s.b
|
|
365
|
+
rescue Timeout::Error, Herdr::Molecules::BoundedProcess::PostLaunchError, IOError, SystemCallError
|
|
366
|
+
raise AttemptErrors::EvidenceUnavailable, "historical batch is unavailable"
|
|
367
|
+
end
|
|
368
|
+
|
|
369
|
+
def decode_history_diff!(commits, paths, raw, all_paths: false)
|
|
370
|
+
unless raw.is_a?(String) && raw.end_with?("\0")
|
|
371
|
+
raise AttemptErrors::EvidenceUnavailable, "historical diff frame is incomplete"
|
|
372
|
+
end
|
|
373
|
+
tokens = raw.split("\0", -1)
|
|
374
|
+
tokens.pop
|
|
375
|
+
result = commits.to_h { |commit| [commit, []] }
|
|
376
|
+
position = 0
|
|
377
|
+
commits.each do |commit|
|
|
378
|
+
raise AttemptErrors::EvidenceUnavailable, "historical commit header differs" unless tokens[position] == commit
|
|
379
|
+
position += 1
|
|
380
|
+
seen = {}
|
|
381
|
+
while tokens[position]&.start_with?(":")
|
|
382
|
+
metadata, path = tokens.values_at(position, position + 1)
|
|
383
|
+
match = /\A:([0-7]{6}) ([0-7]{6}) ([0-9a-f]{40}) ([0-9a-f]{40}) ([AMDT])\z/.match(metadata)
|
|
384
|
+
unless match && path.is_a?(String) && paths.any? { |prefix| path.start_with?(prefix) } && !seen[path]
|
|
385
|
+
raise AttemptErrors::EvidenceUnavailable, "historical tree metadata differs"
|
|
386
|
+
end
|
|
387
|
+
seen[path] = true
|
|
388
|
+
position += 2
|
|
389
|
+
next unless all_paths || path.end_with?(".json")
|
|
390
|
+
old, fresh, status = match.values_at(3, 4, 5)
|
|
391
|
+
if all_paths && !path.match?(%r{\Aexecution/[^/]+/events/.*\.json\z})
|
|
392
|
+
# Complete discovery needs these assignment paths, but their
|
|
393
|
+
# non-event bodies/modes never become canonical event facts.
|
|
394
|
+
result.fetch(commit) << [path, status, old, fresh]
|
|
395
|
+
next
|
|
396
|
+
end
|
|
397
|
+
if all_paths && path.start_with?("execution/requests/")
|
|
398
|
+
result.fetch(commit) << [path, status, old, fresh]
|
|
399
|
+
next
|
|
400
|
+
end
|
|
401
|
+
modes = match.values_at(1, 2)
|
|
402
|
+
unless modes.all? { |mode| %w[000000 100644 100755 120000].include?(mode) } &&
|
|
403
|
+
(status == "A" ? old == "0" * 40 && fresh != "0" * 40 && modes.first == "000000" && modes.last != "000000" :
|
|
404
|
+
status == "D" ? old != "0" * 40 && fresh == "0" * 40 && modes.first != "000000" && modes.last == "000000" :
|
|
405
|
+
old != "0" * 40 && fresh != "0" * 40 && modes.none? { |mode| mode == "000000" })
|
|
406
|
+
raise AttemptErrors::EvidenceUnavailable, "historical event object type differs"
|
|
407
|
+
end
|
|
408
|
+
result.fetch(commit) << [path, status, old, fresh]
|
|
409
|
+
end
|
|
410
|
+
end
|
|
411
|
+
raise AttemptErrors::EvidenceUnavailable, "historical diff has extra headers or bytes" unless position == tokens.size
|
|
412
|
+
result
|
|
413
|
+
end
|
|
414
|
+
private :each_history_event_snapshot!, :history_diff!, :history_blob_sizes!, :bounded_history_read!, :decode_history_diff!
|
|
415
|
+
|
|
416
|
+
# Complete immutable index facts are authenticated before any caller
|
|
417
|
+
# filters ownership. A missing current fact cannot hide an older one.
|
|
418
|
+
def canonical_event_inventory!(commit:)
|
|
419
|
+
context = Thread.current[:ace_assign_event_read_operation]
|
|
420
|
+
reusable = context && @mode == :protected && commit.is_a?(String) && commit.match?(/\A[0-9a-f]{40}\z/)
|
|
421
|
+
selection = operation_inventory_selection(commit) if reusable
|
|
422
|
+
if reusable
|
|
423
|
+
inventories = context[:inventories] ||= {}
|
|
424
|
+
if inventories.key?(selection)
|
|
425
|
+
# Refresh bounded insertion order, retaining only immutable facts.
|
|
426
|
+
inventory = inventories.delete(selection)
|
|
427
|
+
inventories[selection] = inventory
|
|
428
|
+
return inventory
|
|
429
|
+
end
|
|
430
|
+
else
|
|
431
|
+
context&.delete(:inventories)
|
|
432
|
+
end
|
|
433
|
+
inventory = read_canonical_inventory!(commit: commit)
|
|
434
|
+
if reusable
|
|
435
|
+
inventories.shift while inventories.size >= OPERATION_INVENTORY_LIMIT
|
|
436
|
+
inventories[selection] = inventory
|
|
437
|
+
end
|
|
438
|
+
inventory
|
|
439
|
+
end
|
|
440
|
+
|
|
441
|
+
# Query only the exact full history already authenticated in this wire
|
|
442
|
+
# operation. No historical selector builds or advances this snapshot.
|
|
443
|
+
def operation_inventory_selection(commit)
|
|
444
|
+
[object_id, @repo_root, @ref, @checkout_root, @mode, @read_boundary&.object_id, commit]
|
|
445
|
+
.map { |value| value.is_a?(String) ? value.dup.freeze : value }.freeze
|
|
446
|
+
end
|
|
447
|
+
|
|
448
|
+
def retained_operation_inventory(commit)
|
|
449
|
+
context = Thread.current[:ace_assign_event_read_operation]
|
|
450
|
+
return unless context && @mode == :protected && commit.is_a?(String) && commit.match?(/\A[0-9a-f]{40}\z/)
|
|
451
|
+
context.fetch(:inventories, {})[operation_inventory_selection(commit)]
|
|
452
|
+
end
|
|
453
|
+
private :operation_inventory_selection, :retained_operation_inventory
|
|
454
|
+
|
|
455
|
+
def read_canonical_inventory!(commit:)
|
|
456
|
+
nodes = canonical_history_nodes!(commit)
|
|
457
|
+
root_events, introductions = {}, {}
|
|
458
|
+
previous_chains, previous_files = {}, {}
|
|
459
|
+
each_history_event_snapshot!(nil, nodes: nodes) do |node, snapshots, files|
|
|
460
|
+
chains = snapshots.transform_values { |events| validated_attempt_chains!(events) }
|
|
461
|
+
snapshots.each do |id, events|
|
|
462
|
+
unless events.all? { |event| files.fetch(id).key?(event_filename(event)) }
|
|
463
|
+
raise AttemptErrors::EvidenceUnavailable, "canonical event filename differs"
|
|
464
|
+
end
|
|
465
|
+
end
|
|
466
|
+
previous_chains.each do |id, attempts|
|
|
467
|
+
attempts.each do |attempt_id, chain|
|
|
468
|
+
newer = chains.fetch(id, {}).fetch(attempt_id, [])
|
|
469
|
+
unless chain == newer.take(chain.size)
|
|
470
|
+
raise AttemptErrors::EvidenceUnavailable, "canonical inventory history removed or rewrote an attempt"
|
|
471
|
+
end
|
|
472
|
+
end
|
|
473
|
+
end
|
|
474
|
+
previous_files.each do |id, selected_files|
|
|
475
|
+
unless selected_files.all? { |name, oid| files.fetch(id, {})[name] == oid }
|
|
476
|
+
raise AttemptErrors::EvidenceUnavailable, "canonical inventory history removed or rewrote event bytes"
|
|
477
|
+
end
|
|
478
|
+
end
|
|
479
|
+
snapshots.each do |id, events|
|
|
480
|
+
introductions[id] ||= {}
|
|
481
|
+
events.each { |event| introductions.fetch(id)[event.fetch("digest")] ||= node.first }
|
|
482
|
+
end
|
|
483
|
+
root_events = snapshots.reject { |_id, events| events.empty? }
|
|
484
|
+
previous_chains = chains
|
|
485
|
+
previous_files = files.transform_values(&:dup)
|
|
486
|
+
end
|
|
487
|
+
introductions.select! { |id, _| root_events.key?(id) }
|
|
488
|
+
freeze_inventory_projection("commit" => commit, "events" => root_events, "introductions" => introductions)
|
|
489
|
+
rescue KeyError, TypeError, ArgumentError, NoMethodError
|
|
490
|
+
raise AttemptErrors::EvidenceUnavailable, "canonical inventory history is unverifiable"
|
|
491
|
+
end
|
|
492
|
+
|
|
493
|
+
private :read_canonical_inventory!
|
|
494
|
+
|
|
495
|
+
def canonical_history_nodes!(commit)
|
|
496
|
+
verify_commit!(commit)
|
|
497
|
+
topology, error, status = git("rev-list", "--first-parent", "--parents",
|
|
498
|
+
"--max-count=#{HISTORY_LIMIT + 1}", commit)
|
|
499
|
+
nodes = topology.lines.map(&:split)
|
|
500
|
+
unless status.success? && !nodes.empty? && nodes.size <= HISTORY_LIMIT &&
|
|
501
|
+
nodes.all? { |node| node.size.between?(1, 2) && node.all? { |sha| sha.match?(/\A[0-9a-f]{40}\z/) } } &&
|
|
502
|
+
nodes.each_cons(2).all? { |left, right| left[1] == right[0] } && nodes.last.size == 1
|
|
503
|
+
raise AttemptErrors::EvidenceUnavailable, "canonical history topology is unsupported or incomplete: #{error}"
|
|
504
|
+
end
|
|
505
|
+
nodes
|
|
506
|
+
end
|
|
507
|
+
|
|
508
|
+
def validated_attempt_chains!(events)
|
|
509
|
+
chains = events.group_by { |event| event.fetch("attempt_id") }
|
|
510
|
+
unless chains.values.all? { |chain| Models::EvidenceEvent.chain_valid?(chain) }
|
|
511
|
+
raise AttemptErrors::EvidenceUnavailable, "historical canonical event chain is corrupt"
|
|
512
|
+
end
|
|
513
|
+
chains
|
|
514
|
+
end
|
|
515
|
+
|
|
516
|
+
def freeze_inventory_projection(value)
|
|
517
|
+
case value
|
|
518
|
+
when Hash
|
|
519
|
+
value.to_h { |key, item| [freeze_inventory_projection(key), freeze_inventory_projection(item)] }.freeze
|
|
520
|
+
when Array
|
|
521
|
+
value.map { |item| freeze_inventory_projection(item) }.freeze
|
|
522
|
+
when String
|
|
523
|
+
value.dup.freeze
|
|
524
|
+
else
|
|
525
|
+
value.freeze
|
|
526
|
+
end
|
|
527
|
+
end
|
|
528
|
+
private :canonical_history_nodes!, :validated_attempt_chains!, :freeze_inventory_projection
|
|
529
|
+
|
|
73
530
|
# Append accepted events to the journal under lock + CAS.
|
|
74
531
|
#
|
|
75
532
|
# @param assignment_id [String] Assignment ID (journal path segment)
|
|
@@ -132,21 +589,83 @@ module Ace
|
|
|
132
589
|
# @param assignment_id [String] Assignment ID
|
|
133
590
|
# @return [Array<Hash>] Parsed events in journal order
|
|
134
591
|
def read_events(assignment_id, commit: ref_value)
|
|
135
|
-
|
|
136
|
-
|
|
592
|
+
context = Thread.current[:ace_assign_event_read_operation]
|
|
593
|
+
if commit.nil?
|
|
594
|
+
context&.delete(:selection)
|
|
595
|
+
context&.delete(:events)
|
|
596
|
+
return []
|
|
597
|
+
end
|
|
598
|
+
unless context && @mode == :protected && commit.is_a?(String) && commit.match?(/\A[0-9a-f]{40}\z/)
|
|
599
|
+
context&.delete(:selection)
|
|
600
|
+
context&.delete(:events)
|
|
601
|
+
return read_event_snapshots!([assignment_id], commit: commit).first.fetch(assignment_id)
|
|
602
|
+
end
|
|
603
|
+
selection = [object_id, @repo_root, @ref, @checkout_root, @mode, @read_boundary&.object_id,
|
|
604
|
+
assignment_id, commit].map { |value| value.is_a?(String) ? value.dup.freeze : value }.freeze
|
|
605
|
+
return context.fetch(:events) if context[:selection] == selection
|
|
606
|
+
context.delete(:selection)
|
|
607
|
+
context.delete(:events)
|
|
608
|
+
inventory = retained_operation_inventory(commit) if assignment_id.is_a?(String) && assignment_id.match?(JournalMutation::ID)
|
|
609
|
+
events = if inventory
|
|
610
|
+
inventory.fetch("events").fetch(assignment_id, [])
|
|
611
|
+
else
|
|
612
|
+
read_event_snapshots!([assignment_id], commit: commit).first.fetch(assignment_id)
|
|
613
|
+
end
|
|
614
|
+
frozen = freeze_inventory_projection(events)
|
|
615
|
+
context[:selection], context[:events] = selection, frozen
|
|
616
|
+
frozen
|
|
617
|
+
end
|
|
137
618
|
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
raise AttemptErrors::EvidenceUnavailable, "
|
|
144
|
-
|
|
619
|
+
# Bounded argv/blob batches from the same immutable tree. Preserve the
|
|
620
|
+
# actual file OIDs alongside parsed chains for introduction proofs.
|
|
621
|
+
def read_event_snapshots!(assignment_ids, commit:)
|
|
622
|
+
unless assignment_ids.is_a?(Array) && !assignment_ids.empty? && assignment_ids.size <= HISTORY_LIMIT &&
|
|
623
|
+
assignment_ids.uniq.size == assignment_ids.size && assignment_ids.all? { |id| id.is_a?(String) && id.match?(JournalMutation::ID) }
|
|
624
|
+
raise AttemptErrors::EvidenceUnavailable, "canonical assignment selectors are invalid"
|
|
625
|
+
end
|
|
626
|
+
events = assignment_ids.to_h { |id| [id, []] }
|
|
627
|
+
files = assignment_ids.to_h { |id| [id, {}] }
|
|
628
|
+
entries = []
|
|
629
|
+
assignment_ids.each_slice(64) do |ids|
|
|
630
|
+
paths, stderr, status = git("ls-tree", "-r", "-l", commit, "--", *ids.map { |id| "execution/#{id}/events/" })
|
|
631
|
+
raise AttemptErrors::EvidenceUnavailable, "Cannot read journal events: #{stderr}" unless status.success?
|
|
632
|
+
paths.lines.each do |line|
|
|
633
|
+
metadata, path = line.chomp.split("\t", 2)
|
|
634
|
+
next unless path&.end_with?(".json")
|
|
635
|
+
_mode, kind, oid, size = metadata.split
|
|
636
|
+
id = path.split("/")[1]
|
|
637
|
+
unless events.key?(id) && path.start_with?("execution/#{id}/events/") && kind == "blob" &&
|
|
638
|
+
oid&.match?(/\A[0-9a-f]{40}\z/) && size&.match?(/\A(?:0|[1-9][0-9]*)\z/)
|
|
639
|
+
raise AttemptErrors::EvidenceUnavailable, "canonical event blob selection is invalid"
|
|
640
|
+
end
|
|
641
|
+
name = path.delete_prefix("execution/#{id}/events/")
|
|
642
|
+
raise AttemptErrors::EvidenceUnavailable, "canonical event file is ambiguous" if files.fetch(id).key?(name)
|
|
643
|
+
files.fetch(id)[name] = oid
|
|
644
|
+
entries << [oid, size.to_i, id]
|
|
645
|
+
end
|
|
646
|
+
end
|
|
647
|
+
batches = []
|
|
648
|
+
entries.each do |entry|
|
|
649
|
+
batches << [] if batches.empty? || batches.last.size == 64 ||
|
|
650
|
+
(!batches.last.empty? && batches.last.sum { |item| item[1] } + entry[1] > EVENT_BATCH_BYTES)
|
|
651
|
+
batches.last << entry
|
|
145
652
|
end
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
653
|
+
batches.each do |batch|
|
|
654
|
+
selected = batch.map { |oid, size, _id| [oid, size] }
|
|
655
|
+
contents = decode_event_blobs!(selected, read_event_blobs!(selected))
|
|
656
|
+
batch.zip(contents).each { |(_oid, _size, id), content| events.fetch(id) << JSON.parse(content) }
|
|
657
|
+
end
|
|
658
|
+
events.each_value do |chain|
|
|
659
|
+
digests = chain.map { |event| event.fetch("digest") }
|
|
660
|
+
unless digests.uniq.size == digests.size
|
|
661
|
+
raise AttemptErrors::EvidenceUnavailable, "canonical event digest is ambiguous"
|
|
662
|
+
end
|
|
663
|
+
end
|
|
664
|
+
[events.transform_values { |chain| order_by_chain(chain) }, files]
|
|
665
|
+
rescue JSON::ParserError
|
|
666
|
+
raise AttemptErrors::EvidenceUnavailable, "Corrupt canonical journal event"
|
|
149
667
|
end
|
|
668
|
+
private :read_event_snapshots!
|
|
150
669
|
|
|
151
670
|
# Accepted receipt payloads recorded for an assignment.
|
|
152
671
|
#
|
|
@@ -261,6 +780,17 @@ module Ace
|
|
|
261
780
|
"Service settlement requires a no-effect attestation artifact"
|
|
262
781
|
end
|
|
263
782
|
if @mode == :protected
|
|
783
|
+
if (current["operation"] == "prune-preserved-workspace" && %w[succeeded failed-settled].include?(state)) ||
|
|
784
|
+
(current["operation"] == "publish" && %w[succeeded failed].include?(state))
|
|
785
|
+
# The same installed evidence reader owns the entire imported
|
|
786
|
+
# cleanup pair; individual textual markers cannot authorize it.
|
|
787
|
+
contents = @evidence_reader.call(evidence_items, current, state, pending)
|
|
788
|
+
unless contents.is_a?(Array) && contents.size == evidence_items.size &&
|
|
789
|
+
contents.each_with_index.all? { |content, index| content.is_a?(String) && Digest::SHA256.hexdigest(content.b) == evidence_items.fetch(index).fetch("sha256") }
|
|
790
|
+
raise AttemptErrors::ReceiptRejected, "Canonical cleanup evidence is unverifiable"
|
|
791
|
+
end
|
|
792
|
+
return true
|
|
793
|
+
end
|
|
264
794
|
evidence_items.each do |item|
|
|
265
795
|
content = @evidence_reader.call(item, current, state, pending)
|
|
266
796
|
unless content.is_a?(String) && Digest::SHA256.hexdigest(content.b) == item["sha256"]
|
|
@@ -292,14 +822,75 @@ module Ace
|
|
|
292
822
|
end
|
|
293
823
|
|
|
294
824
|
def service_request(request_id, commit: ref_value)
|
|
825
|
+
read_service_request(request_id, commit: commit, pending: {commit: commit})
|
|
826
|
+
end
|
|
827
|
+
|
|
828
|
+
# A read operation owns this view only until its caller returns. The
|
|
829
|
+
# original complete first-parent/raw-blob owner supplies every fact;
|
|
830
|
+
# it is never retained on the journal or reused for another operation.
|
|
831
|
+
ServiceReadView = Struct.new(:journal, :commit, :inventory, :projection, keyword_init: true)
|
|
832
|
+
|
|
833
|
+
def service_settlement_read(request_id, commit: ref_value)
|
|
834
|
+
verify_canonical_prefix!(commit: commit)
|
|
835
|
+
record = read_service_request(request_id, commit: commit, pending: {commit: commit}, terminal: false)
|
|
836
|
+
return {record: nil, settlement_context: nil, read_view: nil}.freeze unless record
|
|
837
|
+
id = record.fetch("assignment_id")
|
|
838
|
+
events = read_events(id, commit: commit)
|
|
839
|
+
selectors = events.map { |event| event.fetch("digest") }
|
|
840
|
+
introductions = event_commits!(assignment_id: id, event_digests: selectors, commit: commit)
|
|
841
|
+
inventory = freeze_inventory_projection("commit" => commit, "events" => {id => events}, "introductions" => {id => introductions})
|
|
842
|
+
view = ServiceReadView.new(journal: self, commit: commit, inventory: inventory)
|
|
843
|
+
if @mode == :protected && terminal_state?(record["state"])
|
|
844
|
+
validate_terminal_receipt!(record, record["state"], record["receipt"],
|
|
845
|
+
pending: {commit: commit, service_read_view: view})
|
|
846
|
+
end
|
|
847
|
+
view.freeze
|
|
848
|
+
{record: freeze_inventory_projection(record), settlement_context: view.projection, read_view: view}.freeze
|
|
849
|
+
end
|
|
850
|
+
|
|
851
|
+
def read_service_request(request_id, commit:, pending:, terminal: true)
|
|
852
|
+
context = Thread.current[:ace_assign_event_read_operation]
|
|
295
853
|
validate_request_id!(request_id)
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
854
|
+
reusable = context && @mode == :protected && commit.is_a?(String) && commit.match?(/\A[0-9a-f]{40}\z/)
|
|
855
|
+
selection = if reusable
|
|
856
|
+
(operation_inventory_selection(commit) + [@evidence_reader&.object_id, @service_authorizer&.object_id, request_id])
|
|
857
|
+
.map { |value| value.is_a?(String) ? value.dup.freeze : value }.freeze
|
|
858
|
+
end
|
|
859
|
+
if reusable && context[:service_record_selection] == selection
|
|
860
|
+
retained = context[:service_record]
|
|
861
|
+
else
|
|
862
|
+
context&.delete(:service_record_selection)
|
|
863
|
+
context&.delete(:service_record)
|
|
864
|
+
retained = read_service_request_record(request_id, commit: commit)
|
|
865
|
+
if reusable && retained
|
|
866
|
+
retained = freeze_inventory_projection(retained)
|
|
867
|
+
context[:service_record_selection], context[:service_record] = selection, retained
|
|
868
|
+
end
|
|
869
|
+
end
|
|
870
|
+
# This memo contains JSON and its immutable accepted event proof,
|
|
871
|
+
# never terminal evidence admission. Every callback remains fresh;
|
|
872
|
+
# callers receive their own mutable copy rather than cached state.
|
|
873
|
+
record = retained && (reusable ? JSON.parse(JSON.generate(retained)) : retained)
|
|
874
|
+
validate_terminal_receipt!(record, record["state"], record["receipt"], pending: pending) if
|
|
875
|
+
record && terminal && @mode == :protected && terminal_state?(record["state"])
|
|
876
|
+
# Historical dependency reads may replace this one slot while the
|
|
877
|
+
# fresh callback executes. Restore only this successful raw view.
|
|
878
|
+
if reusable && retained
|
|
879
|
+
context[:service_record_selection], context[:service_record] = selection, retained
|
|
880
|
+
end
|
|
881
|
+
record
|
|
882
|
+
rescue Exception
|
|
883
|
+
context&.delete(:service_record_selection)
|
|
884
|
+
context&.delete(:service_record)
|
|
885
|
+
raise
|
|
886
|
+
end
|
|
887
|
+
|
|
888
|
+
def read_service_request_record(request_id, commit:)
|
|
889
|
+
return nil unless commit
|
|
890
|
+
out, stderr, status = git("show", "#{commit}:#{service_request_path(request_id)}")
|
|
299
891
|
if status.success?
|
|
300
892
|
record = JSON.parse(out)
|
|
301
|
-
verify_service_record!(record, commit:
|
|
302
|
-
validate_terminal_receipt!(record, record["state"], record["receipt"], pending: {commit: value}) if @mode == :protected && terminal_state?(record["state"])
|
|
893
|
+
verify_service_record!(record, commit: commit) if @mode == :protected
|
|
303
894
|
return record
|
|
304
895
|
end
|
|
305
896
|
return nil if stderr.include?("does not exist") || stderr.include?("exists on disk")
|
|
@@ -308,6 +899,8 @@ module Ace
|
|
|
308
899
|
raise AttemptErrors::EvidenceUnavailable, "Corrupt service request #{request_id}"
|
|
309
900
|
end
|
|
310
901
|
|
|
902
|
+
private :read_service_request, :read_service_request_record
|
|
903
|
+
|
|
311
904
|
# Every service request recorded in this evidence ref, whatever the
|
|
312
905
|
# owning assignment. Backs the journal-wide request-ID and
|
|
313
906
|
# authorization-consumption checks.
|
|
@@ -431,6 +1024,46 @@ module Ace
|
|
|
431
1024
|
|
|
432
1025
|
private
|
|
433
1026
|
|
|
1027
|
+
# Sizes come from the same immutable tree selection. Batch memory is
|
|
1028
|
+
# bounded by that selection; an existing larger single event retains
|
|
1029
|
+
# its previous read semantics rather than gaining a lifetime ceiling.
|
|
1030
|
+
def read_event_blobs!(entries)
|
|
1031
|
+
limit = entries.sum { |_oid, size| size + 128 }
|
|
1032
|
+
return @read_boundary.blob_batch(entries.map(&:first), output_limit: limit) if @read_boundary
|
|
1033
|
+
result = Herdr::Molecules::BoundedProcess.call(["git", "cat-file", "--batch"],
|
|
1034
|
+
chdir: File.expand_path(@repo_root), stdin_data: entries.map { |oid, _size| "#{oid}\n" }.join,
|
|
1035
|
+
timeout_s: 30, output_limit: limit)
|
|
1036
|
+
unless result.status.success? && !result.oversized
|
|
1037
|
+
raise AttemptErrors::EvidenceUnavailable, "canonical blob batch is unavailable or exceeds selected sizes"
|
|
1038
|
+
end
|
|
1039
|
+
result.stdout.to_s.b
|
|
1040
|
+
rescue Timeout::Error, Herdr::Molecules::BoundedProcess::PostLaunchError, IOError, SystemCallError => error
|
|
1041
|
+
raise AttemptErrors::EvidenceUnavailable, "canonical blob batch is unavailable: #{error.message}"
|
|
1042
|
+
end
|
|
1043
|
+
|
|
1044
|
+
def decode_event_blobs!(entries, output)
|
|
1045
|
+
output = output.b
|
|
1046
|
+
offset = 0
|
|
1047
|
+
entries.map do |oid, size|
|
|
1048
|
+
ending = output.index("\n", offset)
|
|
1049
|
+
unless ending && ending - offset <= 127 && output.byteslice(offset, ending - offset) == "#{oid} blob #{size}"
|
|
1050
|
+
raise AttemptErrors::EvidenceUnavailable, "canonical blob batch header is invalid"
|
|
1051
|
+
end
|
|
1052
|
+
offset = ending + 1
|
|
1053
|
+
content = output.byteslice(offset, size)
|
|
1054
|
+
unless content&.bytesize == size && output.byteslice(offset + size, 1) == "\n" &&
|
|
1055
|
+
Digest::SHA1.hexdigest("blob #{size}\0" + content) == oid
|
|
1056
|
+
raise AttemptErrors::EvidenceUnavailable, "canonical blob batch bytes are invalid"
|
|
1057
|
+
end
|
|
1058
|
+
offset += size + 1
|
|
1059
|
+
content
|
|
1060
|
+
end.tap do
|
|
1061
|
+
unless offset == output.bytesize
|
|
1062
|
+
raise AttemptErrors::EvidenceUnavailable, "canonical blob batch has unexpected trailing bytes"
|
|
1063
|
+
end
|
|
1064
|
+
end
|
|
1065
|
+
end
|
|
1066
|
+
|
|
434
1067
|
def update_service_request(request_id, expected:, replacement:, event_type:, guard: nil)
|
|
435
1068
|
validate_request_id!(request_id)
|
|
436
1069
|
with_lock do
|
|
@@ -468,7 +1101,7 @@ module Ace
|
|
|
468
1101
|
def prepare_service_update(request_id:, expected:, replacement:, event_type:, guard: nil, pending: nil)
|
|
469
1102
|
validate_request_id!(request_id)
|
|
470
1103
|
unless replacement.is_a?(Hash) && replacement["request_id"] == request_id &&
|
|
471
|
-
%w[service_claim service_transition].include?(event_type) &&
|
|
1104
|
+
%w[service_claim service_transition service_challenge].include?(event_type) &&
|
|
472
1105
|
%w[accepted uncertain rejected succeeded failed failed-settled].include?(replacement["state"])
|
|
473
1106
|
raise ArgumentError, "invalid service update plan"
|
|
474
1107
|
end
|
|
@@ -496,17 +1129,43 @@ module Ace
|
|
|
496
1129
|
raise_authorization_conflict!(conflict) if conflict
|
|
497
1130
|
end
|
|
498
1131
|
else
|
|
499
|
-
|
|
1132
|
+
challenge_update = event_type == "service_challenge" && pending && pending[:operation] == "claim_service_settlement" &&
|
|
1133
|
+
existing["state"] == replacement["state"] && existing["receipt"] == replacement["receipt"] &&
|
|
1134
|
+
%w[uncertain failed].include?(existing["state"])
|
|
1135
|
+
unless event_type == "service_transition" || challenge_update
|
|
500
1136
|
raise AttemptErrors::InvalidState, "Existing service request needs a transition"
|
|
501
1137
|
end
|
|
502
1138
|
terminal = %w[succeeded failed rejected failed-settled].include?(existing["state"])
|
|
503
1139
|
settlement = replacement["state"] == "failed-settled" &&
|
|
504
1140
|
(existing["state"] == "failed" || (existing["state"] == "rejected" && existing["consumed"] != false))
|
|
505
|
-
|
|
1141
|
+
recovered_cleanup = @mode == :protected && pending && pending[:operation] == "complete_service" &&
|
|
1142
|
+
existing["operation"] == "prune-preserved-workspace" && existing["state"] == "failed" &&
|
|
1143
|
+
existing["dispatch_phase"] == "dispatch_started" && existing["completion_digest"].nil? &&
|
|
1144
|
+
replacement["state"] == "succeeded"
|
|
1145
|
+
if terminal && !settlement && !challenge_update && !recovered_cleanup
|
|
506
1146
|
raise AttemptErrors::InvalidState, "Service request #{request_id} is terminal"
|
|
507
1147
|
end
|
|
508
1148
|
mutable = %w[state receipt reason claimed_at failed_at dispatch_phase no_effect_challenge
|
|
509
|
-
challenge_generation challenge_event_digest completion_digest]
|
|
1149
|
+
challenge_generation challenge_event_digest completion_digest no_effect_completion_digest]
|
|
1150
|
+
# Only this authenticated fresh dispatch may introduce the fixed
|
|
1151
|
+
# root collaborator identity. It is immutable on every later update.
|
|
1152
|
+
if @mode == :protected && pending && pending[:operation] == "begin_dispatch" &&
|
|
1153
|
+
existing["operation"] == "prune-preserved-workspace" && existing["dispatch_phase"] == "issued" &&
|
|
1154
|
+
replacement["dispatch_phase"] == "dispatch_started" &&
|
|
1155
|
+
%w[operation_owner_binding executor_process_binding].none? { |key| existing.key?(key) } &&
|
|
1156
|
+
%w[operation_owner_binding executor_process_binding].all? { |key| replacement.key?(key) }
|
|
1157
|
+
mutable.concat(%w[operation_owner_binding executor_process_binding])
|
|
1158
|
+
end
|
|
1159
|
+
if @mode == :protected && pending && existing["operation"] == "publish"
|
|
1160
|
+
if pending[:operation] == "begin_dispatch" && existing["dispatch_phase"] == "issued" &&
|
|
1161
|
+
replacement["dispatch_phase"] == "dispatch_started" && !existing.key?("executor_process_binding") &&
|
|
1162
|
+
replacement.key?("executor_process_binding")
|
|
1163
|
+
mutable << "executor_process_binding"
|
|
1164
|
+
elsif %w[publication_challenge publication_continue].include?(pending[:operation])
|
|
1165
|
+
mutable.concat(%w[publication_challenge_digest publication_challenge_ref publication_challenge_event_digest
|
|
1166
|
+
publication_challenge_generation publication_issuing_event_digest])
|
|
1167
|
+
end
|
|
1168
|
+
end
|
|
510
1169
|
unless existing.except(*mutable) == replacement.except(*mutable)
|
|
511
1170
|
raise AttemptErrors::Conflict, "Service request #{request_id} changed immutable binding"
|
|
512
1171
|
end
|
|
@@ -554,7 +1213,7 @@ module Ace
|
|
|
554
1213
|
raise AttemptErrors::EvidenceUnavailable, "Canonical service event chain is unverifiable"
|
|
555
1214
|
end
|
|
556
1215
|
event = events.reverse.find do |entry|
|
|
557
|
-
%w[service_claim service_transition].include?(entry["type"]) && entry.dig("payload", "request_id") == record["request_id"]
|
|
1216
|
+
%w[service_claim service_transition service_challenge].include?(entry["type"]) && entry.dig("payload", "request_id") == record["request_id"]
|
|
558
1217
|
end
|
|
559
1218
|
unless event && event.dig("payload", "record_digest") == Atoms::EvidenceDigest.digest(record)
|
|
560
1219
|
raise AttemptErrors::EvidenceUnavailable, "Canonical service record does not match accepted event"
|
|
@@ -589,22 +1248,7 @@ module Ace
|
|
|
589
1248
|
# Latest lifecycle state implied by the events, or nil when the
|
|
590
1249
|
# events do not describe a full attempt (intent missing).
|
|
591
1250
|
def derive_state(events)
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
state = "reserved"
|
|
595
|
-
events.each do |event|
|
|
596
|
-
case event["type"]
|
|
597
|
-
when "process_start"
|
|
598
|
-
state = "running"
|
|
599
|
-
when "receipt_accepted"
|
|
600
|
-
state = event.dig("payload", "receipt", "verdict") || state
|
|
601
|
-
when "transition"
|
|
602
|
-
state = event.dig("payload", "to") || state
|
|
603
|
-
when "reconciliation"
|
|
604
|
-
state = event.dig("payload", "resolution") || state
|
|
605
|
-
end
|
|
606
|
-
end
|
|
607
|
-
state
|
|
1251
|
+
CanonicalAttemptState.derive(events)
|
|
608
1252
|
end
|
|
609
1253
|
|
|
610
1254
|
def build_attempt(assignment_id, attempt_id, intent_payload, events, state)
|
|
@@ -743,21 +1387,52 @@ module Ace
|
|
|
743
1387
|
File.join(@checkout_root, ".evidence.lock")
|
|
744
1388
|
end
|
|
745
1389
|
|
|
746
|
-
def with_lock
|
|
1390
|
+
def with_lock(cleanup_on_failure: true, owner_uid: nil, owner_gid: nil)
|
|
1391
|
+
raise AttemptErrors::EvidenceUnavailable, "read-only journal cannot mutate canonical evidence" if @read_boundary
|
|
747
1392
|
FileUtils.mkdir_p(@checkout_root)
|
|
748
|
-
|
|
1393
|
+
lock = if owner_uid
|
|
1394
|
+
initialization_lock!(owner_uid: owner_uid, owner_gid: owner_gid)
|
|
1395
|
+
else
|
|
1396
|
+
File.open(lock_path, File::RDWR | File::CREAT, 0o600)
|
|
1397
|
+
end
|
|
1398
|
+
begin
|
|
749
1399
|
lock.flock(File::LOCK_EX)
|
|
750
1400
|
begin
|
|
751
1401
|
yield
|
|
752
1402
|
rescue StandardError
|
|
753
|
-
#
|
|
754
|
-
#
|
|
755
|
-
sync_checkout(ref_value) if File.exist?(File.join(checkout_dir, ".git"))
|
|
1403
|
+
# Ordinary mutation rollback is unchanged. Initialization must
|
|
1404
|
+
# not adopt/reset a foreign disposable checkout on refusal.
|
|
1405
|
+
sync_checkout(ref_value) if cleanup_on_failure && File.exist?(File.join(checkout_dir, ".git"))
|
|
756
1406
|
raise
|
|
757
1407
|
ensure
|
|
758
1408
|
lock.flock(File::LOCK_UN)
|
|
759
1409
|
end
|
|
1410
|
+
ensure
|
|
1411
|
+
lock.close
|
|
1412
|
+
end
|
|
1413
|
+
end
|
|
1414
|
+
|
|
1415
|
+
def initialization_lock!(owner_uid:, owner_gid:)
|
|
1416
|
+
created = false
|
|
1417
|
+
begin
|
|
1418
|
+
file = File.open(lock_path, File::RDWR | File::CREAT | File::EXCL | File::NOFOLLOW, 0o600)
|
|
1419
|
+
created = true
|
|
1420
|
+
rescue Errno::EEXIST
|
|
1421
|
+
file = File.open(lock_path, File::RDWR | File::NOFOLLOW)
|
|
1422
|
+
end
|
|
1423
|
+
if created
|
|
1424
|
+
file.flush
|
|
1425
|
+
file.fsync
|
|
1426
|
+
end
|
|
1427
|
+
info = file.stat
|
|
1428
|
+
unless info.file? && info.nlink == 1 && info.size.zero? &&
|
|
1429
|
+
[info.uid, info.gid, info.mode & 0o7777] == [owner_uid, owner_gid, 0o600]
|
|
1430
|
+
raise AttemptErrors::EvidenceUnavailable, "canonical initialization lock protection differs"
|
|
760
1431
|
end
|
|
1432
|
+
file
|
|
1433
|
+
rescue Exception
|
|
1434
|
+
file&.close
|
|
1435
|
+
raise
|
|
761
1436
|
end
|
|
762
1437
|
|
|
763
1438
|
def ensure_checkout!
|
|
@@ -811,6 +1486,7 @@ module Ace
|
|
|
811
1486
|
end
|
|
812
1487
|
|
|
813
1488
|
def git(*argv)
|
|
1489
|
+
return @read_boundary.call(argv) if @read_boundary
|
|
814
1490
|
unless File.directory?(@repo_root)
|
|
815
1491
|
return ["", "repository root missing: #{@repo_root}", FAILED_RESULT]
|
|
816
1492
|
end
|