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
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
require "yaml"
|
|
3
|
+
require "ace/git/organisms/pull_request_lifecycle"
|
|
4
|
+
require_relative "../atoms/delivery_parameters"
|
|
5
|
+
|
|
6
|
+
module Ace
|
|
7
|
+
module Assign
|
|
8
|
+
module Organisms
|
|
9
|
+
# Remote delivery is a projection of the existing attempt journal.
|
|
10
|
+
# Intents precede writes; an interrupted intent permits reconciliation
|
|
11
|
+
# reads only. Privileged merge is executed by qjx, never this worker.
|
|
12
|
+
class DeliveryCoordinator
|
|
13
|
+
def initialize(repo_root:, coordinator: nil, journal: nil, lifecycle_factory: nil, exclusion: nil, identity_resolver: nil)
|
|
14
|
+
@mutex = Mutex.new
|
|
15
|
+
@identity_resolver = identity_resolver || Molecules::ExecutionIdentityResolver.new
|
|
16
|
+
@repo_root = repo_root
|
|
17
|
+
@journal = journal || Molecules::EvidenceJournal.new(repo_root: repo_root)
|
|
18
|
+
@coordinator = coordinator || AttemptCoordinator.new(repo_root: repo_root, journal: @journal)
|
|
19
|
+
@factory = lifecycle_factory || ->(selection) { Ace::Git::Organisms::PullRequestLifecycle.new(**selection) }
|
|
20
|
+
@exclusion = exclusion || Molecules::LifecycleExclusion.new(repo_root: repo_root)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def perform(**arguments)
|
|
24
|
+
@mutex.synchronize { execute_request(**arguments) }
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
private
|
|
28
|
+
|
|
29
|
+
def execute_request(assignment_id:, attempt_id:, operation:, parameters: nil, title: nil, body: nil,
|
|
30
|
+
tests: nil, review: nil, service_request_id: nil)
|
|
31
|
+
@identity = @identity_resolver.resolve
|
|
32
|
+
unless @identity_resolver.trusted?(@identity)
|
|
33
|
+
raise AttemptErrors::UnauthorizedIdentity, "Only a trusted coordinator or service may accept delivery evidence"
|
|
34
|
+
end
|
|
35
|
+
raise ArgumentError, "Unsupported delivery operation" unless %w[create update ready review merge status].include?(operation)
|
|
36
|
+
if operation == "create" && (!title.is_a?(String) || title.strip.empty?)
|
|
37
|
+
raise ArgumentError, "Create requires a concrete PR title"
|
|
38
|
+
end
|
|
39
|
+
if operation == "update" && title.nil? && body.nil?
|
|
40
|
+
raise ArgumentError, "Update requires title or body"
|
|
41
|
+
end
|
|
42
|
+
raise ArgumentError, "PR body must be text" unless body.nil? || body.is_a?(String)
|
|
43
|
+
params = Atoms::DeliveryParameters.validate(parameters || assignment_parameters(assignment_id))
|
|
44
|
+
binding = {"assignment_id" => assignment_id, "attempt_id" => attempt_id}
|
|
45
|
+
attempt = @coordinator.authoritative_attempt(binding)
|
|
46
|
+
raise AttemptErrors::NotFound, "Delivery attempt not found" unless attempt
|
|
47
|
+
raise AttemptErrors::ReceiptRejected, "Delivery requires a managed attempt" unless attempt.managed?
|
|
48
|
+
@exclusion.with_shared(@exclusion.task_key(attempt.binding.task_id)) do
|
|
49
|
+
@exclusion.with_exclusive(@exclusion.assignment_key(assignment_id)) do
|
|
50
|
+
raise AttemptErrors::Conflict, "Assignment was pruned" if @exclusion.removed?(@exclusion.assignment_key(assignment_id))
|
|
51
|
+
execute(binding, operation, params, title: title, body: body, tests: tests, review: review,
|
|
52
|
+
service_request_id: service_request_id)
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
private
|
|
58
|
+
|
|
59
|
+
def assignment_parameters(assignment_id)
|
|
60
|
+
assignment = Molecules::AssignmentManager.new.load(assignment_id)
|
|
61
|
+
raise AttemptErrors::NotFound, "Assignment not found" unless assignment
|
|
62
|
+
document = YAML.safe_load_file(assignment.source_config, permitted_classes: [Time, Date])
|
|
63
|
+
document.fetch("delivery")
|
|
64
|
+
rescue KeyError, Errno::ENOENT, Psych::Exception
|
|
65
|
+
raise ArgumentError, "Assignment needs explicit delivery parameters"
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def execute(binding, operation, params, **options)
|
|
69
|
+
@binding = binding
|
|
70
|
+
@intent_context = nil
|
|
71
|
+
@attempt = @coordinator.authoritative_attempt(binding)
|
|
72
|
+
unless @attempt&.active? && @attempt.managed?
|
|
73
|
+
raise AttemptErrors::ReceiptRejected, "Delivery attempt is no longer active"
|
|
74
|
+
end
|
|
75
|
+
@head = git!("rev-parse", "HEAD").strip
|
|
76
|
+
selection = {server_name: params["forge_server"], use_default: params["forge_default"], repo_root: @repo_root}
|
|
77
|
+
historical = events.find { |event| event.dig("payload", "stage") == "identity" }&.fetch("payload")
|
|
78
|
+
selection = {server_name: historical.dig("server", "name"), use_default: false, repo_root: @repo_root} if historical
|
|
79
|
+
@lifecycle = @factory.call(selection)
|
|
80
|
+
server = @lifecycle.resolved_identity
|
|
81
|
+
@server = server
|
|
82
|
+
Atoms::DeliveryParameters.validate_url!(server.fetch("url"))
|
|
83
|
+
provenance = params.fetch("pr_provenance")
|
|
84
|
+
unless Ace::Git::Atoms::ServerUrl.match?(server["url"], provenance["base_repository_url"])
|
|
85
|
+
raise Ace::Git::ProviderIdentityMismatchError, "Selected forge does not match delivery base"
|
|
86
|
+
end
|
|
87
|
+
if historical
|
|
88
|
+
unless historical["server"] == server && historical["provenance"] == provenance &&
|
|
89
|
+
historical["selection"] == params.slice("forge_server", "forge_default")
|
|
90
|
+
raise AttemptErrors::Conflict, "Delivery identity or selection changed during attempt"
|
|
91
|
+
end
|
|
92
|
+
elsif !%w[status review].include?(operation)
|
|
93
|
+
record("stage" => "identity", "server" => server, "provenance" => provenance,
|
|
94
|
+
"selection" => params.slice("forge_server", "forge_default"))
|
|
95
|
+
end
|
|
96
|
+
@provenance = provenance
|
|
97
|
+
pinned = Ace::Git::ResolvedServer.new(name: server.fetch("name"),
|
|
98
|
+
provider: server.fetch("provider").to_sym, url: server.fetch("url"))
|
|
99
|
+
@lifecycle = @factory.call(server_name: pinned.name, use_default: false,
|
|
100
|
+
repo_root: @repo_root, resolved_server: pinned)
|
|
101
|
+
verify_pushed_head!
|
|
102
|
+
return status if operation == "status"
|
|
103
|
+
return review_snapshot if operation == "review"
|
|
104
|
+
pending = unresolved_intent
|
|
105
|
+
if pending
|
|
106
|
+
result = reconcile(pending)
|
|
107
|
+
return result if same_effect?(pending, operation, options)
|
|
108
|
+
raise AttemptErrors::CurrentEffectRequired.new(operation: operation, recovered_operation: pending["operation"])
|
|
109
|
+
end
|
|
110
|
+
require_evidence!(options[:tests], options[:review]) if %w[ready merge].include?(operation)
|
|
111
|
+
return consume_merge(options[:service_request_id]) if operation == "merge"
|
|
112
|
+
pr = current_pr
|
|
113
|
+
if operation == "create" && pr
|
|
114
|
+
requested = content_input(options)
|
|
115
|
+
metadata = @lifecycle.review_metadata_snapshot(pr.number).pull_request
|
|
116
|
+
verify_pr!(metadata)
|
|
117
|
+
unless content_matches?(requested.keys.map(&:to_s), Atoms::EvidenceDigest.digest(requested), metadata)
|
|
118
|
+
raise AttemptErrors::CurrentEffectRequired.new(operation: operation, recovered_operation: "create")
|
|
119
|
+
end
|
|
120
|
+
return reconcile({"operation" => "create", "head" => @head,
|
|
121
|
+
"fields" => requested.keys.map(&:to_s), "input_digest" => Atoms::EvidenceDigest.digest(requested)})
|
|
122
|
+
end
|
|
123
|
+
raise AttemptErrors::ReceiptRejected, "Create a draft before #{operation}" if operation != "create" && !pr
|
|
124
|
+
verify_pr!(pr) if pr
|
|
125
|
+
intent = {"stage" => "intent", "operation" => operation, "head" => @head,
|
|
126
|
+
"input_digest" => Atoms::EvidenceDigest.digest(content_input(options)),
|
|
127
|
+
"fields" => content_input(options).keys.map(&:to_s),
|
|
128
|
+
"pr_number" => pr&.number, "tests" => options[:tests], "review" => options[:review]}
|
|
129
|
+
record(intent)
|
|
130
|
+
@intent_context = unresolved_intent
|
|
131
|
+
receipt = case operation
|
|
132
|
+
when "create"
|
|
133
|
+
@lifecycle.create(head_ref: provenance["head_ref"], base_ref: provenance["base_ref"],
|
|
134
|
+
head_repository_url: provenance["head_repository_url"], expected_head: @head,
|
|
135
|
+
title: options[:title], body: options[:body], draft: true)
|
|
136
|
+
when "update"
|
|
137
|
+
@lifecycle.update(pr.number, expected_head: @head, title: options[:title], body: options[:body])
|
|
138
|
+
when "ready" then @lifecycle.ready(pr.number, expected_head: @head)
|
|
139
|
+
end
|
|
140
|
+
verify_pr!(receipt.pull_request)
|
|
141
|
+
unless receipt.operation.to_s == operation && receipt.server_name == server["name"] &&
|
|
142
|
+
(operation != "create" || receipt.pull_request.draft == true) &&
|
|
143
|
+
(operation != "ready" || receipt.pull_request.draft == false)
|
|
144
|
+
raise Ace::Git::ProviderUnknownOutcomeError, "Delivery mutation lacks expected outcome"
|
|
145
|
+
end
|
|
146
|
+
record_result(operation, receipt.pull_request, "succeeded")
|
|
147
|
+
status
|
|
148
|
+
rescue Ace::Git::ProviderUnknownOutcomeError
|
|
149
|
+
# The durable intent remains unresolved; no exception text or
|
|
150
|
+
# provider payload is stored (both may contain sensitive data).
|
|
151
|
+
raise
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def events
|
|
155
|
+
@journal.read_events(@binding.fetch("assignment_id")).select do |event|
|
|
156
|
+
event["type"] == "delivery"
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def record(payload)
|
|
161
|
+
producer = {"actor" => @identity.actor, "role" => @identity.role, "runtime" => @identity.runtime}
|
|
162
|
+
@journal.record(**@binding.transform_keys(&:to_sym), type: "delivery", payload: payload.merge("producer" => producer),
|
|
163
|
+
guard: -> {
|
|
164
|
+
live = @coordinator.authoritative_attempt(@binding)
|
|
165
|
+
raise AttemptErrors::ReceiptRejected, "Delivery attempt is no longer active" unless live&.active?
|
|
166
|
+
raise AttemptErrors::ReceiptRejected, "Candidate changed during delivery" unless git!("rev-parse", "HEAD").strip == @head
|
|
167
|
+
})
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def unresolved_intent
|
|
171
|
+
history = events
|
|
172
|
+
settled = history.filter_map do |event|
|
|
173
|
+
payload = event.fetch("payload")
|
|
174
|
+
payload["intent_digest"] if payload["stage"] == "result" && payload["outcome"] == "succeeded"
|
|
175
|
+
end
|
|
176
|
+
pending = history.select do |event|
|
|
177
|
+
event.dig("payload", "stage") == "intent" && !settled.include?(event["digest"])
|
|
178
|
+
end
|
|
179
|
+
raise AttemptErrors::Conflict, "Multiple unresolved delivery intents require reconciliation" if pending.length > 1
|
|
180
|
+
event = pending.first
|
|
181
|
+
event && event.fetch("payload").merge("intent_digest" => event.fetch("digest"),
|
|
182
|
+
"intent_attempt_id" => event.fetch("attempt_id"))
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def current_pr
|
|
186
|
+
result = events.reverse.find { |event| event.dig("payload", "stage") == "result" && event.dig("payload", "pr") }
|
|
187
|
+
result && @lifecycle.show(result.dig("payload", "pr", "number"))
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def reconcile(intent)
|
|
191
|
+
@intent_context = intent
|
|
192
|
+
raise AttemptErrors::ReceiptRejected, "Unresolved delivery belongs to an older candidate" unless intent["head"] == @head
|
|
193
|
+
operation = intent.fetch("operation")
|
|
194
|
+
if operation == "create"
|
|
195
|
+
matches = @lifecycle.reconcile_create(**@provenance.reject { |key, _| key == "mode" }.transform_keys(&:to_sym))
|
|
196
|
+
raise Ace::Git::ProviderUnknownOutcomeError, "No exact draft found; create remains unresolved" if matches.empty?
|
|
197
|
+
raise Ace::Git::ProviderConflictingMatchesError, "Multiple exact drafts found" unless matches.length == 1
|
|
198
|
+
pr = matches.first
|
|
199
|
+
verify_pr!(pr)
|
|
200
|
+
raise Ace::Git::ProviderUnknownOutcomeError, "Created PR draft state is unproven" unless pr.draft == true
|
|
201
|
+
pr = @lifecycle.review_metadata_snapshot(pr.number).pull_request
|
|
202
|
+
verify_pr!(pr)
|
|
203
|
+
unless content_matches?(intent.fetch("fields"), intent.fetch("input_digest"), pr)
|
|
204
|
+
raise Ace::Git::ProviderUnknownOutcomeError, "Created PR content remains unproven"
|
|
205
|
+
end
|
|
206
|
+
elsif operation == "ready"
|
|
207
|
+
require_evidence!(intent["tests"], intent["review"])
|
|
208
|
+
pr = @lifecycle.show(intent.fetch("pr_number"))
|
|
209
|
+
verify_pr!(pr)
|
|
210
|
+
raise Ace::Git::ProviderUnknownOutcomeError, "Readiness remains unresolved" unless pr.draft == false
|
|
211
|
+
elsif operation == "update"
|
|
212
|
+
pr = @lifecycle.review_metadata_snapshot(intent.fetch("pr_number")).pull_request
|
|
213
|
+
verify_pr!(pr)
|
|
214
|
+
unless content_matches?(intent.fetch("fields"), intent.fetch("input_digest"), pr)
|
|
215
|
+
raise Ace::Git::ProviderUnknownOutcomeError, "PR update remains unresolved"
|
|
216
|
+
end
|
|
217
|
+
else
|
|
218
|
+
raise Ace::Git::ProviderUnknownOutcomeError, "#{operation} requires authoritative receipt reconciliation"
|
|
219
|
+
end
|
|
220
|
+
record_result(operation, pr, "succeeded")
|
|
221
|
+
status
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
def content_input(options)
|
|
225
|
+
options.slice(:title, :body).compact
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
def content_matches?(fields, digest, pr)
|
|
229
|
+
observed = fields.to_h { |field| [field, pr.public_send(field)] }
|
|
230
|
+
Atoms::EvidenceDigest.digest(observed) == digest
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
def same_effect?(intent, operation, options)
|
|
234
|
+
return false unless intent["operation"] == operation
|
|
235
|
+
case operation
|
|
236
|
+
when "create", "update"
|
|
237
|
+
intent["input_digest"] == Atoms::EvidenceDigest.digest(content_input(options))
|
|
238
|
+
when "ready"
|
|
239
|
+
%i[tests review].all? { |key| options[key].nil? || options[key] == intent[key.to_s] }
|
|
240
|
+
when "merge"
|
|
241
|
+
options[:service_request_id].is_a?(String) &&
|
|
242
|
+
options[:service_request_id] == intent["service_request_id"] &&
|
|
243
|
+
%i[tests review].all? { |key| options[key].nil? || options[key] == intent[key.to_s] }
|
|
244
|
+
else false
|
|
245
|
+
end
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
def require_evidence!(tests, review)
|
|
249
|
+
[[tests, "check"], [review, "review-approval"]].each do |ref, kind|
|
|
250
|
+
unless ref.is_a?(Hash) && ref.keys.sort == %w[attempt_id receipt_digest]
|
|
251
|
+
raise AttemptErrors::ReceiptRejected, "Current accepted #{kind} evidence is required"
|
|
252
|
+
end
|
|
253
|
+
evidence = @coordinator.evidence(attempt_id: ref["attempt_id"], receipt_digest: ref["receipt_digest"], kind: kind)
|
|
254
|
+
unless evidence["head"] == @head && evidence["assignment_id"] == @binding["assignment_id"] &&
|
|
255
|
+
evidence["project_id"] == @attempt.binding.project_id
|
|
256
|
+
raise AttemptErrors::ReceiptRejected, "Delivery evidence does not match assignment, project and candidate"
|
|
257
|
+
end
|
|
258
|
+
end
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
def consume_merge(request_id)
|
|
262
|
+
unless request_id.is_a?(String) && request_id.match?(/\A[a-zA-Z0-9][a-zA-Z0-9_.-]{0,127}\z/)
|
|
263
|
+
raise AttemptErrors::ReceiptRejected, "Exact authorized merge service receipt is required"
|
|
264
|
+
end
|
|
265
|
+
request = @coordinator.service_request_status(request_id)
|
|
266
|
+
unless request && request["state"] == "succeeded" && request["operation"] == "merge" &&
|
|
267
|
+
request["assignment_id"] == @binding["assignment_id"] && request["attempt_id"] == @binding["attempt_id"] &&
|
|
268
|
+
request["project_id"] == @attempt.binding.project_id && request["candidate_head"] == @head &&
|
|
269
|
+
request["authorization"].is_a?(String) && !request["authorization"].empty?
|
|
270
|
+
raise AttemptErrors::ReceiptRejected, "Exact authorized merge service receipt is required"
|
|
271
|
+
end
|
|
272
|
+
@coordinator.validate_service_receipt!(request, "succeeded", request["receipt"])
|
|
273
|
+
pr = current_pr
|
|
274
|
+
verify_pr!(pr)
|
|
275
|
+
unless request.dig("target", "resource") == pr.url && pr.state.to_s == "merged" &&
|
|
276
|
+
pr.merge_commit_sha.to_s.match?(/\A[0-9a-f]{40}\z/)
|
|
277
|
+
raise AttemptErrors::ReceiptRejected, "Service receipt and merged PR identity do not agree"
|
|
278
|
+
end
|
|
279
|
+
record_result("merge", pr, "succeeded", "service_request_id" => request_id)
|
|
280
|
+
status
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
def review_snapshot
|
|
284
|
+
pr = current_pr
|
|
285
|
+
raise AttemptErrors::ReceiptRejected, "Create a draft before remote review" unless pr
|
|
286
|
+
verify_pr!(pr)
|
|
287
|
+
snapshot = @lifecycle.review_snapshot(pr.number)
|
|
288
|
+
verify_pr!(snapshot.pull_request)
|
|
289
|
+
snapshot
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
def verify_pr!(pr)
|
|
293
|
+
reference = pr && Ace::Git::Atoms::PrReference.parse(pr.url)
|
|
294
|
+
unless reference && reference.number == pr.number && reference.repository_url &&
|
|
295
|
+
Ace::Git::Atoms::ServerUrl.match?(reference.repository_url, @provenance["base_repository_url"])
|
|
296
|
+
raise Ace::Git::ProviderIdentityMismatchError, "PR URL does not match delivery repository and number"
|
|
297
|
+
end
|
|
298
|
+
Atoms::DeliveryParameters.validate_url!(pr.url)
|
|
299
|
+
unless pr && pr.server_name == @server["name"] && pr.number.is_a?(Integer) && pr.number.positive? &&
|
|
300
|
+
pr.head_sha == @head && pr.head_ref == @provenance["head_ref"] &&
|
|
301
|
+
pr.base_ref == @provenance["base_ref"] &&
|
|
302
|
+
Ace::Git::Atoms::ServerUrl.match?(pr.head_repository_url, @provenance["head_repository_url"]) &&
|
|
303
|
+
Ace::Git::Atoms::ServerUrl.match?(pr.base_repository_url, @provenance["base_repository_url"])
|
|
304
|
+
raise Ace::Git::ProviderExpectedHeadConflictError, "PR candidate or provenance does not match delivery"
|
|
305
|
+
end
|
|
306
|
+
end
|
|
307
|
+
|
|
308
|
+
def verify_pushed_head!
|
|
309
|
+
output = git!("ls-remote", "--heads", @provenance["head_repository_url"], "refs/heads/#{@provenance['head_ref']}")
|
|
310
|
+
expected = "#{@head}\trefs/heads/#{@provenance['head_ref']}"
|
|
311
|
+
raise AttemptErrors::ReceiptRejected, "Pushed source does not match candidate" unless output.strip == expected
|
|
312
|
+
end
|
|
313
|
+
|
|
314
|
+
def record_result(operation, pr, outcome, extra = {})
|
|
315
|
+
record({"stage" => "result", "operation" => operation, "head" => @head, "outcome" => outcome,
|
|
316
|
+
"intent_digest" => @intent_context&.fetch("intent_digest", nil),
|
|
317
|
+
"intent_attempt_id" => @intent_context&.fetch("intent_attempt_id", nil),
|
|
318
|
+
"pr" => {"number" => pr.number, "url" => pr.url, "head_sha" => pr.head_sha,
|
|
319
|
+
"draft" => pr.draft, "state" => pr.state.to_s, "merge_commit_sha" => pr.merge_commit_sha}}.merge(extra))
|
|
320
|
+
end
|
|
321
|
+
|
|
322
|
+
def status
|
|
323
|
+
attempt = @coordinator.authoritative_attempt(@binding)
|
|
324
|
+
{"attempt_id" => attempt.attempt_id, "assignment_id" => @binding["assignment_id"],
|
|
325
|
+
"task_id" => attempt.binding.task_id, "project_id" => attempt.binding.project_id,
|
|
326
|
+
"actor" => attempt.binding.actor, "role" => attempt.binding.role, "runtime" => attempt.binding.runtime,
|
|
327
|
+
"base_head" => attempt.binding.base_head, "candidate_head" => attempt.candidate_head,
|
|
328
|
+
"evidence_git_ref" => @attempt.binding.evidence_git_ref, "journal_commit" => @journal.ref_value,
|
|
329
|
+
"delivery" => events.map do |event|
|
|
330
|
+
event.fetch("payload").merge("attempt_id" => event.fetch("attempt_id"), "event_digest" => event.fetch("digest"))
|
|
331
|
+
end}
|
|
332
|
+
end
|
|
333
|
+
|
|
334
|
+
def git!(*argv)
|
|
335
|
+
result = Ace::Git::Atoms::CommandExecutor.execute("git", "-C", @repo_root, *argv)
|
|
336
|
+
raise AttemptErrors::EvidenceUnavailable, "Git delivery proof unavailable" unless result[:success]
|
|
337
|
+
result[:output]
|
|
338
|
+
end
|
|
339
|
+
end
|
|
340
|
+
end
|
|
341
|
+
end
|
|
342
|
+
end
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "ace/bundle"
|
|
4
|
+
require_relative "task_assignment_creator"
|
|
5
|
+
require_relative "../authority/prepared_work"
|
|
6
|
+
|
|
7
|
+
module Ace
|
|
8
|
+
module Assign
|
|
9
|
+
module Organisms
|
|
10
|
+
# Captures managed preparation once. This owner neither registers nor
|
|
11
|
+
# launches work; callers retain its exact output across uncertain replies.
|
|
12
|
+
class PreparedWorkBuilder
|
|
13
|
+
def initialize(export_root:, task_manager: nil, executor: nil, bundle_loader: nil)
|
|
14
|
+
@tasks = task_manager || Ace::Task::Organisms::TaskManager.new
|
|
15
|
+
@executor = executor || AssignmentExecutor.new
|
|
16
|
+
@bundles = bundle_loader || Ace::Bundle::Organisms::BundleLoader.new
|
|
17
|
+
@transfer = Authority::CandidateTransfer.new(root: export_root)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def call(task_ref:, project_id:, dependency_reports:)
|
|
21
|
+
token!(project_id)
|
|
22
|
+
@reads = {}
|
|
23
|
+
@inventories = {}
|
|
24
|
+
tasks = closure(task_ref)
|
|
25
|
+
selected = tasks.fetch(task_ref)
|
|
26
|
+
refuse!("selected task is not a reviewed leaf") unless
|
|
27
|
+
%w[pending in-progress].include?(selected.status.to_s) && Array(selected.subtasks).empty?
|
|
28
|
+
contexts = tasks.sort.to_h do |id, task|
|
|
29
|
+
refuse!("task still needs review") unless task.metadata.fetch("needs_review", false) == false
|
|
30
|
+
[id, capture_context(task)]
|
|
31
|
+
end
|
|
32
|
+
reports = capture_reports(dependency_reports, tasks, task_ref, project_id)
|
|
33
|
+
result = TaskAssignmentCreator.new(task_manager: @tasks, executor: @executor)
|
|
34
|
+
.call(task_refs: [task_ref], primary_task_ref: task_ref, project_id: project_id)
|
|
35
|
+
assignment = result.fetch(:assignment)
|
|
36
|
+
refuse!("managed association changed") unless assignment.task_id == task_ref && assignment.project_id == project_id
|
|
37
|
+
job = read(assignment.source_config)
|
|
38
|
+
read(assignment.assignment_file)
|
|
39
|
+
inventory = inventory(assignment.steps_dir)
|
|
40
|
+
step_files = inventory.to_h { |name| [name, read(File.join(assignment.steps_dir, name))] }
|
|
41
|
+
roots = step_files.filter_map do |name, bytes|
|
|
42
|
+
fm = Atoms::StepFileParser.parse(bytes).fetch(:frontmatter)
|
|
43
|
+
Atoms::StepFileParser.parse_filename(name).fetch(:number) if fm["context"] == "fork" && fm["taskref"] == task_ref
|
|
44
|
+
end
|
|
45
|
+
refuse!("selected fork root is not unique") unless roots.one? && roots.first
|
|
46
|
+
scope = roots.first
|
|
47
|
+
files = {"definition.json" => JSON.generate(assignment.to_h), "job.yaml" => job}
|
|
48
|
+
selected_steps = step_files.filter_map do |name, bytes|
|
|
49
|
+
number = Atoms::StepFileParser.parse_filename(name).fetch(:number)
|
|
50
|
+
refuse!("unexpected queue entry") unless number && name.end_with?(".st.md")
|
|
51
|
+
next unless number == scope || number.start_with?(scope + ".")
|
|
52
|
+
files["steps/#{name}"] = bytes
|
|
53
|
+
record(name, bytes).merge("number" => number)
|
|
54
|
+
end.sort_by { |entry| number_key(entry.fetch("number")) }
|
|
55
|
+
context = contexts.map do |id, captured|
|
|
56
|
+
spec_path = "context/#{id}/spec.md"
|
|
57
|
+
text_path = "context/#{id}/bundle.txt"
|
|
58
|
+
files[spec_path], files[text_path] = captured.values_at(:spec, :text)
|
|
59
|
+
selected_reports = reports.fetch(id, []).map do |report|
|
|
60
|
+
path = "context/#{id}/reports/#{report.fetch(:assignment_id)}/#{report.fetch(:number)}.r.md"
|
|
61
|
+
files[path] = report.fetch(:bytes)
|
|
62
|
+
{"assignment_id" => report.fetch(:assignment_id), "number" => report.fetch(:number),
|
|
63
|
+
"file" => record(path, report.fetch(:bytes))}
|
|
64
|
+
end
|
|
65
|
+
{"uri" => "task://#{id}", "task_id" => id, "spec" => record(spec_path, files.fetch(spec_path)),
|
|
66
|
+
"text" => record(text_path, files.fetch(text_path)), "reports" => selected_reports}
|
|
67
|
+
end
|
|
68
|
+
manifest = {"version" => 1, "assignment_id" => assignment.id, "project_id" => project_id,
|
|
69
|
+
"task_id" => task_ref, "scope" => scope, "job" => record("job.yaml", job),
|
|
70
|
+
"steps" => selected_steps, "context" => context}
|
|
71
|
+
files["manifest.json"] = Authority::PreparedWork.canonical_manifest(manifest) + "\n"
|
|
72
|
+
work = Authority::PreparedWork.new(files: files)
|
|
73
|
+
current = closure(task_ref)
|
|
74
|
+
refuse!("task dependency inventory changed") unless current.keys.sort == tasks.keys.sort
|
|
75
|
+
refuse!("selected task expanded during capture") unless Array(current.fetch(task_ref).subtasks).empty?
|
|
76
|
+
current.each { |id, task| refuse!("task context changed") unless capture_context(task) == contexts.fetch(id) }
|
|
77
|
+
@inventories.each { |path, names| refuse!("input inventory changed") unless Dir.children(path).sort == names }
|
|
78
|
+
@reads.each { |path, bytes| refuse!("captured input changed") unless read_bytes(path) == bytes }
|
|
79
|
+
@transfer.export_prepared(work: work).merge("assignment_id" => assignment.id,
|
|
80
|
+
"scope" => scope.freeze, "selection_sha256" => work.selection_sha256).freeze
|
|
81
|
+
ensure
|
|
82
|
+
@reads = nil
|
|
83
|
+
@inventories = nil
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
private
|
|
87
|
+
|
|
88
|
+
def closure(ref)
|
|
89
|
+
found = {}; visiting = []
|
|
90
|
+
walk = lambda do |id|
|
|
91
|
+
token!(id)
|
|
92
|
+
refuse!("dependency cycle: #{(visiting + [id]).join(' -> ')}") if visiting.include?(id)
|
|
93
|
+
return if found.key?(id)
|
|
94
|
+
refuse!("too many task contexts") if found.size >= 256
|
|
95
|
+
task = @tasks.show(id)
|
|
96
|
+
refuse!("missing or noncanonical task") unless task && task.id == id
|
|
97
|
+
found[id] = task
|
|
98
|
+
visiting << id
|
|
99
|
+
Array(task.dependencies).each { |dependency| walk.call(dependency) }
|
|
100
|
+
visiting.pop
|
|
101
|
+
end
|
|
102
|
+
walk.call(ref)
|
|
103
|
+
found
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def capture_context(task)
|
|
107
|
+
spec = read(task.file_path)
|
|
108
|
+
bundle = @bundles.load_file(task.file_path)
|
|
109
|
+
errors = bundle.metadata.values_at(:error, :errors, "error", "errors").flatten.compact
|
|
110
|
+
refuse!("unresolvable task context") unless errors.empty?
|
|
111
|
+
text = bounded_text(bundle.content)
|
|
112
|
+
# Deep-copy owner inventories; a loader may reuse mutable objects.
|
|
113
|
+
{spec: spec, text: text, path: task.file_path,
|
|
114
|
+
inventory: Marshal.dump([bundle.files, bundle.source_files])}
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def capture_reports(selections, tasks, selected, project)
|
|
118
|
+
refuse!("report selections must be explicit") unless selections.is_a?(Array) && selections.size <= 4096
|
|
119
|
+
seen = {}; grouped = Hash.new { |hash, key| hash[key] = [] }
|
|
120
|
+
selections.each do |selection|
|
|
121
|
+
refuse!("report selector") unless selection.is_a?(Hash) && selection.keys.sort == %w[assignment_id number task_id]
|
|
122
|
+
id, assignment_id, number = selection.values_at("task_id", "assignment_id", "number")
|
|
123
|
+
token!(id); token!(assignment_id)
|
|
124
|
+
refuse!("report scope") unless number.is_a?(String) && number.match?(Authority::PreparedWork::SCOPE)
|
|
125
|
+
refuse!("report is not a dependency") unless id != selected && tasks.key?(id)
|
|
126
|
+
identity = [id, assignment_id, number]
|
|
127
|
+
refuse!("duplicate report") if seen[identity]
|
|
128
|
+
seen[identity] = true
|
|
129
|
+
assignment = @executor.assignment_manager.load(assignment_id)
|
|
130
|
+
refuse!("report assignment association") unless assignment && assignment.task_id == id && assignment.project_id == project
|
|
131
|
+
metadata = read(assignment.assignment_file)
|
|
132
|
+
parsed_metadata = YAML.safe_load(metadata, permitted_classes: [Time, Date])
|
|
133
|
+
refuse!("report assignment changed") unless parsed_metadata["session_id"] == assignment_id &&
|
|
134
|
+
parsed_metadata["task_id"] == id && parsed_metadata["project_id"] == project
|
|
135
|
+
inventory(assignment.reports_dir)
|
|
136
|
+
matches = inventory(assignment.steps_dir).select do |name|
|
|
137
|
+
name.end_with?(".st.md") && Atoms::StepFileParser.parse_filename(name)[:number] == number
|
|
138
|
+
end
|
|
139
|
+
refuse!("report step association") unless matches.one?
|
|
140
|
+
step = matches.first
|
|
141
|
+
read(File.join(assignment.steps_dir, step))
|
|
142
|
+
parsed = Atoms::StepFileParser.parse_filename(step)
|
|
143
|
+
filename = Atoms::StepFileParser.generate_report_filename(number, parsed.fetch(:name))
|
|
144
|
+
grouped[id] << {assignment_id: assignment_id, number: number,
|
|
145
|
+
bytes: read(File.join(assignment.reports_dir, filename))}
|
|
146
|
+
end
|
|
147
|
+
grouped.each_value { |entries| entries.sort_by! { |entry| [entry.fetch(:assignment_id), number_key(entry.fetch(:number))] } }
|
|
148
|
+
grouped
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def record(path, bytes)
|
|
152
|
+
{"filename" => path, "bytes" => bytes.bytesize, "sha256" => Digest::SHA256.hexdigest(bytes)}
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def read(path)
|
|
156
|
+
bytes = read_bytes(path)
|
|
157
|
+
refuse!("captured input changed") if @reads.key?(path) && @reads.fetch(path) != bytes
|
|
158
|
+
@reads[path] = bytes
|
|
159
|
+
refuse!("capture inventory bound") if @reads.size > 4096 || @reads.values.sum(&:bytesize) > Authority::PreparedWork::MAX_TOTAL
|
|
160
|
+
bytes
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def inventory(path)
|
|
164
|
+
names = Dir.children(path).sort.freeze
|
|
165
|
+
refuse!("capture inventory bound") if names.size > 4096
|
|
166
|
+
refuse!("input inventory changed") if @inventories.key?(path) && @inventories.fetch(path) != names
|
|
167
|
+
@inventories[path] = names
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def read_bytes(path)
|
|
171
|
+
File.open(path, File::RDONLY | File::NOFOLLOW | File::NONBLOCK) do |file|
|
|
172
|
+
refuse!("input is not a bounded regular file") unless file.stat.file? && file.stat.size <= Authority::PreparedWork::MAX_TEXT
|
|
173
|
+
bounded_text(file.read(Authority::PreparedWork::MAX_TEXT + 1))
|
|
174
|
+
end
|
|
175
|
+
rescue SystemCallError
|
|
176
|
+
refuse!("input unavailable")
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def bounded_text(bytes)
|
|
180
|
+
refuse!("input text bound") unless bytes.is_a?(String) && bytes.bytesize.between?(1, Authority::PreparedWork::MAX_TEXT)
|
|
181
|
+
text = bytes.dup.force_encoding(Encoding::UTF_8)
|
|
182
|
+
refuse!("input encoding") unless text.valid_encoding? && !text.include?("\0")
|
|
183
|
+
text.freeze
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def number_key(number) = number.split(".").map(&:to_i)
|
|
187
|
+
def token!(value)
|
|
188
|
+
refuse!("invalid identity") unless value.is_a?(String) && value.match?(Authority::PreparedWork::TOKEN)
|
|
189
|
+
end
|
|
190
|
+
def refuse!(message) = raise(AttemptErrors::ReceiptRejected, "prepared_input_invalid: #{message}")
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
end
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
require_relative "../authority/service_delivery_evidence"
|
|
3
|
+
require "ace/git/atoms/service_pr_evidence"
|
|
4
|
+
|
|
5
|
+
module Ace
|
|
6
|
+
module Assign
|
|
7
|
+
module Organisms
|
|
8
|
+
# Worker consumes only the original authority's canonical completion.
|
|
9
|
+
# No local journal, provider mutation, recovery write or merge retry.
|
|
10
|
+
class ProtectedDeliveryCoordinator
|
|
11
|
+
BASE_FIELDS = %w[request_id assignment_id attempt_id project_id operation service_id target candidate_head
|
|
12
|
+
candidate_generation state dispatch_ticket_id claim_binding claim_generation dispatch_phase policy_digest generation journal_commit].freeze
|
|
13
|
+
COMPLETE_FIELDS = %w[input_digest authorization executor_uid completion_digest receipt delivery_event].freeze
|
|
14
|
+
|
|
15
|
+
def initialize(client:, project_id:)
|
|
16
|
+
@client, @project_id = client, project_id
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def perform(assignment_id:, attempt_id:, operation:, service_request_id:, candidate_head:, candidate_generation:,
|
|
20
|
+
input_digest:, target:, parameters: nil, title: nil, body: nil, tests: nil, review: nil)
|
|
21
|
+
unless %w[create update ready merge status].include?(operation) && [title, body, tests, review].all?(&:nil?) &&
|
|
22
|
+
[assignment_id, attempt_id, service_request_id].all? { |value| value.is_a?(String) && value.match?(Molecules::JournalMutation::ID) } &&
|
|
23
|
+
candidate_head.is_a?(String) && candidate_head.match?(/\A[0-9a-f]{40}\z/) &&
|
|
24
|
+
candidate_generation.is_a?(Integer) && candidate_generation.positive? &&
|
|
25
|
+
input_digest.is_a?(String) && input_digest.match?(/\A[0-9a-f]{64}\z/) &&
|
|
26
|
+
target.is_a?(Hash) && target.keys.sort == %w[artifact_digest resource] && target["resource"].is_a?(String) &&
|
|
27
|
+
(target["artifact_digest"].nil? || target["artifact_digest"].is_a?(String) && target["artifact_digest"].match?(/\A[0-9a-f]{64}\z/))
|
|
28
|
+
raise ArgumentError, "protected delivery requires exact canonical merge selectors; local delivery flags are unavailable"
|
|
29
|
+
end
|
|
30
|
+
binding = {"assignment_id" => assignment_id, "attempt_id" => attempt_id,
|
|
31
|
+
"candidate_generation" => candidate_generation, "head" => candidate_head, "request_id" => service_request_id}
|
|
32
|
+
data = @client.call("service_status", binding).data
|
|
33
|
+
unless data.is_a?(Hash) && (data.keys - BASE_FIELDS - COMPLETE_FIELDS).empty? &&
|
|
34
|
+
Atoms::EvidenceDigest.digest(data.slice("assignment_id", "attempt_id", "request_id", "candidate_generation")) == Atoms::EvidenceDigest.digest(binding.except("head")) &&
|
|
35
|
+
data["project_id"] == @project_id && data["candidate_head"] == candidate_head && Atoms::EvidenceDigest.digest(data["target"]) == Atoms::EvidenceDigest.digest(target) &&
|
|
36
|
+
%w[create update ready merge].include?(data["operation"]) && (operation == "status" || data["operation"] == operation) && data["generation"].is_a?(Integer) && data["generation"].positive? &&
|
|
37
|
+
data["input_digest"] == input_digest &&
|
|
38
|
+
data["journal_commit"].is_a?(String) && data["journal_commit"].match?(/\A(?:[0-9a-f]{40}|[0-9a-f]{64})\z/)
|
|
39
|
+
raise AttemptErrors::ReceiptRejected, "original canonical merge status differs"
|
|
40
|
+
end
|
|
41
|
+
if data["state"] != "succeeded"
|
|
42
|
+
return data if operation == "status"
|
|
43
|
+
raise AttemptErrors::EvidenceUnavailable, "original merge is not canonically succeeded; inspect its status without retry"
|
|
44
|
+
end
|
|
45
|
+
unless data.keys.sort == (BASE_FIELDS + COMPLETE_FIELDS).sort && data["input_digest"] == input_digest &&
|
|
46
|
+
data["executor_uid"].is_a?(Integer) && data["executor_uid"].positive? &&
|
|
47
|
+
data["completion_digest"].is_a?(String) && data["completion_digest"].match?(/\A[0-9a-f]{64}\z/)
|
|
48
|
+
raise AttemptErrors::ReceiptRejected, "original canonical completion selectors differ"
|
|
49
|
+
end
|
|
50
|
+
receipt = data.fetch("receipt")
|
|
51
|
+
unless receipt.is_a?(Hash) && receipt.keys.sort == Molecules::EvidenceJournal::TERMINAL_RECEIPT_FIELDS.sort &&
|
|
52
|
+
receipt["outcome"] == "succeeded" && receipt["operation"] == data["operation"] && receipt["transport"] == "unix" &&
|
|
53
|
+
receipt.slice("request_id", "assignment_id", "attempt_id", "project_id", "input_digest", "target", "candidate_head", "executor_uid") ==
|
|
54
|
+
data.slice("request_id", "assignment_id", "attempt_id", "project_id", "input_digest", "target", "candidate_head", "executor_uid") &&
|
|
55
|
+
receipt["evidence"].is_a?(Array) && receipt["evidence"].one?
|
|
56
|
+
raise AttemptErrors::ReceiptRejected, "original canonical merge receipt differs"
|
|
57
|
+
end
|
|
58
|
+
reference = receipt.fetch("evidence").first
|
|
59
|
+
unless reference.is_a?(Hash) && reference.keys.sort == %w[ref sha256] &&
|
|
60
|
+
reference["ref"].is_a?(String) && reference["ref"].match?(%r{\Aevidence/imports/[a-z0-9-]+\z}) &&
|
|
61
|
+
reference["sha256"].is_a?(String) && reference["sha256"].match?(/\A[0-9a-f]{64}\z/)
|
|
62
|
+
raise AttemptErrors::ReceiptRejected, "original canonical artifact reference differs"
|
|
63
|
+
end
|
|
64
|
+
fetched = @client.call("evidence_fetch", {"assignment_id" => assignment_id, "attempt_id" => attempt_id,
|
|
65
|
+
"kind" => "service", "purpose_id" => service_request_id,
|
|
66
|
+
"artifact_id" => reference.fetch("ref").delete_prefix("evidence/imports/")}, download: true, purpose: :artifacts)
|
|
67
|
+
descriptor = fetched.data.fetch("descriptor")
|
|
68
|
+
original_binding = Authority::ServiceEvidence::FIELDS.to_h { |key| [key, receipt.key?(key) ? receipt.fetch(key) : data.fetch(key)] }
|
|
69
|
+
unless Molecules::CanonicalEvidence.valid_descriptor?(descriptor) &&
|
|
70
|
+
descriptor["binding_digest"] == Atoms::EvidenceDigest.digest(original_binding) &&
|
|
71
|
+
descriptor["sha256"] == reference.fetch("sha256") && descriptor["peer_uid"] == data.fetch("executor_uid") &&
|
|
72
|
+
fetched.parts.is_a?(Array) && fetched.parts.one? && fetched.parts.first.is_a?(String) &&
|
|
73
|
+
Digest::SHA256.hexdigest(fetched.parts.first) == reference.fetch("sha256")
|
|
74
|
+
raise AttemptErrors::ReceiptRejected, "original canonical artifact descriptor differs"
|
|
75
|
+
end
|
|
76
|
+
proof_options = {request_id: service_request_id, input_digest: input_digest, target: target, head: candidate_head}
|
|
77
|
+
proof = if data.fetch("operation") == "merge"
|
|
78
|
+
Ace::Git::Atoms::ServiceMergeEvidence.validate(fetched.parts.first, **proof_options)
|
|
79
|
+
else
|
|
80
|
+
Ace::Git::Atoms::ServicePrEvidence.validate(fetched.parts.first, operation: data.fetch("operation"), **proof_options)
|
|
81
|
+
end
|
|
82
|
+
unless Atoms::EvidenceDigest.digest(proof.fetch(:input)) == input_digest &&
|
|
83
|
+
(!parameters || Atoms::EvidenceDigest.digest(Atoms::DeliveryParameters.validate(parameters)) == Atoms::EvidenceDigest.digest(proof.fetch(:input).fetch("delivery")))
|
|
84
|
+
raise AttemptErrors::ReceiptRejected, "canonical merge artifact accepted input differs"
|
|
85
|
+
end
|
|
86
|
+
event = data.fetch("delivery_event")
|
|
87
|
+
expected = Authority::ServiceDeliveryEvidence.payload(data, receipt, data.fetch("completion_digest"), proof)
|
|
88
|
+
unless event.is_a?(Hash) && event.keys.sort == %w[attempt_id digest payload previous_digest recorded_at type] &&
|
|
89
|
+
event["type"] == "delivery" && event["attempt_id"] == attempt_id && Models::EvidenceEvent.valid?(event) &&
|
|
90
|
+
Atoms::EvidenceDigest.digest(event.fetch("payload")) == Atoms::EvidenceDigest.digest(expected)
|
|
91
|
+
raise AttemptErrors::ReceiptRejected, "canonical delivery result/completion/import join differs"
|
|
92
|
+
end
|
|
93
|
+
data
|
|
94
|
+
rescue KeyError, TypeError, ArgumentError
|
|
95
|
+
raise AttemptErrors::ReceiptRejected, "protected merge evidence is malformed or unavailable"
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|