ace-assign 0.71.0 → 0.73.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.ace-defaults/assign/catalog/steps/auto-merge.step.yml +8 -8
- data/.ace-defaults/assign/catalog/steps/create-pr.step.yml +1 -1
- data/.ace-defaults/assign/catalog/steps/mark-pr-ready.step.yml +1 -1
- data/.ace-defaults/assign/config.yml +0 -13
- data/.ace-defaults/assign/presets/fix-bug.yml +1 -1
- data/.ace-defaults/assign/presets/work-on-task-ace-development.yml +1 -1
- data/.ace-defaults/assign/presets/work-on-task-auto-merge.yml +1 -1
- data/.ace-defaults/assign/presets/work-on-task.yml +1 -1
- data/CHANGELOG.md +3 -2119
- data/README.md +58 -31
- data/docs/exit-codes.md +36 -44
- data/docs/getting-started.md +9 -4
- data/docs/usage.md +129 -522
- data/handbook/skills/as-assign-drive/SKILL.md +1 -4
- data/handbook/workflow-instructions/assign/drive.wf.md +47 -195
- data/handbook/workflow-instructions/assign/prepare.wf.md +1 -1
- data/handbook/workflow-instructions/assign/verify-test-suite.wf.md +8 -2
- data/lib/ace/assign/cli/commands/assignment_target.rb +2 -15
- data/lib/ace/assign/cli/commands/create.rb +1 -8
- data/lib/ace/assign/cli/commands/fail.rb +0 -6
- data/lib/ace/assign/cli/commands/finish.rb +2 -23
- data/lib/ace/assign/cli/commands/fork_session.rb +0 -1
- data/lib/ace/assign/cli/commands/select.rb +0 -1
- data/lib/ace/assign/cli/commands/start.rb +0 -2
- data/lib/ace/assign/cli/commands/status.rb +1 -31
- data/lib/ace/assign/cli/commands/step.rb +1 -3
- data/lib/ace/assign/cli.rb +1 -85
- data/lib/ace/assign/errors.rb +8 -80
- data/lib/ace/assign/models/assignment.rb +5 -28
- data/lib/ace/assign/molecules/assignment_manager.rb +0 -26
- data/lib/ace/assign/molecules/fork_session_launcher.rb +4 -12
- data/lib/ace/assign/molecules/lifecycle_exclusion.rb +15 -112
- data/lib/ace/assign/organisms/assignment_executor.rb +0 -6
- data/lib/ace/assign/organisms/task_assignment_creator.rb +3 -8
- data/lib/ace/assign/version.rb +1 -1
- data/lib/ace/assign.rb +0 -6
- metadata +4 -176
- data/.ace-defaults/assign/presets/close-delivery.yml +0 -57
- data/.ace-defaults/assign/presets/deliver-release.yml +0 -60
- data/.ace-defaults/assign/presets/integrate-batch.yml +0 -72
- data/.ace-defaults/assign/presets/supervise-delivery.yml +0 -69
- data/docs/protected-authority.md +0 -282
- data/exe/ace-scope-ready +0 -55
- data/lib/ace/assign/atoms/assignment_scope.rb +0 -58
- data/lib/ace/assign/atoms/attempt_state_machine.rb +0 -84
- data/lib/ace/assign/atoms/candidate_limits.rb +0 -20
- data/lib/ace/assign/atoms/delivery_parameters.rb +0 -19
- data/lib/ace/assign/atoms/evidence_digest.rb +0 -78
- data/lib/ace/assign/atoms/fixed_merge_schemas.rb +0 -398
- data/lib/ace/assign/atoms/git_blob_batch.rb +0 -59
- data/lib/ace/assign/authority/assignment_definition.rb +0 -35
- data/lib/ace/assign/authority/assignment_inventory.rb +0 -290
- data/lib/ace/assign/authority/campaign_consumer_policy.rb +0 -100
- data/lib/ace/assign/authority/campaign_execution.rb +0 -119
- data/lib/ace/assign/authority/campaign_round_transfer.rb +0 -82
- data/lib/ace/assign/authority/candidate_blob_batch.rb +0 -55
- data/lib/ace/assign/authority/candidate_head_comparison.rb +0 -139
- data/lib/ace/assign/authority/candidate_transfer.rb +0 -357
- data/lib/ace/assign/authority/cleanup_result_protection.rb +0 -53
- data/lib/ace/assign/authority/client.rb +0 -257
- data/lib/ace/assign/authority/command_execution.rb +0 -180
- data/lib/ace/assign/authority/deployment.rb +0 -627
- data/lib/ace/assign/authority/deployment_history.rb +0 -146
- data/lib/ace/assign/authority/endcap.rb +0 -457
- data/lib/ace/assign/authority/endcap_attempts.rb +0 -274
- data/lib/ace/assign/authority/endcap_campaign_results.rb +0 -232
- data/lib/ace/assign/authority/endcap_campaign_rounds.rb +0 -266
- data/lib/ace/assign/authority/endcap_contexts.rb +0 -23
- data/lib/ace/assign/authority/endcap_inboxes.rb +0 -37
- data/lib/ace/assign/authority/endcap_prepared_work.rb +0 -80
- data/lib/ace/assign/authority/endcap_publication.rb +0 -41
- data/lib/ace/assign/authority/endcap_results.rb +0 -398
- data/lib/ace/assign/authority/endcap_review_requests.rb +0 -183
- data/lib/ace/assign/authority/endcap_reviews.rb +0 -130
- data/lib/ace/assign/authority/endcap_service_context.rb +0 -38
- data/lib/ace/assign/authority/endcap_service_settlement.rb +0 -152
- data/lib/ace/assign/authority/evidence_transfer.rb +0 -67
- data/lib/ace/assign/authority/execution_scope_observation.rb +0 -903
- data/lib/ace/assign/authority/fixed_publication_lifecycle.rb +0 -176
- data/lib/ace/assign/authority/fixed_service_lifecycle.rb +0 -594
- data/lib/ace/assign/authority/inbox_context_original.rb +0 -113
- data/lib/ace/assign/authority/launch_attempt_consumers.rb +0 -51
- data/lib/ace/assign/authority/launch_campaign_registration.rb +0 -214
- data/lib/ace/assign/authority/launch_control_channel.rb +0 -313
- data/lib/ace/assign/authority/launch_control_exclusions.rb +0 -127
- data/lib/ace/assign/authority/launch_driver.rb +0 -393
- data/lib/ace/assign/authority/launch_input_inhibition.rb +0 -132
- data/lib/ace/assign/authority/launch_lifecycle.rb +0 -997
- data/lib/ace/assign/authority/launch_prepared_work.rb +0 -122
- data/lib/ace/assign/authority/launch_review.rb +0 -91
- data/lib/ace/assign/authority/launch_scope_abort.rb +0 -81
- data/lib/ace/assign/authority/launch_scope_admission.rb +0 -215
- data/lib/ace/assign/authority/launch_scope_close.rb +0 -146
- data/lib/ace/assign/authority/launch_scope_parent.rb +0 -44
- data/lib/ace/assign/authority/launch_scope_release.rb +0 -859
- data/lib/ace/assign/authority/launch_steering.rb +0 -353
- data/lib/ace/assign/authority/launch_stop.rb +0 -254
- data/lib/ace/assign/authority/maintenance_workspace_retirement.rb +0 -229
- data/lib/ace/assign/authority/ordinary_attempt_start.rb +0 -137
- data/lib/ace/assign/authority/ordinary_client.rb +0 -70
- data/lib/ace/assign/authority/ordinary_completion.rb +0 -104
- data/lib/ace/assign/authority/ordinary_effect_settlement.rb +0 -118
- data/lib/ace/assign/authority/ordinary_lifecycle.rb +0 -227
- data/lib/ace/assign/authority/ordinary_merge_evidence.rb +0 -81
- data/lib/ace/assign/authority/ordinary_peer.rb +0 -54
- data/lib/ace/assign/authority/ordinary_progress.rb +0 -162
- data/lib/ace/assign/authority/ordinary_queue.rb +0 -115
- data/lib/ace/assign/authority/ordinary_release_artifacts.rb +0 -143
- data/lib/ace/assign/authority/ordinary_results.rb +0 -206
- data/lib/ace/assign/authority/ordinary_review_evidence.rb +0 -180
- data/lib/ace/assign/authority/ordinary_reviews.rb +0 -214
- data/lib/ace/assign/authority/ordinary_server.rb +0 -75
- data/lib/ace/assign/authority/ordinary_supervisor.rb +0 -99
- data/lib/ace/assign/authority/original_inbox_selection.rb +0 -41
- data/lib/ace/assign/authority/posix_acl.rb +0 -59
- data/lib/ace/assign/authority/prepared_input.rb +0 -130
- data/lib/ace/assign/authority/prepared_queue.rb +0 -185
- data/lib/ace/assign/authority/prepared_task_context.rb +0 -116
- data/lib/ace/assign/authority/prepared_work.rb +0 -309
- data/lib/ace/assign/authority/prepared_worker.rb +0 -69
- data/lib/ace/assign/authority/private_directory.rb +0 -33
- data/lib/ace/assign/authority/protected_assignment_context.rb +0 -226
- data/lib/ace/assign/authority/readiness_hook.rb +0 -120
- data/lib/ace/assign/authority/receipt_transfer.rb +0 -48
- data/lib/ace/assign/authority/release_artifact_manifest.rb +0 -91
- data/lib/ace/assign/authority/retained_input.rb +0 -53
- data/lib/ace/assign/authority/router.rb +0 -81
- data/lib/ace/assign/authority/server.rb +0 -131
- data/lib/ace/assign/authority/service_cleanup_dispatch.rb +0 -88
- data/lib/ace/assign/authority/service_cleanup_evidence.rb +0 -180
- data/lib/ace/assign/authority/service_delivery_evidence.rb +0 -101
- data/lib/ace/assign/authority/service_evidence.rb +0 -286
- data/lib/ace/assign/authority/service_publication_evidence.rb +0 -110
- data/lib/ace/assign/authority/socket_server.rb +0 -255
- data/lib/ace/assign/authority/task_context_entry.rb +0 -32
- data/lib/ace/assign/authority/transfer_codec.rb +0 -189
- data/lib/ace/assign/cli/commands/attempt/base.rb +0 -84
- data/lib/ace/assign/cli/commands/attempt/evidence.rb +0 -28
- data/lib/ace/assign/cli/commands/attempt/finish.rb +0 -51
- data/lib/ace/assign/cli/commands/attempt/reconcile.rb +0 -49
- data/lib/ace/assign/cli/commands/attempt/start.rb +0 -45
- data/lib/ace/assign/cli/commands/attempt/status.rb +0 -54
- data/lib/ace/assign/cli/commands/authority/inbox_context_principal.rb +0 -25
- data/lib/ace/assign/cli/commands/authority/inbox_context_selection.rb +0 -28
- data/lib/ace/assign/cli/commands/authority/launch.rb +0 -97
- data/lib/ace/assign/cli/commands/authority/serve.rb +0 -31
- data/lib/ace/assign/cli/commands/authority/status.rb +0 -26
- data/lib/ace/assign/cli/commands/authority/task_context.rb +0 -31
- data/lib/ace/assign/cli/commands/authority/task_context_principal.rb +0 -25
- data/lib/ace/assign/cli/commands/authority/task_context_selection.rb +0 -30
- data/lib/ace/assign/cli/commands/authority/terminate.rb +0 -29
- data/lib/ace/assign/cli/commands/authority/worker.rb +0 -23
- data/lib/ace/assign/cli/commands/campaign_export_result.rb +0 -55
- data/lib/ace/assign/cli/commands/campaign_record_round.rb +0 -57
- data/lib/ace/assign/cli/commands/delivery.rb +0 -107
- data/lib/ace/assign/cli/commands/inbox_bind.rb +0 -34
- data/lib/ace/assign/cli/commands/protected_submission.rb +0 -61
- data/lib/ace/assign/cli/commands/resume.rb +0 -38
- data/lib/ace/assign/cli/commands/submit_candidate.rb +0 -34
- data/lib/ace/assign/cli/commands/submit_result.rb +0 -61
- data/lib/ace/assign/models/attempt.rb +0 -178
- data/lib/ace/assign/models/attempt_binding.rb +0 -116
- data/lib/ace/assign/models/evidence_event.rb +0 -86
- data/lib/ace/assign/models/execution_receipt.rb +0 -139
- data/lib/ace/assign/molecules/attempt_reconciler.rb +0 -127
- data/lib/ace/assign/molecules/attempt_store.rb +0 -201
- data/lib/ace/assign/molecules/canonical_attempt_state.rb +0 -151
- data/lib/ace/assign/molecules/canonical_evidence.rb +0 -170
- data/lib/ace/assign/molecules/canonical_read_commands.rb +0 -84
- data/lib/ace/assign/molecules/canonical_read_snapshot.rb +0 -313
- data/lib/ace/assign/molecules/evidence_calculator.rb +0 -237
- data/lib/ace/assign/molecules/evidence_journal.rb +0 -1899
- data/lib/ace/assign/molecules/execution_identity_resolver.rb +0 -137
- data/lib/ace/assign/molecules/execution_scope_lineage.rb +0 -345
- data/lib/ace/assign/molecules/fixed_effect_reference.rb +0 -31
- data/lib/ace/assign/molecules/installed_journal_read.rb +0 -143
- data/lib/ace/assign/molecules/journal_input_inhibition.rb +0 -78
- data/lib/ace/assign/molecules/journal_mutation.rb +0 -274
- data/lib/ace/assign/molecules/journal_prompt_mutation.rb +0 -241
- data/lib/ace/assign/molecules/ordinary_merge_cli.rb +0 -68
- data/lib/ace/assign/molecules/ordinary_merge_contract.rb +0 -410
- data/lib/ace/assign/molecules/original_launch_binding.rb +0 -37
- data/lib/ace/assign/molecules/proposal_journal.rb +0 -179
- data/lib/ace/assign/molecules/protected_control_exclusion.rb +0 -303
- data/lib/ace/assign/molecules/protected_workspace_exclusion.rb +0 -330
- data/lib/ace/assign/molecules/protected_workspace_provisioner.rb +0 -164
- data/lib/ace/assign/molecules/receipt_verifier.rb +0 -320
- data/lib/ace/assign/molecules/terminal_scope_receipt.rb +0 -88
- data/lib/ace/assign/organisms/attempt_coordinator.rb +0 -1374
- data/lib/ace/assign/organisms/delivery_coordinator.rb +0 -342
- data/lib/ace/assign/organisms/ordinary_driver.rb +0 -310
- data/lib/ace/assign/organisms/ordinary_merge_consumer.rb +0 -365
- data/lib/ace/assign/organisms/prepared_work_builder.rb +0 -194
- data/lib/ace/assign/organisms/protected_delivery_coordinator.rb +0 -100
|
@@ -1,342 +0,0 @@
|
|
|
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
|
|
@@ -1,310 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require "securerandom"
|
|
4
|
-
require "tmpdir"
|
|
5
|
-
require "fileutils"
|
|
6
|
-
require_relative "../authority/retained_input"
|
|
7
|
-
require_relative "../authority/prepared_work"
|
|
8
|
-
require_relative "../authority/transfer_codec"
|
|
9
|
-
require_relative "../molecules/journal_mutation"
|
|
10
|
-
require_relative "../models/execution_receipt"
|
|
11
|
-
require_relative "../models/assignment"
|
|
12
|
-
require_relative "../molecules/queue_scanner"
|
|
13
|
-
|
|
14
|
-
module Ace
|
|
15
|
-
module Assign
|
|
16
|
-
module Organisms
|
|
17
|
-
# Operator protocol over the installed owner supplied by the composition.
|
|
18
|
-
# Files retain requests only; every observation comes from that owner.
|
|
19
|
-
class OrdinaryDriver
|
|
20
|
-
Files = Authority::RetainedInput
|
|
21
|
-
CONTEXT_KEYS = %w[version project_id assignment_id attempt_id scope pane supervisor_pane cwd head provider].freeze
|
|
22
|
-
attr_reader :context, :path
|
|
23
|
-
|
|
24
|
-
def initialize(path:, project:, transport:)
|
|
25
|
-
@path = File.expand_path(path)
|
|
26
|
-
@context = Files.json(@path)
|
|
27
|
-
unless @context.is_a?(Hash) && @context.keys.sort == CONTEXT_KEYS.sort && @context["version"] == 1 &&
|
|
28
|
-
@context["project_id"] == project && %w[project_id assignment_id attempt_id].all? { |key|
|
|
29
|
-
@context[key].is_a?(String) && Molecules::JournalMutation::ID.match?(@context[key]) } &&
|
|
30
|
-
@context["scope"].is_a?(String) && Authority::PreparedWork::SCOPE.match?(@context["scope"]) &&
|
|
31
|
-
%w[pane supervisor_pane].all? { |key| @context[key].is_a?(String) && @context[key].match?(/\A[\w.:-]{1,128}\z/) } &&
|
|
32
|
-
@context["cwd"].is_a?(String) && @context["cwd"].start_with?("/") &&
|
|
33
|
-
File.expand_path(@context["cwd"]) == @context["cwd"] && @context["head"].is_a?(String) &&
|
|
34
|
-
@context["head"].match?(/\A[0-9a-f]{40}\z/) && @context["provider"].is_a?(String) &&
|
|
35
|
-
@context["provider"].bytesize.between?(1, 512)
|
|
36
|
-
raise ArgumentError, "ordinary driver selection differs; retain the original context"
|
|
37
|
-
end
|
|
38
|
-
@root = Authority::PrivateDirectory.verify!(File.dirname(@path))
|
|
39
|
-
@transport = transport
|
|
40
|
-
end
|
|
41
|
-
|
|
42
|
-
def status
|
|
43
|
-
reply = read("result_status", selection)
|
|
44
|
-
data = reply.fetch("data")
|
|
45
|
-
unless data.values_at("project_id", "assignment_id", "attempt_id") == context.values_at("project_id", "assignment_id", "attempt_id")
|
|
46
|
-
raise AttemptErrors::EvidenceUnavailable, "canonical status belongs to another execution"
|
|
47
|
-
end
|
|
48
|
-
data
|
|
49
|
-
end
|
|
50
|
-
|
|
51
|
-
def start(pane:)
|
|
52
|
-
worker!(pane)
|
|
53
|
-
mutate("start", "start_attempt", selection.merge("expected_generation" => 0,
|
|
54
|
-
"pane" => pane, "scope" => context.fetch("scope"), "head" => context.fetch("head")))
|
|
55
|
-
end
|
|
56
|
-
|
|
57
|
-
def supervisor
|
|
58
|
-
data = read("supervisor_status", context.slice("assignment_id")).fetch("data")
|
|
59
|
-
unless data.values_at("project_id", "assignment_id") == context.values_at("project_id", "assignment_id")
|
|
60
|
-
raise AttemptErrors::EvidenceUnavailable, "canonical supervisor belongs to another assignment"
|
|
61
|
-
end
|
|
62
|
-
data
|
|
63
|
-
end
|
|
64
|
-
|
|
65
|
-
def adopt_supervisor(pane:, generation:, previous_binding_sha256:)
|
|
66
|
-
mutate("supervisor-#{generation}", "adopt_supervisor", context.slice("assignment_id").merge(
|
|
67
|
-
"pane" => pane, "expected_generation" => generation, "previous_binding_sha256" => previous_binding_sha256))
|
|
68
|
-
end
|
|
69
|
-
|
|
70
|
-
def queue
|
|
71
|
-
reply, bytes = snapshot("assignment_snapshot", selection.merge("kind" => "queue"))
|
|
72
|
-
data = reply.fetch("data")
|
|
73
|
-
unless data.values_at("project_id", "assignment_id", "attempt_id", "scope") ==
|
|
74
|
-
context.values_at("project_id", "assignment_id", "attempt_id", "scope") &&
|
|
75
|
-
Digest::SHA256.hexdigest(bytes) == data.fetch("sha256")
|
|
76
|
-
raise AttemptErrors::EvidenceUnavailable, "canonical queue belongs to another execution"
|
|
77
|
-
end
|
|
78
|
-
value = JSON.parse(bytes)
|
|
79
|
-
assignment = Models::Assignment.new(id: context.fetch("assignment_id"), name: "ordinary",
|
|
80
|
-
created_at: Time.now.utc, source_config: "canonical", cache_dir: @root)
|
|
81
|
-
state = Molecules::QueueScanner.new.scan_captured(steps: value.fetch("steps"),
|
|
82
|
-
reports: value.fetch("reports"), assignment: assignment)
|
|
83
|
-
scope = context.fetch("scope")
|
|
84
|
-
current = state.current_in_subtree(scope)
|
|
85
|
-
next_step = state.next_workable_in_subtree(scope)
|
|
86
|
-
data.merge("complete" => state.subtree_complete?(scope), "failed" => state.subtree_failed?(scope),
|
|
87
|
-
"current" => step(current), "next" => step(next_step), "reports" => value.fetch("reports"))
|
|
88
|
-
end
|
|
89
|
-
|
|
90
|
-
def bootstrap(output:)
|
|
91
|
-
# Accepted task/context bytes are recovered from the canonical owner,
|
|
92
|
-
# not the worker checkout's possibly older mutable task files.
|
|
93
|
-
reply, bytes = snapshot("assignment_snapshot", selection.merge("attempt_id" => nil, "kind" => "prepared_work"))
|
|
94
|
-
data = reply.fetch("data")
|
|
95
|
-
unless data.values_at("project_id", "assignment_id") == context.values_at("project_id", "assignment_id")
|
|
96
|
-
raise AttemptErrors::EvidenceUnavailable, "canonical preparation belongs to another assignment"
|
|
97
|
-
end
|
|
98
|
-
reference = data.fetch("prepared_work")
|
|
99
|
-
work = Authority::PreparedWork.admit(bytes: bytes, sha256: data.fetch("sha256"), size: data.fetch("bytes"),
|
|
100
|
-
head: reference.fetch("prepared_head"), tree: reference.fetch("prepared_tree"), root: @root,
|
|
101
|
-
deadline: Process.clock_gettime(Process::CLOCK_MONOTONIC) + 30)
|
|
102
|
-
unless work.reference(head: reference.fetch("prepared_head"), tree: reference.fetch("prepared_tree")) == reference &&
|
|
103
|
-
work.manifest.values_at("project_id", "assignment_id", "scope") == context.values_at("project_id", "assignment_id", "scope")
|
|
104
|
-
raise AttemptErrors::EvidenceUnavailable, "canonical prepared scope differs"
|
|
105
|
-
end
|
|
106
|
-
directory = Files.create_output(output)
|
|
107
|
-
inventory = work.manifest.fetch("context").each_with_index.flat_map do |entry, index|
|
|
108
|
-
selected = %w[spec text].map { |kind| [kind, entry.fetch(kind)] } +
|
|
109
|
-
entry.fetch("reports").each_with_index.map { |report, number| ["report-#{number}", report.fetch("file")] }
|
|
110
|
-
selected.map do |kind, source|
|
|
111
|
-
content = work.files.fetch(source.fetch("filename"))
|
|
112
|
-
filename = "context-#{index}-#{kind}.md"
|
|
113
|
-
Files.write_file(directory, filename, content)
|
|
114
|
-
{"task_id" => entry.fetch("task_id"), "kind" => kind, "path" => File.join(directory, filename),
|
|
115
|
-
"sha256" => Digest::SHA256.hexdigest(content), "bytes" => content.bytesize}
|
|
116
|
-
end
|
|
117
|
-
end
|
|
118
|
-
{"project_id" => context.fetch("project_id"), **selection, "scope" => context.fetch("scope"),
|
|
119
|
-
"manifest_sha256" => work.manifest_sha256, "files" => inventory, "journal_commit" => data.fetch("journal_commit")}
|
|
120
|
-
end
|
|
121
|
-
|
|
122
|
-
def progress(operation:, pane:, generation:, step: nil, report: nil, message: nil)
|
|
123
|
-
worker!(pane)
|
|
124
|
-
params = selection.merge("expected_generation" => generation, "pane" => pane)
|
|
125
|
-
parts = []
|
|
126
|
-
case operation
|
|
127
|
-
when "queue_start" then params["step"] = step
|
|
128
|
-
when "queue_finish"
|
|
129
|
-
params["step"] = step
|
|
130
|
-
parts << Files.read_file(report, 65_536)
|
|
131
|
-
when "queue_fail" then params["message"] = message
|
|
132
|
-
else raise ArgumentError, "unknown ordinary queue operation"
|
|
133
|
-
end
|
|
134
|
-
mutate("progress-#{generation}", operation, params, parts)
|
|
135
|
-
end
|
|
136
|
-
|
|
137
|
-
def submit(pane:, generation:, receipt:, artifacts: [])
|
|
138
|
-
worker!(pane)
|
|
139
|
-
bytes = Files.read_file(receipt, 16_384)
|
|
140
|
-
value = JSON.parse(bytes)
|
|
141
|
-
unless value.values_at("project_id", "assignment_id", "attempt_id", "scope") ==
|
|
142
|
-
context.values_at("project_id", "assignment_id", "attempt_id", "scope") &&
|
|
143
|
-
value.fetch("artifacts").size == artifacts.size
|
|
144
|
-
raise ArgumentError, "result receipt differs from ordinary selection"
|
|
145
|
-
end
|
|
146
|
-
parts = artifacts.each_with_index.map do |file, index|
|
|
147
|
-
content = Files.read_file(file, Authority::TransferCodec::LIMITS.fetch(:receipt_artifacts).last)
|
|
148
|
-
unless Digest::SHA256.hexdigest(content) == value.fetch("artifacts").fetch(index).fetch("sha256")
|
|
149
|
-
raise ArgumentError, "result artifact differs from its receipt"
|
|
150
|
-
end
|
|
151
|
-
content
|
|
152
|
-
end
|
|
153
|
-
params = selection.merge("expected_generation" => generation,
|
|
154
|
-
"pane" => pane, "head" => value.fetch("head"), "receipt_sha256" => Digest::SHA256.hexdigest(bytes))
|
|
155
|
-
verify_result_intent!(params, [bytes] + parts)
|
|
156
|
-
observed = status
|
|
157
|
-
if (accepted = observed.fetch("submitted_result"))
|
|
158
|
-
# An accepted result may have lost its reply. Its generation selects
|
|
159
|
-
# the original input; never mint a replacement mutation to recover it.
|
|
160
|
-
original_generation = accepted.fetch("generation") - 1
|
|
161
|
-
input = File.join(@root, "requests", "result-#{original_generation}", "request.json")
|
|
162
|
-
original = Files.json(input)
|
|
163
|
-
expected = document("submit_result", params.merge("expected_generation" => original_generation), nil,
|
|
164
|
-
inventory([bytes] + parts))
|
|
165
|
-
unless original.except("mutation_id") == expected.except("mutation_id")
|
|
166
|
-
raise ArgumentError, "accepted result differs from retained original input"
|
|
167
|
-
end
|
|
168
|
-
return replay(input: input).merge("request" => input)
|
|
169
|
-
end
|
|
170
|
-
unless observed.fetch("state") == "running" && observed.fetch("generation") == generation
|
|
171
|
-
raise AttemptErrors::EvidenceUnavailable, "result requires the current canonical generation; retain original inputs"
|
|
172
|
-
end
|
|
173
|
-
reply = mutate("result-#{generation}", "submit_result", params, [bytes] + parts)
|
|
174
|
-
signal_result(reply.fetch("data"))
|
|
175
|
-
reply
|
|
176
|
-
end
|
|
177
|
-
|
|
178
|
-
def adopt(pane:)
|
|
179
|
-
owner = supervisor
|
|
180
|
-
raise ArgumentError, "ordinary result owner pane differs" unless pane == owner.fetch("binding").fetch("pane")
|
|
181
|
-
observed = status
|
|
182
|
-
return {"status" => "ok", "data" => observed} if %w[succeeded failed].include?(observed.fetch("state"))
|
|
183
|
-
result = observed.fetch("submitted_result")
|
|
184
|
-
raise AttemptErrors::EvidenceUnavailable, "ordinary worker has not submitted a result" unless result
|
|
185
|
-
mutate("adopt-#{owner.fetch('generation')}-#{observed.fetch('generation')}", "finish", selection.merge("pane" => pane,
|
|
186
|
-
"expected_generation" => observed.fetch("generation"),
|
|
187
|
-
**result.slice("result_id", "receipt_digest", "head")))
|
|
188
|
-
end
|
|
189
|
-
|
|
190
|
-
def replay(input:)
|
|
191
|
-
request = Files.json(input)
|
|
192
|
-
expected = request["operation"] == "adopt_supervisor" ? context.slice("assignment_id") : selection
|
|
193
|
-
unless request["project_id"] == context.fetch("project_id") &&
|
|
194
|
-
request.fetch("params").slice("assignment_id", "attempt_id") == expected &&
|
|
195
|
-
File.dirname(File.dirname(File.expand_path(input))) == File.join(@root, "requests")
|
|
196
|
-
raise ArgumentError, "recovery request differs from original execution"
|
|
197
|
-
end
|
|
198
|
-
reply = @transport.request(input: input)
|
|
199
|
-
signal_result(reply.fetch("data")) if request.fetch("operation") == "submit_result"
|
|
200
|
-
reply
|
|
201
|
-
end
|
|
202
|
-
|
|
203
|
-
private
|
|
204
|
-
|
|
205
|
-
def selection = context.slice("assignment_id", "attempt_id")
|
|
206
|
-
|
|
207
|
-
# A filesystem watch hint is neither execution state nor a receipt.
|
|
208
|
-
# Adoption never reads this file, even after restart or forged writes.
|
|
209
|
-
def signal_result(result)
|
|
210
|
-
name = "hint-#{SecureRandom.hex(8)}"
|
|
211
|
-
Files.write_file(@root, name, JSON.generate({"hint_only" => true,
|
|
212
|
-
"project_id" => context.fetch("project_id"), **selection,
|
|
213
|
-
"result_id" => result.fetch("result_id")}))
|
|
214
|
-
File.rename(File.join(@root, name), File.join(@root, "result-hint.json"))
|
|
215
|
-
File.open(@root) { |file| file.fsync }
|
|
216
|
-
ensure
|
|
217
|
-
File.unlink(File.join(@root, name)) if name && File.exist?(File.join(@root, name))
|
|
218
|
-
end
|
|
219
|
-
|
|
220
|
-
def worker!(pane)
|
|
221
|
-
raise ArgumentError, "ordinary worker pane differs" unless pane == context.fetch("pane")
|
|
222
|
-
end
|
|
223
|
-
|
|
224
|
-
def step(value)
|
|
225
|
-
value && {"number" => value.number, "name" => value.name, "status" => value.status.to_s,
|
|
226
|
-
"instructions" => value.instructions, "workflow" => value.workflow, "skill" => value.skill}
|
|
227
|
-
end
|
|
228
|
-
|
|
229
|
-
def read(operation, params)
|
|
230
|
-
Dir.mktmpdir("ordinary-read-", @root) do |directory|
|
|
231
|
-
Files.write_file(directory, "request.json", JSON.generate(document(operation, params, nil, [])))
|
|
232
|
-
@transport.request(input: File.join(directory, "request.json"))
|
|
233
|
-
end
|
|
234
|
-
end
|
|
235
|
-
|
|
236
|
-
def snapshot(operation, params)
|
|
237
|
-
Dir.mktmpdir("ordinary-read-", @root) do |directory|
|
|
238
|
-
Files.write_file(directory, "request.json", JSON.generate(document(operation, params, nil, [])))
|
|
239
|
-
reply = @transport.request(input: File.join(directory, "request.json"), output: File.join(directory, "snapshot"))
|
|
240
|
-
entries = reply.fetch("files")
|
|
241
|
-
raise AttemptErrors::EvidenceUnavailable, "canonical queue snapshot count differs" unless entries.one?
|
|
242
|
-
[reply, Files.read_file(entries.first.fetch("file"), Authority::PreparedWork::MAX_TOTAL)]
|
|
243
|
-
end
|
|
244
|
-
end
|
|
245
|
-
|
|
246
|
-
def mutate(key, operation, params, parts = [])
|
|
247
|
-
request = retain(key, operation, params, parts)
|
|
248
|
-
@transport.request(input: request).merge("request" => request)
|
|
249
|
-
rescue StandardError => error
|
|
250
|
-
raise error.class, "#{error.message}; recover original request #{request || key} before new work"
|
|
251
|
-
end
|
|
252
|
-
|
|
253
|
-
def retain(key, operation, params, parts)
|
|
254
|
-
directory = File.join(@root, "requests")
|
|
255
|
-
begin
|
|
256
|
-
Dir.mkdir(directory, 0o700)
|
|
257
|
-
rescue Errno::EEXIST
|
|
258
|
-
Authority::PrivateDirectory.verify!(directory)
|
|
259
|
-
end
|
|
260
|
-
raise ArgumentError, "ordinary retained operation key is invalid" unless key.match?(/\A[a-z0-9-]{1,128}\z/)
|
|
261
|
-
File.open(File.join(directory, ".lock"), File::RDWR | File::CREAT | File::NOFOLLOW, 0o600) do |lock|
|
|
262
|
-
lock.flock(File::LOCK_EX)
|
|
263
|
-
target = File.join(directory, key)
|
|
264
|
-
value = document(operation, params, nil, inventory(parts))
|
|
265
|
-
if File.exist?(target)
|
|
266
|
-
Authority::PrivateDirectory.verify!(target)
|
|
267
|
-
original = Files.json(File.join(target, "request.json"))
|
|
268
|
-
unless original.except("mutation_id") == value.except("mutation_id")
|
|
269
|
-
raise ArgumentError, "retained operation changed; replay its exact request or reconcile canonical state"
|
|
270
|
-
end
|
|
271
|
-
else
|
|
272
|
-
scratch = Dir.mktmpdir(".prepare-", directory)
|
|
273
|
-
begin
|
|
274
|
-
parts.each_with_index { |bytes, index| Files.write_file(scratch, "part-#{index}", bytes) }
|
|
275
|
-
value["mutation_id"] = SecureRandom.hex(16)
|
|
276
|
-
Files.write_file(scratch, "request.json", JSON.generate(value) + "\n")
|
|
277
|
-
File.rename(scratch, target)
|
|
278
|
-
File.open(directory) { |handle| handle.fsync }
|
|
279
|
-
ensure
|
|
280
|
-
FileUtils.remove_entry(scratch) if File.exist?(scratch)
|
|
281
|
-
end
|
|
282
|
-
end
|
|
283
|
-
File.join(target, "request.json")
|
|
284
|
-
end
|
|
285
|
-
end
|
|
286
|
-
|
|
287
|
-
def document(operation, params, mutation, parts)
|
|
288
|
-
{"version" => 1, "project_id" => context.fetch("project_id"), "operation" => operation,
|
|
289
|
-
"mutation_id" => mutation, "params" => params, "parts" => parts}
|
|
290
|
-
end
|
|
291
|
-
|
|
292
|
-
def inventory(parts)
|
|
293
|
-
parts.each_with_index.map { |bytes, index|
|
|
294
|
-
{"file" => "part-#{index}", "bytes" => bytes.bytesize, "sha256" => Digest::SHA256.hexdigest(bytes)} }
|
|
295
|
-
end
|
|
296
|
-
|
|
297
|
-
def verify_result_intent!(params, parts)
|
|
298
|
-
expected = document("submit_result", params.except("expected_generation"), nil, inventory(parts))
|
|
299
|
-
Dir.glob(File.join(@root, "requests", "result-*", "request.json")).each do |input|
|
|
300
|
-
original = Files.json(input)
|
|
301
|
-
original = original.merge("params" => original.fetch("params").except("expected_generation"))
|
|
302
|
-
unless original.except("mutation_id") == expected.except("mutation_id")
|
|
303
|
-
raise ArgumentError, "result retry changed the retained original intent"
|
|
304
|
-
end
|
|
305
|
-
end
|
|
306
|
-
end
|
|
307
|
-
end
|
|
308
|
-
end
|
|
309
|
-
end
|
|
310
|
-
end
|