ace-assign 0.63.1 → 0.66.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.ace-defaults/assign/catalog/steps/auto-merge.step.yml +11 -0
- data/.ace-defaults/assign/catalog/steps/create-pr.step.yml +2 -2
- data/.ace-defaults/assign/catalog/steps/mark-pr-ready.step.yml +1 -1
- data/.ace-defaults/assign/catalog/steps/release-minor.step.yml +4 -2
- data/.ace-defaults/assign/catalog/steps/release.step.yml +4 -2
- data/.ace-defaults/assign/catalog/steps/update-pr-desc.step.yml +1 -1
- data/.ace-defaults/assign/presets/fix-bug.yml +15 -3
- data/.ace-defaults/assign/presets/work-on-task-ace-development.yml +16 -4
- data/.ace-defaults/assign/presets/work-on-task-auto-merge.yml +15 -3
- data/.ace-defaults/assign/presets/work-on-task.yml +16 -4
- data/CHANGELOG.md +62 -0
- data/docs/demo/canonical-skill-source.tape.yml +4 -4
- data/docs/protected-authority.md +282 -0
- data/docs/usage.md +205 -3
- data/exe/ace-scope-ready +55 -0
- data/handbook/workflow-instructions/assign/drive.wf.md +28 -3
- data/handbook/workflow-instructions/assign/prepare.wf.md +2 -2
- data/lib/ace/assign/atoms/attempt_state_machine.rb +8 -0
- data/lib/ace/assign/atoms/delivery_parameters.rb +19 -0
- 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 +327 -0
- data/lib/ace/assign/authority/cleanup_result_protection.rb +53 -0
- data/lib/ace/assign/authority/client.rb +257 -0
- data/lib/ace/assign/authority/deployment.rb +627 -0
- data/lib/ace/assign/authority/deployment_history.rb +146 -0
- data/lib/ace/assign/authority/endcap.rb +455 -0
- 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 +37 -0
- 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 +398 -0
- 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 +598 -0
- data/lib/ace/assign/authority/evidence_transfer.rb +67 -0
- data/lib/ace/assign/authority/execution_scope_observation.rb +903 -0
- 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 +393 -0
- data/lib/ace/assign/authority/launch_input_inhibition.rb +132 -0
- data/lib/ace/assign/authority/launch_lifecycle.rb +997 -0
- 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 +81 -0
- data/lib/ace/assign/authority/launch_scope_admission.rb +215 -0
- data/lib/ace/assign/authority/launch_scope_close.rb +146 -0
- data/lib/ace/assign/authority/launch_scope_parent.rb +44 -0
- data/lib/ace/assign/authority/launch_scope_release.rb +859 -0
- 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 +59 -0
- 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/private_directory.rb +33 -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/receipt_transfer.rb +48 -0
- data/lib/ace/assign/authority/router.rb +76 -0
- data/lib/ace/assign/authority/server.rb +300 -0
- 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 +286 -0
- 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 +187 -0
- 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 +97 -0
- data/lib/ace/assign/cli/commands/authority/serve.rb +31 -0
- data/lib/ace/assign/cli/commands/authority/status.rb +26 -0
- 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/terminate.rb +29 -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 +11 -1
- data/lib/ace/assign/cli/commands/delivery.rb +107 -0
- 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 +38 -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 +56 -4
- 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 +4 -2
- data/lib/ace/assign/molecules/assignment_manager.rb +12 -2
- data/lib/ace/assign/molecules/attempt_reconciler.rb +51 -12
- data/lib/ace/assign/molecules/canonical_attempt_state.rb +151 -0
- data/lib/ace/assign/molecules/canonical_evidence.rb +153 -0
- data/lib/ace/assign/molecules/canonical_read_snapshot.rb +374 -0
- data/lib/ace/assign/molecules/evidence_journal.rb +922 -91
- data/lib/ace/assign/molecules/execution_identity_resolver.rb +49 -9
- data/lib/ace/assign/molecules/execution_scope_lineage.rb +345 -0
- 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 +271 -0
- data/lib/ace/assign/molecules/journal_prompt_mutation.rb +241 -0
- data/lib/ace/assign/molecules/lifecycle_exclusion.rb +182 -26
- data/lib/ace/assign/molecules/original_launch_binding.rb +37 -0
- data/lib/ace/assign/molecules/proposal_journal.rb +151 -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 +68 -32
- 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 +10 -4
- data/lib/ace/assign/organisms/attempt_coordinator.rb +302 -13
- data/lib/ace/assign/organisms/delivery_coordinator.rb +342 -0
- 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 +11 -4
- data/lib/ace/assign/version.rb +1 -1
- data/lib/ace/assign.rb +4 -102
- metadata +186 -8
|
@@ -1,10 +1,15 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
require_relative "canonical_attempt_state"
|
|
4
|
+
|
|
4
5
|
require "digest"
|
|
5
6
|
require "fileutils"
|
|
6
7
|
require "json"
|
|
7
8
|
require "open3"
|
|
9
|
+
require "pathname"
|
|
10
|
+
require "ace/herdr/molecules/bounded_process"
|
|
11
|
+
require_relative "proposal_journal"
|
|
12
|
+
require_relative "journal_mutation"
|
|
8
13
|
|
|
9
14
|
module Ace
|
|
10
15
|
module Assign
|
|
@@ -25,12 +30,43 @@ module Ace
|
|
|
25
30
|
# reloads the authoritative ref and replays only still-valid,
|
|
26
31
|
# non-duplicate events.
|
|
27
32
|
class EvidenceJournal
|
|
33
|
+
include JournalMutation
|
|
34
|
+
include ProposalJournal
|
|
28
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
|
|
50
|
+
|
|
51
|
+
# Selected by source composition, never by receipt or wire parameters.
|
|
52
|
+
def evidence_mode
|
|
53
|
+
@mode
|
|
54
|
+
end
|
|
29
55
|
|
|
30
56
|
# @param repo_root [String] Git repository root holding the evidence ref
|
|
31
57
|
# @param ref [String, nil] Evidence ref (default from config)
|
|
32
58
|
# @param checkout_root [String, nil] Isolated checkout root (default from config)
|
|
33
|
-
def initialize(repo_root:, ref: nil, checkout_root: nil
|
|
59
|
+
def initialize(repo_root:, ref: nil, checkout_root: nil, mode: :local,
|
|
60
|
+
evidence_reader: nil, service_authorizer: nil, read_boundary: nil)
|
|
61
|
+
unless %i[local protected].include?(mode) &&
|
|
62
|
+
(mode != :protected || [evidence_reader, service_authorizer].all? { |owner| owner.respond_to?(:call) })
|
|
63
|
+
raise ArgumentError, "protected journal requires source-owned evidence and service boundaries"
|
|
64
|
+
end
|
|
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
|
|
34
70
|
@repo_root = repo_root
|
|
35
71
|
@ref = ref || default_config("evidence_git_ref") || "refs/ace/execution"
|
|
36
72
|
root = checkout_root || default_config("evidence_checkout_root") || ".ace-local/assign/evidence-checkout"
|
|
@@ -44,6 +80,71 @@ module Ace
|
|
|
44
80
|
git_ok?("rev-parse", "--git-dir")
|
|
45
81
|
end
|
|
46
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
|
+
|
|
47
148
|
# Current value of the evidence ref.
|
|
48
149
|
#
|
|
49
150
|
# @return [String, nil] Commit SHA or nil when the ref does not exist
|
|
@@ -56,6 +157,376 @@ module Ace
|
|
|
56
157
|
nil
|
|
57
158
|
end
|
|
58
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
|
+
|
|
59
530
|
# Append accepted events to the journal under lock + CAS.
|
|
60
531
|
#
|
|
61
532
|
# @param assignment_id [String] Assignment ID (journal path segment)
|
|
@@ -89,28 +560,112 @@ module Ace
|
|
|
89
560
|
end
|
|
90
561
|
end
|
|
91
562
|
|
|
563
|
+
# Build an event against the latest chain while holding the ref lock.
|
|
564
|
+
# Re-run the eligibility guard on each CAS retry, never append a
|
|
565
|
+
# stale chain continuation after another accepted writer.
|
|
566
|
+
def record(assignment_id:, attempt_id:, type:, payload:, guard: nil)
|
|
567
|
+
with_lock do
|
|
568
|
+
CAS_ATTEMPTS.times do
|
|
569
|
+
old = ref_value
|
|
570
|
+
ensure_checkout!
|
|
571
|
+
old = ref_value if old.nil?
|
|
572
|
+
sync_checkout(old)
|
|
573
|
+
guard&.call
|
|
574
|
+
previous = read_events(assignment_id).reverse.find { |event| event["attempt_id"] == attempt_id }&.fetch("digest")
|
|
575
|
+
event = Models::EvidenceEvent.build(type: type, attempt_id: attempt_id,
|
|
576
|
+
payload: payload, previous_digest: previous)
|
|
577
|
+
write_event_files(assignment_id, [event])
|
|
578
|
+
commit = commit_events(assignment_id, attempt_id, [event])
|
|
579
|
+
return commit if update_ref_cas(commit, old)
|
|
580
|
+
end
|
|
581
|
+
raise AttemptErrors::EvidenceUnavailable, "Evidence ref stayed conflicting during event recording"
|
|
582
|
+
end
|
|
583
|
+
end
|
|
584
|
+
|
|
92
585
|
# Read all journal events recorded for an assignment, ordered by the
|
|
93
586
|
# digest chain (never by filename: same-second events sort
|
|
94
587
|
# alphabetically, which can invert lifecycle order).
|
|
95
588
|
#
|
|
96
589
|
# @param assignment_id [String] Assignment ID
|
|
97
590
|
# @return [Array<Hash>] Parsed events in journal order
|
|
98
|
-
def read_events(assignment_id)
|
|
99
|
-
|
|
100
|
-
|
|
591
|
+
def read_events(assignment_id, commit: ref_value)
|
|
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
|
|
101
618
|
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
raise AttemptErrors::EvidenceUnavailable, "
|
|
108
|
-
|
|
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
|
|
109
652
|
end
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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"
|
|
113
667
|
end
|
|
668
|
+
private :read_event_snapshots!
|
|
114
669
|
|
|
115
670
|
# Accepted receipt payloads recorded for an assignment.
|
|
116
671
|
#
|
|
@@ -197,7 +752,7 @@ module Ace
|
|
|
197
752
|
%w[succeeded failed failed-settled].include?(state)
|
|
198
753
|
end
|
|
199
754
|
|
|
200
|
-
def validate_terminal_receipt!(current, state, receipt)
|
|
755
|
+
def validate_terminal_receipt!(current, state, receipt, pending: nil)
|
|
201
756
|
unless receipt.is_a?(Hash) && receipt.keys.sort == TERMINAL_RECEIPT_FIELDS.sort
|
|
202
757
|
raise AttemptErrors::ReceiptRejected,
|
|
203
758
|
"Service terminal receipt has invalid fields"
|
|
@@ -224,6 +779,27 @@ module Ace
|
|
|
224
779
|
raise AttemptErrors::ReceiptRejected,
|
|
225
780
|
"Service settlement requires a no-effect attestation artifact"
|
|
226
781
|
end
|
|
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
|
|
794
|
+
evidence_items.each do |item|
|
|
795
|
+
content = @evidence_reader.call(item, current, state, pending)
|
|
796
|
+
unless content.is_a?(String) && Digest::SHA256.hexdigest(content.b) == item["sha256"]
|
|
797
|
+
raise AttemptErrors::ReceiptRejected, "Canonical service evidence is unverifiable"
|
|
798
|
+
end
|
|
799
|
+
verify_service_attestation!(content, current, state)
|
|
800
|
+
end
|
|
801
|
+
return true
|
|
802
|
+
end
|
|
227
803
|
repo_root = File.realpath(@repo_root)
|
|
228
804
|
receipt["evidence"].each do |item|
|
|
229
805
|
path = File.expand_path(item["ref"], repo_root)
|
|
@@ -241,30 +817,90 @@ module Ace
|
|
|
241
817
|
end
|
|
242
818
|
raise AttemptErrors::ReceiptRejected,
|
|
243
819
|
"Service terminal receipt evidence is unverifiable: #{item["ref"]}" unless intact
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
820
|
+
verify_service_attestation!(File.read(real), current, state)
|
|
821
|
+
end
|
|
822
|
+
end
|
|
823
|
+
|
|
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})
|
|
253
846
|
end
|
|
847
|
+
view.freeze
|
|
848
|
+
{record: freeze_inventory_projection(record), settlement_context: view.projection, read_view: view}.freeze
|
|
254
849
|
end
|
|
255
850
|
|
|
256
|
-
def
|
|
851
|
+
def read_service_request(request_id, commit:, pending:, terminal: true)
|
|
852
|
+
context = Thread.current[:ace_assign_event_read_operation]
|
|
257
853
|
validate_request_id!(request_id)
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
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)}")
|
|
891
|
+
if status.success?
|
|
892
|
+
record = JSON.parse(out)
|
|
893
|
+
verify_service_record!(record, commit: commit) if @mode == :protected
|
|
894
|
+
return record
|
|
895
|
+
end
|
|
262
896
|
return nil if stderr.include?("does not exist") || stderr.include?("exists on disk")
|
|
263
897
|
raise AttemptErrors::EvidenceUnavailable, "Cannot read service request: #{stderr}"
|
|
264
898
|
rescue JSON::ParserError
|
|
265
899
|
raise AttemptErrors::EvidenceUnavailable, "Corrupt service request #{request_id}"
|
|
266
900
|
end
|
|
267
901
|
|
|
902
|
+
private :read_service_request, :read_service_request_record
|
|
903
|
+
|
|
268
904
|
# Every service request recorded in this evidence ref, whatever the
|
|
269
905
|
# owning assignment. Backs the journal-wide request-ID and
|
|
270
906
|
# authorization-consumption checks.
|
|
@@ -292,6 +928,12 @@ input:#{Regexp.escape(current["input_digest"])} outcome:(\S+)( no-effect:(\S+))?
|
|
|
292
928
|
def settlement_evidence_intact?(record)
|
|
293
929
|
receipt = record["receipt"]
|
|
294
930
|
return false unless receipt.is_a?(Hash)
|
|
931
|
+
if @mode == :protected
|
|
932
|
+
commit = ref_value
|
|
933
|
+
verify_service_record!(record, commit: commit)
|
|
934
|
+
validate_terminal_receipt!(record, "failed-settled", receipt, pending: {commit: commit})
|
|
935
|
+
return true
|
|
936
|
+
end
|
|
295
937
|
repo_root = File.realpath(@repo_root)
|
|
296
938
|
Array(receipt["evidence"]).all? do |item|
|
|
297
939
|
path = File.expand_path(item["ref"].to_s, repo_root)
|
|
@@ -307,10 +949,12 @@ input:#{Regexp.escape(current["input_digest"])} outcome:(\S+)( no-effect:(\S+))?
|
|
|
307
949
|
false
|
|
308
950
|
end
|
|
309
951
|
end
|
|
952
|
+
rescue AttemptErrors::EvidenceUnavailable, AttemptErrors::ReceiptRejected
|
|
953
|
+
false
|
|
310
954
|
end
|
|
311
955
|
|
|
312
|
-
def service_request_records
|
|
313
|
-
value =
|
|
956
|
+
def service_request_records(commit: ref_value)
|
|
957
|
+
value = commit
|
|
314
958
|
return [] unless value
|
|
315
959
|
paths, stderr, status = git("ls-tree", "-r", "--name-only", value, "--", "execution/requests/")
|
|
316
960
|
raise AttemptErrors::EvidenceUnavailable, "Cannot read service request index: #{stderr}" unless status.success?
|
|
@@ -319,7 +963,9 @@ input:#{Regexp.escape(current["input_digest"])} outcome:(\S+)( no-effect:(\S+))?
|
|
|
319
963
|
unless read_status.success?
|
|
320
964
|
raise AttemptErrors::EvidenceUnavailable, "Cannot read service request #{path}: #{error}"
|
|
321
965
|
end
|
|
322
|
-
JSON.parse(content)
|
|
966
|
+
record = JSON.parse(content)
|
|
967
|
+
verify_service_record!(record, commit: value) if @mode == :protected
|
|
968
|
+
record
|
|
323
969
|
end
|
|
324
970
|
rescue JSON::ParserError
|
|
325
971
|
raise AttemptErrors::EvidenceUnavailable, "Corrupt service request index"
|
|
@@ -366,8 +1012,8 @@ input:#{Regexp.escape(current["input_digest"])} outcome:(\S+)( no-effect:(\S+))?
|
|
|
366
1012
|
# ref itself (works even when the local assignment cache is gone).
|
|
367
1013
|
#
|
|
368
1014
|
# @return [Array<String>] Assignment IDs with journal evidence
|
|
369
|
-
def assignment_ids
|
|
370
|
-
value =
|
|
1015
|
+
def assignment_ids(commit: ref_value)
|
|
1016
|
+
value = commit
|
|
371
1017
|
return [] if value.nil?
|
|
372
1018
|
|
|
373
1019
|
paths, stderr, status = git("ls-tree", "-d", "--name-only", value, "--", "execution/")
|
|
@@ -378,6 +1024,46 @@ input:#{Regexp.escape(current["input_digest"])} outcome:(\S+)( no-effect:(\S+))?
|
|
|
378
1024
|
|
|
379
1025
|
private
|
|
380
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
|
+
|
|
381
1067
|
def update_service_request(request_id, expected:, replacement:, event_type:, guard: nil)
|
|
382
1068
|
validate_request_id!(request_id)
|
|
383
1069
|
with_lock do
|
|
@@ -386,47 +1072,20 @@ input:#{Regexp.escape(current["input_digest"])} outcome:(\S+)( no-effect:(\S+))?
|
|
|
386
1072
|
ensure_checkout!
|
|
387
1073
|
old = ref_value if old.nil?
|
|
388
1074
|
sync_checkout(old)
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
conflict = guard.call
|
|
394
|
-
if conflict
|
|
395
|
-
raise AttemptErrors::Conflict,
|
|
396
|
-
"Authorization reference already consumed by request #{conflict.fetch("request_id")}"
|
|
397
|
-
end
|
|
398
|
-
end
|
|
399
|
-
path = File.join(checkout_dir, service_request_path(request_id))
|
|
400
|
-
existing = File.exist?(path) ? JSON.parse(File.read(path)) : nil
|
|
401
|
-
if expected.nil? && existing
|
|
402
|
-
return existing if existing.except("state", "receipt", "reason", "claimed_at") ==
|
|
403
|
-
replacement.except("state", "receipt", "reason", "claimed_at")
|
|
404
|
-
raise AttemptErrors::Conflict, "Service request #{request_id} has different input"
|
|
405
|
-
end
|
|
406
|
-
if expected && existing != expected
|
|
407
|
-
raise AttemptErrors::Conflict, "Service request #{request_id} changed during transition"
|
|
408
|
-
end
|
|
409
|
-
terminal = existing && %w[succeeded failed rejected].include?(existing["state"])
|
|
410
|
-
settlement = existing && existing["state"] == "failed" && replacement["state"] == "failed-settled"
|
|
411
|
-
if expected && existing && terminal && !settlement
|
|
412
|
-
raise AttemptErrors::InvalidState, "Service request #{request_id} is terminal"
|
|
413
|
-
end
|
|
414
|
-
|
|
415
|
-
FileUtils.mkdir_p(File.dirname(path))
|
|
416
|
-
File.write(path, JSON.pretty_generate(replacement))
|
|
1075
|
+
prepared = prepare_service_update(request_id: request_id, expected: expected,
|
|
1076
|
+
replacement: replacement, event_type: event_type, guard: guard)
|
|
1077
|
+
return prepared.fetch(:record) if prepared[:replayed]
|
|
1078
|
+
write_service_records([prepared])
|
|
417
1079
|
assignment_id = replacement.fetch("assignment_id")
|
|
418
1080
|
attempt_id = replacement.fetch("attempt_id")
|
|
419
1081
|
prior = read_events(assignment_id).reverse
|
|
420
1082
|
.find { |entry| entry["attempt_id"] == attempt_id }&.fetch("digest")
|
|
421
|
-
payload = {"request_id" => request_id, "state" => replacement.fetch("state"),
|
|
422
|
-
"input_digest" => replacement.fetch("input_digest"),
|
|
423
|
-
"receipt_digest" => replacement["receipt"] &&
|
|
424
|
-
Atoms::EvidenceDigest.digest(replacement["receipt"])}
|
|
425
1083
|
event = Models::EvidenceEvent.build(type: event_type, attempt_id: attempt_id,
|
|
426
|
-
payload: payload, previous_digest: prior)
|
|
1084
|
+
payload: prepared.fetch(:event).fetch(:payload), previous_digest: prior)
|
|
427
1085
|
write_event_files(assignment_id, [event])
|
|
428
1086
|
git!("-C", checkout_dir, "add", "--", service_request_path(request_id),
|
|
429
1087
|
"execution/#{assignment_id}/events")
|
|
1088
|
+
stage_service_records([prepared])
|
|
430
1089
|
state = replacement.fetch("state")
|
|
431
1090
|
git!("-C", checkout_dir, "-c", "user.name=ace-assign", "-c", "user.email=ace-assign@localhost",
|
|
432
1091
|
"commit", "-m", "evidence: service request #{request_id} #{state}")
|
|
@@ -437,6 +1096,146 @@ input:#{Regexp.escape(current["input_digest"])} outcome:(\S+)( no-effect:(\S+))?
|
|
|
437
1096
|
end
|
|
438
1097
|
end
|
|
439
1098
|
|
|
1099
|
+
# Shared owner for ordinary service writes and atomic authority
|
|
1100
|
+
# imports. Call only after checkout sync, under this journal's CAS lock.
|
|
1101
|
+
def prepare_service_update(request_id:, expected:, replacement:, event_type:, guard: nil, pending: nil)
|
|
1102
|
+
validate_request_id!(request_id)
|
|
1103
|
+
unless replacement.is_a?(Hash) && replacement["request_id"] == request_id &&
|
|
1104
|
+
%w[service_claim service_transition service_challenge].include?(event_type) &&
|
|
1105
|
+
%w[accepted uncertain rejected succeeded failed failed-settled].include?(replacement["state"])
|
|
1106
|
+
raise ArgumentError, "invalid service update plan"
|
|
1107
|
+
end
|
|
1108
|
+
existing = service_request(request_id)
|
|
1109
|
+
if expected.nil? && existing
|
|
1110
|
+
return {record: existing, replayed: true} if existing.except("state", "receipt", "reason", "claimed_at") ==
|
|
1111
|
+
replacement.except("state", "receipt", "reason", "claimed_at")
|
|
1112
|
+
raise AttemptErrors::Conflict, "Service request #{request_id} has different input"
|
|
1113
|
+
end
|
|
1114
|
+
if expected && existing != expected
|
|
1115
|
+
raise AttemptErrors::Conflict, "Service request #{request_id} changed during transition"
|
|
1116
|
+
end
|
|
1117
|
+
if expected.nil?
|
|
1118
|
+
unless event_type == "service_claim" && %w[accepted uncertain rejected].include?(replacement["state"])
|
|
1119
|
+
raise AttemptErrors::InvalidState, "Service request needs an initial claim"
|
|
1120
|
+
end
|
|
1121
|
+
if %w[accepted uncertain].include?(replacement["state"])
|
|
1122
|
+
if replacement["authorization"].to_s.start_with?("proposal-")
|
|
1123
|
+
unless respond_to?(:proposal_authorize!, true)
|
|
1124
|
+
raise AttemptErrors::UnauthorizedIdentity, "Canonical proposal producer is unavailable"
|
|
1125
|
+
end
|
|
1126
|
+
proposal_authorize!(replacement["authorization"], replacement)
|
|
1127
|
+
end
|
|
1128
|
+
conflict = authorization_conflict(replacement)
|
|
1129
|
+
raise_authorization_conflict!(conflict) if conflict
|
|
1130
|
+
end
|
|
1131
|
+
else
|
|
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
|
|
1136
|
+
raise AttemptErrors::InvalidState, "Existing service request needs a transition"
|
|
1137
|
+
end
|
|
1138
|
+
terminal = %w[succeeded failed rejected failed-settled].include?(existing["state"])
|
|
1139
|
+
settlement = replacement["state"] == "failed-settled" &&
|
|
1140
|
+
(existing["state"] == "failed" || (existing["state"] == "rejected" && existing["consumed"] != false))
|
|
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
|
|
1146
|
+
raise AttemptErrors::InvalidState, "Service request #{request_id} is terminal"
|
|
1147
|
+
end
|
|
1148
|
+
mutable = %w[state receipt reason claimed_at failed_at dispatch_phase no_effect_challenge
|
|
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
|
|
1169
|
+
unless existing.except(*mutable) == replacement.except(*mutable)
|
|
1170
|
+
raise AttemptErrors::Conflict, "Service request #{request_id} changed immutable binding"
|
|
1171
|
+
end
|
|
1172
|
+
end
|
|
1173
|
+
conflict = guard&.call
|
|
1174
|
+
raise_authorization_conflict!(conflict) if conflict
|
|
1175
|
+
@service_authorizer.call(existing, replacement, pending) if @mode == :protected
|
|
1176
|
+
validate_terminal_receipt!(replacement, replacement["state"], replacement["receipt"], pending: pending) if terminal_state?(replacement["state"])
|
|
1177
|
+
payload = {"request_id" => request_id, "state" => replacement.fetch("state"),
|
|
1178
|
+
"input_digest" => replacement.fetch("input_digest"),
|
|
1179
|
+
"record_digest" => Atoms::EvidenceDigest.digest(replacement),
|
|
1180
|
+
"receipt_digest" => replacement["receipt"] && Atoms::EvidenceDigest.digest(replacement["receipt"])}
|
|
1181
|
+
{record: replacement, path: service_request_path(request_id),
|
|
1182
|
+
event: {type: event_type, payload: payload}, replayed: false}
|
|
1183
|
+
end
|
|
1184
|
+
|
|
1185
|
+
def prepare_service_updates(updates, assignment_id:, attempt_id:, pending:)
|
|
1186
|
+
unless updates.is_a?(Array) && updates.length <= 1
|
|
1187
|
+
raise ArgumentError, "one service update is allowed per authority mutation"
|
|
1188
|
+
end
|
|
1189
|
+
updates.map do |update|
|
|
1190
|
+
unless update.is_a?(Hash) && update.keys.sort == %i[event_type expected replacement request_id].sort &&
|
|
1191
|
+
update.dig(:replacement, "assignment_id") == assignment_id && update.dig(:replacement, "attempt_id") == attempt_id
|
|
1192
|
+
raise ArgumentError, "service update does not match mutation attempt"
|
|
1193
|
+
end
|
|
1194
|
+
prepare_service_update(**update, pending: pending)
|
|
1195
|
+
end.reject { |prepared| prepared[:replayed] }
|
|
1196
|
+
end
|
|
1197
|
+
|
|
1198
|
+
def write_service_records(records)
|
|
1199
|
+
records.each do |prepared|
|
|
1200
|
+
path = File.join(checkout_dir, prepared.fetch(:path))
|
|
1201
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
1202
|
+
File.write(path, JSON.pretty_generate(prepared.fetch(:record)))
|
|
1203
|
+
end
|
|
1204
|
+
end
|
|
1205
|
+
|
|
1206
|
+
def stage_service_records(records)
|
|
1207
|
+
stage_mutation_blobs(records.to_h { |prepared| [prepared.fetch(:path), JSON.pretty_generate(prepared.fetch(:record))] })
|
|
1208
|
+
end
|
|
1209
|
+
|
|
1210
|
+
def verify_service_record!(record, commit: ref_value)
|
|
1211
|
+
events = read_events(record.fetch("assignment_id"), commit: commit).select { |event| event["attempt_id"] == record.fetch("attempt_id") }
|
|
1212
|
+
unless Models::EvidenceEvent.chain_valid?(events)
|
|
1213
|
+
raise AttemptErrors::EvidenceUnavailable, "Canonical service event chain is unverifiable"
|
|
1214
|
+
end
|
|
1215
|
+
event = events.reverse.find do |entry|
|
|
1216
|
+
%w[service_claim service_transition service_challenge].include?(entry["type"]) && entry.dig("payload", "request_id") == record["request_id"]
|
|
1217
|
+
end
|
|
1218
|
+
unless event && event.dig("payload", "record_digest") == Atoms::EvidenceDigest.digest(record)
|
|
1219
|
+
raise AttemptErrors::EvidenceUnavailable, "Canonical service record does not match accepted event"
|
|
1220
|
+
end
|
|
1221
|
+
rescue KeyError, TypeError
|
|
1222
|
+
raise AttemptErrors::EvidenceUnavailable, "Canonical service record has invalid binding"
|
|
1223
|
+
end
|
|
1224
|
+
|
|
1225
|
+
def raise_authorization_conflict!(conflict)
|
|
1226
|
+
raise AttemptErrors::Conflict,
|
|
1227
|
+
"Authorization reference already consumed by request #{conflict.fetch('request_id')}"
|
|
1228
|
+
end
|
|
1229
|
+
|
|
1230
|
+
def verify_service_attestation!(content, request, state)
|
|
1231
|
+
expression = /^ace-service-attestation request:#{Regexp.escape(request["request_id"])} input:#{Regexp.escape(request["input_digest"])} outcome:(\S+)( no-effect:(\S+))?$/
|
|
1232
|
+
attested = content.scan(expression).first
|
|
1233
|
+
outcome = state == "failed-settled" ? "failed" : state
|
|
1234
|
+
unless attested && attested.first == outcome && (state != "failed-settled" || attested[2] == "true")
|
|
1235
|
+
raise AttemptErrors::ReceiptRejected, "Service terminal evidence does not attest #{outcome}"
|
|
1236
|
+
end
|
|
1237
|
+
end
|
|
1238
|
+
|
|
440
1239
|
def service_request_path(request_id)
|
|
441
1240
|
"execution/requests/#{request_id}.json"
|
|
442
1241
|
end
|
|
@@ -449,39 +1248,31 @@ input:#{Regexp.escape(current["input_digest"])} outcome:(\S+)( no-effect:(\S+))?
|
|
|
449
1248
|
# Latest lifecycle state implied by the events, or nil when the
|
|
450
1249
|
# events do not describe a full attempt (intent missing).
|
|
451
1250
|
def derive_state(events)
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
state = "running"
|
|
455
|
-
events.each do |event|
|
|
456
|
-
case event["type"]
|
|
457
|
-
when "receipt_accepted"
|
|
458
|
-
state = event.dig("payload", "receipt", "verdict") || state
|
|
459
|
-
when "transition"
|
|
460
|
-
state = event.dig("payload", "to") || state
|
|
461
|
-
when "reconciliation"
|
|
462
|
-
state = event.dig("payload", "resolution") || state
|
|
463
|
-
end
|
|
464
|
-
end
|
|
465
|
-
state
|
|
1251
|
+
CanonicalAttemptState.derive(events)
|
|
466
1252
|
end
|
|
467
1253
|
|
|
468
1254
|
def build_attempt(assignment_id, attempt_id, intent_payload, events, state)
|
|
469
1255
|
process_start = events.reverse.find do |event|
|
|
470
1256
|
event["type"] == "process_start" && event["attempt_id"] == attempt_id
|
|
471
1257
|
end
|
|
472
|
-
candidate_head =
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
1258
|
+
candidate_head = nil
|
|
1259
|
+
events.each do |event|
|
|
1260
|
+
case event["type"]
|
|
1261
|
+
when "candidate_invalidated" then candidate_head = nil
|
|
1262
|
+
when "receipt_accepted" then candidate_head ||= event.dig("payload", "receipt", "head")
|
|
1263
|
+
when "delivery"
|
|
1264
|
+
candidate_head = event.dig("payload", "head") if %w[intent result].include?(event.dig("payload", "stage"))
|
|
1265
|
+
end
|
|
1266
|
+
end
|
|
476
1267
|
binding = Models::AttemptBinding.new(
|
|
477
1268
|
attempt_id: attempt_id,
|
|
478
1269
|
assignment_id: assignment_id,
|
|
479
1270
|
scope: intent_payload["scope"],
|
|
480
1271
|
project_id: intent_payload["project_id"],
|
|
481
1272
|
task_id: intent_payload["task_id"],
|
|
482
|
-
actor: process_start&.dig("payload", "actor") || "recovered",
|
|
483
|
-
role: process_start&.dig("payload", "role") || "coordinator",
|
|
484
|
-
runtime: process_start&.dig("payload", "runtime") || "recovered",
|
|
1273
|
+
actor: process_start&.dig("payload", "actor") || intent_payload["actor"] || "recovered",
|
|
1274
|
+
role: process_start&.dig("payload", "role") || intent_payload["role"] || "coordinator",
|
|
1275
|
+
runtime: process_start&.dig("payload", "runtime") || intent_payload["runtime"] || "recovered",
|
|
485
1276
|
base_head: intent_payload["base_head"],
|
|
486
1277
|
evidence_git_ref: ref,
|
|
487
1278
|
created_at: parse_event_time(intent_time(events, attempt_id))
|
|
@@ -596,16 +1387,52 @@ input:#{Regexp.escape(current["input_digest"])} outcome:(\S+)( no-effect:(\S+))?
|
|
|
596
1387
|
File.join(@checkout_root, ".evidence.lock")
|
|
597
1388
|
end
|
|
598
1389
|
|
|
599
|
-
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
|
|
600
1392
|
FileUtils.mkdir_p(@checkout_root)
|
|
601
|
-
|
|
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
|
|
602
1399
|
lock.flock(File::LOCK_EX)
|
|
603
1400
|
begin
|
|
604
1401
|
yield
|
|
1402
|
+
rescue StandardError
|
|
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"))
|
|
1406
|
+
raise
|
|
605
1407
|
ensure
|
|
606
1408
|
lock.flock(File::LOCK_UN)
|
|
607
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"
|
|
608
1431
|
end
|
|
1432
|
+
file
|
|
1433
|
+
rescue Exception
|
|
1434
|
+
file&.close
|
|
1435
|
+
raise
|
|
609
1436
|
end
|
|
610
1437
|
|
|
611
1438
|
def ensure_checkout!
|
|
@@ -648,6 +1475,9 @@ input:#{Regexp.escape(current["input_digest"])} outcome:(\S+)( no-effect:(\S+))?
|
|
|
648
1475
|
return if target.nil?
|
|
649
1476
|
|
|
650
1477
|
git!("-C", checkout_dir, "reset", "--hard", target)
|
|
1478
|
+
# All writers use this boundary, including CAS retries. Only the
|
|
1479
|
+
# journal's disposable namespaces are cleaned, never the candidate.
|
|
1480
|
+
git!("-C", checkout_dir, "clean", "-fd", "--", "execution", "evidence")
|
|
651
1481
|
rescue AttemptErrors::EvidenceUnavailable
|
|
652
1482
|
# Stale, broken, or unregistered worktree: rebuild from scratch.
|
|
653
1483
|
FileUtils.rm_rf(checkout_dir)
|
|
@@ -656,6 +1486,7 @@ input:#{Regexp.escape(current["input_digest"])} outcome:(\S+)( no-effect:(\S+))?
|
|
|
656
1486
|
end
|
|
657
1487
|
|
|
658
1488
|
def git(*argv)
|
|
1489
|
+
return @read_boundary.call(argv) if @read_boundary
|
|
659
1490
|
unless File.directory?(@repo_root)
|
|
660
1491
|
return ["", "repository root missing: #{@repo_root}", FAILED_RESULT]
|
|
661
1492
|
end
|