ace-assign 0.69.0 → 0.71.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/presets/deliver-release.yml +11 -8
- data/.ace-defaults/assign/presets/integrate-batch.yml +10 -5
- data/.ace-defaults/assign/presets/supervise-delivery.yml +6 -4
- data/CHANGELOG.md +27 -0
- data/handbook/skills/as-assign-drive/SKILL.md +5 -2
- data/handbook/workflow-instructions/assign/drive.wf.md +77 -0
- data/lib/ace/assign/atoms/assignment_scope.rb +5 -0
- data/lib/ace/assign/atoms/candidate_limits.rb +20 -0
- data/lib/ace/assign/atoms/git_blob_batch.rb +59 -0
- data/lib/ace/assign/authority/assignment_definition.rb +35 -0
- data/lib/ace/assign/authority/candidate_blob_batch.rb +55 -0
- data/lib/ace/assign/authority/candidate_head_comparison.rb +139 -0
- data/lib/ace/assign/authority/candidate_transfer.rb +57 -27
- data/lib/ace/assign/authority/command_execution.rb +180 -0
- data/lib/ace/assign/authority/endcap.rb +4 -2
- data/lib/ace/assign/authority/endcap_attempts.rb +1 -1
- data/lib/ace/assign/authority/endcap_campaign_rounds.rb +1 -1
- data/lib/ace/assign/authority/endcap_publication.rb +0 -166
- data/lib/ace/assign/authority/endcap_service_context.rb +38 -0
- data/lib/ace/assign/authority/fixed_publication_lifecycle.rb +176 -0
- data/lib/ace/assign/authority/{endcap_services.rb → fixed_service_lifecycle.rb} +42 -46
- data/lib/ace/assign/authority/launch_control_exclusions.rb +3 -17
- data/lib/ace/assign/authority/ordinary_attempt_start.rb +137 -0
- data/lib/ace/assign/authority/ordinary_client.rb +70 -0
- data/lib/ace/assign/authority/ordinary_completion.rb +104 -0
- data/lib/ace/assign/authority/ordinary_effect_settlement.rb +118 -0
- data/lib/ace/assign/authority/ordinary_lifecycle.rb +227 -0
- data/lib/ace/assign/authority/ordinary_merge_evidence.rb +81 -0
- data/lib/ace/assign/authority/ordinary_peer.rb +54 -0
- data/lib/ace/assign/authority/ordinary_progress.rb +162 -0
- data/lib/ace/assign/authority/ordinary_queue.rb +115 -0
- data/lib/ace/assign/authority/ordinary_release_artifacts.rb +143 -0
- data/lib/ace/assign/authority/ordinary_results.rb +206 -0
- data/lib/ace/assign/authority/ordinary_review_evidence.rb +180 -0
- data/lib/ace/assign/authority/ordinary_reviews.rb +214 -0
- data/lib/ace/assign/authority/ordinary_server.rb +75 -0
- data/lib/ace/assign/authority/ordinary_supervisor.rb +99 -0
- data/lib/ace/assign/authority/prepared_work.rb +9 -4
- data/lib/ace/assign/authority/protected_assignment_context.rb +3 -3
- data/lib/ace/assign/authority/release_artifact_manifest.rb +91 -0
- data/lib/ace/assign/authority/retained_input.rb +53 -0
- data/lib/ace/assign/authority/router.rb +11 -6
- data/lib/ace/assign/authority/server.rb +11 -180
- data/lib/ace/assign/authority/socket_server.rb +255 -0
- data/lib/ace/assign/authority/transfer_codec.rb +5 -3
- data/lib/ace/assign/cli/commands/attempt/evidence.rb +2 -2
- data/lib/ace/assign/cli/commands/submit_candidate.rb +1 -1
- data/lib/ace/assign/molecules/canonical_evidence.rb +25 -8
- data/lib/ace/assign/molecules/canonical_read_commands.rb +84 -0
- data/lib/ace/assign/molecules/canonical_read_snapshot.rb +2 -63
- data/lib/ace/assign/molecules/evidence_journal.rb +157 -63
- data/lib/ace/assign/molecules/installed_journal_read.rb +143 -0
- data/lib/ace/assign/molecules/journal_mutation.rb +7 -4
- data/lib/ace/assign/molecules/lifecycle_exclusion.rb +13 -2
- data/lib/ace/assign/molecules/proposal_journal.rb +32 -4
- data/lib/ace/assign/molecules/receipt_verifier.rb +8 -2
- data/lib/ace/assign/molecules/runtime_control_surface_runner.rb +2 -1
- data/lib/ace/assign/organisms/attempt_coordinator.rb +88 -36
- data/lib/ace/assign/organisms/ordinary_driver.rb +310 -0
- data/lib/ace/assign/organisms/ordinary_merge_consumer.rb +5 -5
- data/lib/ace/assign/version.rb +1 -1
- metadata +39 -10
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ace
|
|
4
|
+
module Assign
|
|
5
|
+
module Authority
|
|
6
|
+
module FixedPublicationLifecycle
|
|
7
|
+
def publication_executor_identity?(project:, uid:)
|
|
8
|
+
service_project!(project).fetch("service_executor_uids").include?(uid)
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def with_publication_hitl!(binding:, project:, peer:)
|
|
12
|
+
params = binding.slice("mapping_id", "assignment_id", "attempt_id", "request_id", "claim_binding", "input_digest", "candidate_generation", "head")
|
|
13
|
+
map = service_mapping!(params.fetch("mapping_id"))
|
|
14
|
+
raise AttemptErrors::UnauthorizedIdentity, "publication project differs" unless map.fetch("project_id") == project
|
|
15
|
+
with_service_scope(params: params, map: map) do |journal, _registration|
|
|
16
|
+
service_journal!(journal)
|
|
17
|
+
record = publication_record!(journal, params, map, peer, :executor)
|
|
18
|
+
unless record["dispatch_phase"] == "otp_pending" && record["publication_challenge_digest"] == binding.fetch("challenge_digest")
|
|
19
|
+
raise AttemptErrors::Conflict, "publication HITL challenge is no longer pending"
|
|
20
|
+
end
|
|
21
|
+
ServiceEvidence.new(journal: journal).publication_challenge!(record)
|
|
22
|
+
selection = settlement_immutable({"executor_process_binding" => record.fetch("executor_process_binding"),
|
|
23
|
+
"challenge_ref" => record.fetch("publication_challenge_ref").fetch("ref")})
|
|
24
|
+
yield selection
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def authorize_publication_update!(journal:, existing:, replacement:, pending:)
|
|
29
|
+
operation = pending.fetch(:operation)
|
|
30
|
+
allowed = ServicePublicationEvidence::PUBLICATION_FIELDS + ["dispatch_phase"]
|
|
31
|
+
unless existing.except(*allowed) == replacement.except(*allowed) && existing["state"] == "uncertain"
|
|
32
|
+
raise AttemptErrors::InvalidState, "publication transition changed original binding"
|
|
33
|
+
end
|
|
34
|
+
owner = ServiceEvidence.new(journal: journal)
|
|
35
|
+
if operation == "publication_challenge"
|
|
36
|
+
unless %w[dispatch_started issuing].include?(existing["dispatch_phase"]) && replacement["dispatch_phase"] == "otp_pending" &&
|
|
37
|
+
replacement["publication_issuing_event_digest"] == existing["publication_issuing_event_digest"]
|
|
38
|
+
raise AttemptErrors::InvalidState, "publication need has no source-owned rejected push"
|
|
39
|
+
end
|
|
40
|
+
need = owner.publication_challenge!(replacement, pending: pending)
|
|
41
|
+
unless need["previous_challenge_digest"] == existing["publication_challenge_digest"]
|
|
42
|
+
raise AttemptErrors::InvalidState, "publication need predecessor differs"
|
|
43
|
+
end
|
|
44
|
+
elsif operation == "publication_continue"
|
|
45
|
+
unless existing["dispatch_phase"] == "otp_pending" && replacement["dispatch_phase"] == "issuing" &&
|
|
46
|
+
(allowed - %w[dispatch_phase publication_issuing_event_digest]).all? { |key| existing[key] == replacement[key] }
|
|
47
|
+
raise AttemptErrors::InvalidState, "publication continuation is not a unique pending challenge"
|
|
48
|
+
end
|
|
49
|
+
owner.publication_challenge!(replacement, pending: pending)
|
|
50
|
+
events = pending.fetch(:current_events) + pending.fetch(:pending_events)
|
|
51
|
+
event = events.find { |row| row["digest"] == replacement["publication_issuing_event_digest"] }
|
|
52
|
+
unless event && event["type"] == "service_publication_issuing" &&
|
|
53
|
+
%w[request_id input_digest claim_binding publication_challenge_digest].all? { |key| event.dig("payload", key) == replacement[key] }
|
|
54
|
+
raise AttemptErrors::EvidenceUnavailable, "publication issuing event is unavailable"
|
|
55
|
+
end
|
|
56
|
+
else
|
|
57
|
+
raise AttemptErrors::InvalidState, "publication transition has no fixed source owner"
|
|
58
|
+
end
|
|
59
|
+
true
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def publication_record!(journal, params, map, peer, role, events: nil)
|
|
63
|
+
record = journal.service_request(params.fetch("request_id"))
|
|
64
|
+
unless record && record["operation"] == "publish" && record["state"] == "uncertain" &&
|
|
65
|
+
%w[dispatch_started otp_pending issuing].include?(record["dispatch_phase"]) &&
|
|
66
|
+
%w[claim_binding input_digest candidate_generation].all? { |key| record[key] == params[key] } &&
|
|
67
|
+
record["candidate_head"] == params["head"] && record["mapping_id"] == params["mapping_id"]
|
|
68
|
+
raise AttemptErrors::Conflict, "publication original tuple or phase differs"
|
|
69
|
+
end
|
|
70
|
+
service_executor!(peer, role, record)
|
|
71
|
+
service_ticket!(record, params, map, peer)
|
|
72
|
+
events ||= attempt_events(journal, params)
|
|
73
|
+
current = exact_candidate!(candidate(events), params)
|
|
74
|
+
origin = service_origin!(events, params)
|
|
75
|
+
service_worker!(record.fetch("worker_process_binding"), :worker, map, origin)
|
|
76
|
+
service_review!(journal, events, params, map, current)
|
|
77
|
+
service_scope_open!(events: events, params: params, map: map)
|
|
78
|
+
service_policy!.authorize!(record)
|
|
79
|
+
ServiceEvidence.new(journal: journal).context(record)
|
|
80
|
+
record
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def authorize_publication_transfer!(request, params, map, peer, role)
|
|
84
|
+
with_service_scope(params: params, map: map) do |journal, _registration|
|
|
85
|
+
service_journal!(journal)
|
|
86
|
+
publication_record!(journal, params, map, peer, role)
|
|
87
|
+
end
|
|
88
|
+
true
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def dispatch_publication(request, params, map, peer, role, transfer)
|
|
92
|
+
authorize_publication_transfer!(request, params, map, peer, role)
|
|
93
|
+
challenge = request.fetch("operation") == "publication_challenge"
|
|
94
|
+
bytes = nil
|
|
95
|
+
if challenge
|
|
96
|
+
unless transfer && transfer.count == 1
|
|
97
|
+
raise ArgumentError, "publication need requires one bounded artifact"
|
|
98
|
+
end
|
|
99
|
+
bytes = transfer.bytes.dup.freeze
|
|
100
|
+
unless Digest::SHA256.hexdigest(bytes) == params.fetch("receipt_sha256")
|
|
101
|
+
raise AttemptErrors::ReceiptRejected, "publication need transfer digest differs"
|
|
102
|
+
end
|
|
103
|
+
elsif transfer
|
|
104
|
+
raise ArgumentError, "publication continuation forbids upload"
|
|
105
|
+
end
|
|
106
|
+
with_service_scope(params: params, map: map) do |journal, _registration|
|
|
107
|
+
publication_record!(journal, params, map, peer, role)
|
|
108
|
+
result = journal.mutate(assignment_id: params.fetch("assignment_id"), attempt_id: params.fetch("attempt_id"),
|
|
109
|
+
mutation_id: request.fetch("mutation_id"), operation: request.fetch("operation"),
|
|
110
|
+
parameters_digest: Atoms::EvidenceDigest.digest(params), expected_generation: params.fetch("expected_generation"),
|
|
111
|
+
with_replay: true) do |events, _commit, generation|
|
|
112
|
+
record = publication_record!(journal, params, map, peer, role, events: events)
|
|
113
|
+
if challenge
|
|
114
|
+
publication_challenge_plan(journal, events, record, params, bytes, generation)
|
|
115
|
+
else
|
|
116
|
+
publication_continue_plan(journal, events, record, params, generation)
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
if !challenge && result.fetch(:replayed)
|
|
120
|
+
result = result.merge(data: result.fetch(:data).merge("invocation" => "already_issued"))
|
|
121
|
+
end
|
|
122
|
+
result
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def publication_challenge_plan(journal, events, record, params, bytes, generation)
|
|
127
|
+
owner = ServiceEvidence.new(journal: journal)
|
|
128
|
+
digest = Digest::SHA256.hexdigest(bytes)
|
|
129
|
+
if record["dispatch_phase"] == "otp_pending"
|
|
130
|
+
owner.publication_challenge!(record)
|
|
131
|
+
raise AttemptErrors::Conflict, "publication pending challenge differs" unless record["publication_challenge_digest"] == digest
|
|
132
|
+
return {data: service_projection(record).merge("challenge" => "retained")}
|
|
133
|
+
end
|
|
134
|
+
unless %w[dispatch_started issuing].include?(record["dispatch_phase"])
|
|
135
|
+
raise AttemptErrors::InvalidState, "publication has no rejected push"
|
|
136
|
+
end
|
|
137
|
+
previous = record["publication_challenge_digest"]
|
|
138
|
+
owner.publication_challenge!(record) if previous
|
|
139
|
+
owner.publication_need!(bytes, record, previous: previous)
|
|
140
|
+
payload = record.slice("request_id", "input_digest", "claim_binding").merge(
|
|
141
|
+
"challenge_digest" => digest, "previous_challenge_digest" => previous, "generation" => generation + 1)
|
|
142
|
+
time = Time.now.utc
|
|
143
|
+
event = Models::EvidenceEvent.build(type: "service_publication_challenge", attempt_id: record.fetch("attempt_id"),
|
|
144
|
+
payload: payload, previous_digest: events.last&.fetch("digest"), recorded_at: time)
|
|
145
|
+
plan = Molecules::CanonicalEvidence.new(journal: journal).import_plan(**owner.context(record),
|
|
146
|
+
artifacts: [bytes], admitted_after_event_digest: event.fetch("digest"))
|
|
147
|
+
replacement = record.merge("dispatch_phase" => "otp_pending", "publication_challenge_digest" => digest,
|
|
148
|
+
"publication_challenge_ref" => plan.fetch(:references).fetch(0),
|
|
149
|
+
"publication_challenge_event_digest" => event.fetch("digest"), "publication_challenge_generation" => generation + 1)
|
|
150
|
+
plan.merge(events: [{type: "service_publication_challenge", payload: payload, recorded_at: time}] + plan.fetch(:events),
|
|
151
|
+
data: service_projection(replacement).merge("challenge" => "created"), service_updates: [{request_id: record.fetch("request_id"),
|
|
152
|
+
expected: record, replacement: replacement, event_type: "service_transition"}])
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def publication_continue_plan(journal, events, record, params, generation)
|
|
156
|
+
ServiceEvidence.new(journal: journal).publication_challenge!(record)
|
|
157
|
+
unless record["publication_challenge_digest"] == params.fetch("challenge_digest")
|
|
158
|
+
raise AttemptErrors::Conflict, "publication continuation challenge differs"
|
|
159
|
+
end
|
|
160
|
+
if record["dispatch_phase"] == "issuing"
|
|
161
|
+
return {data: service_projection(record).merge("invocation" => "already_issued")}
|
|
162
|
+
end
|
|
163
|
+
raise AttemptErrors::InvalidState, "publication is not pending" unless record["dispatch_phase"] == "otp_pending"
|
|
164
|
+
payload = record.slice("request_id", "input_digest", "claim_binding", "publication_challenge_digest").merge("generation" => generation + 1)
|
|
165
|
+
time = Time.now.utc
|
|
166
|
+
event = Models::EvidenceEvent.build(type: "service_publication_issuing", attempt_id: record.fetch("attempt_id"),
|
|
167
|
+
payload: payload, previous_digest: events.last&.fetch("digest"), recorded_at: time)
|
|
168
|
+
replacement = record.merge("dispatch_phase" => "issuing", "publication_issuing_event_digest" => event.fetch("digest"))
|
|
169
|
+
{events: [{type: "service_publication_issuing", payload: payload, recorded_at: time}],
|
|
170
|
+
data: service_projection(replacement).merge("invocation" => "permitted"), service_updates: [{request_id: record.fetch("request_id"),
|
|
171
|
+
expected: record, replacement: replacement, event_type: "service_transition"}]}
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
end
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
module Ace
|
|
4
4
|
module Assign
|
|
5
5
|
module Authority
|
|
6
|
-
|
|
6
|
+
module FixedServiceLifecycle
|
|
7
7
|
SERVICE_IDENTITY = %w[request_id assignment_id attempt_id project_id operation input_digest target
|
|
8
8
|
authorization service_id caller_uid candidate_head candidate_generation executor_uid transport policy_digest
|
|
9
9
|
worker_process_binding launch_ticket reservation_generation mapping_id].freeze
|
|
@@ -17,7 +17,7 @@ module Ace
|
|
|
17
17
|
end
|
|
18
18
|
|
|
19
19
|
def service_settlement_evidence!(journal:, events:, params:, map:, commit:)
|
|
20
|
-
|
|
20
|
+
service_journal!(journal)
|
|
21
21
|
journal.verify_commit!(commit)
|
|
22
22
|
retained = journal.read_events(params.fetch("assignment_id"), commit: commit)
|
|
23
23
|
.select { |event| event["attempt_id"] == params.fetch("attempt_id") }
|
|
@@ -80,7 +80,7 @@ module Ace
|
|
|
80
80
|
# Called only by the installed journal composition, including lower
|
|
81
81
|
# service writers. It never reacquires lifecycle/authority locks.
|
|
82
82
|
def authorize_service_update!(journal:, existing:, replacement:, pending:)
|
|
83
|
-
|
|
83
|
+
service_journal!(journal)
|
|
84
84
|
if existing && existing.key?("executor_process_binding") &&
|
|
85
85
|
Atoms::EvidenceDigest.digest(existing.fetch("executor_process_binding")) != Atoms::EvidenceDigest.digest(replacement["executor_process_binding"])
|
|
86
86
|
raise AttemptErrors::InvalidState, "original cleanup executor cannot change"
|
|
@@ -114,28 +114,27 @@ module Ace
|
|
|
114
114
|
end
|
|
115
115
|
bytes = pending && pending.fetch(:service_inputs, {})[replacement.fetch("request_id")]
|
|
116
116
|
raise AttemptErrors::UnauthorizedIdentity, "fresh service effect requires its exact original input" unless bytes.is_a?(String)
|
|
117
|
-
map =
|
|
117
|
+
map = service_mapping!(replacement.fetch("mapping_id"))
|
|
118
118
|
unless replacement["project_id"] == map["project_id"] && replacement["caller_uid"] == map["worker_uid"]
|
|
119
119
|
raise AttemptErrors::UnauthorizedIdentity, "protected service mapping differs"
|
|
120
120
|
end
|
|
121
121
|
params = replacement.slice("mapping_id", "assignment_id", "attempt_id", "candidate_generation").merge("head" => replacement.fetch("candidate_head"))
|
|
122
122
|
events = pending.fetch(:current_events)
|
|
123
|
-
|
|
124
|
-
origin =
|
|
125
|
-
unless replacement
|
|
126
|
-
replacement["launch_ticket"] == origin.fetch("launch_ticket") && replacement["reservation_generation"] == origin.fetch("reservation_generation")
|
|
123
|
+
service_scope_open!(events: events, params: params, map: map)
|
|
124
|
+
origin = service_origin!(events, params)
|
|
125
|
+
unless service_origin_matches!(replacement, origin)
|
|
127
126
|
raise AttemptErrors::UnauthorizedIdentity, "protected service origin differs"
|
|
128
127
|
end
|
|
129
|
-
receiver =
|
|
128
|
+
receiver = service_project!(map.fetch("project_id")).fetch("service_receivers").fetch(replacement.fetch("service_id"))
|
|
130
129
|
raise AttemptErrors::UnauthorizedIdentity, "protected service executor differs" unless receiver.fetch("executor_uid") == replacement.fetch("executor_uid")
|
|
131
130
|
current = exact_candidate!(candidate(events), params)
|
|
132
|
-
|
|
131
|
+
service_review!(journal, events, params, map, current, service_operation: replacement.fetch("operation")) unless %w[create update].include?(replacement.fetch("operation"))
|
|
133
132
|
service_policy!.prepare!(replacement, input_bytes: bytes)
|
|
134
133
|
if begin_dispatch && %w[prune-preserved-workspace publish].include?(replacement.fetch("operation"))
|
|
135
134
|
executor = replacement.fetch("executor_process_binding")
|
|
136
135
|
boot = replacement.fetch("worker_process_binding").fetch("started_at").split(":").fetch(1)
|
|
137
136
|
Molecules::ExecutionScopeLineage.validate_process_identity!(executor, boot_id: boot)
|
|
138
|
-
credentials =
|
|
137
|
+
credentials = service_project!(map.fetch("project_id")).fetch("peer_credentials").fetch(receiver.fetch("executor_uid").to_s)
|
|
139
138
|
unless executor.slice("uid", "gid", "groups") == credentials.slice("gid", "groups").merge("uid" => receiver.fetch("executor_uid"))
|
|
140
139
|
raise AttemptErrors::UnauthorizedIdentity, "original cleanup executor credentials differ"
|
|
141
140
|
end
|
|
@@ -164,8 +163,8 @@ module Ace
|
|
|
164
163
|
def authorize_service_transfer!(request, params, map, peer, role)
|
|
165
164
|
service_policy!
|
|
166
165
|
service_policy!.visible!(project: map.fetch("project_id"), uid: map.fetch("worker_uid")) unless request.fetch("operation") == "complete_service"
|
|
167
|
-
|
|
168
|
-
|
|
166
|
+
with_service_scope(params: params, map: map) do |journal, _registration|
|
|
167
|
+
service_journal!(journal)
|
|
169
168
|
if request.fetch("operation") == "request_service"
|
|
170
169
|
existing = journal.service_request(params.fetch("request_id"))
|
|
171
170
|
if existing
|
|
@@ -173,8 +172,8 @@ module Ace
|
|
|
173
172
|
service_replay_binding!(existing, params, map)
|
|
174
173
|
else
|
|
175
174
|
events = attempt_events(journal, params)
|
|
176
|
-
origin =
|
|
177
|
-
|
|
175
|
+
origin = service_origin!(events, params)
|
|
176
|
+
service_worker!(params.fetch("worker_process_binding"), :worker, map, origin)
|
|
178
177
|
service_receiver!(peer, role, map, params.fetch("service_id"))
|
|
179
178
|
end
|
|
180
179
|
else
|
|
@@ -191,7 +190,7 @@ module Ace
|
|
|
191
190
|
# This immutable observation grants no service claim or dispatch permit.
|
|
192
191
|
def workspace_prune_preview_context(_request, params, map, peer, role, transfer)
|
|
193
192
|
raise ArgumentError, "preview context forbids upload" unless transfer.nil?
|
|
194
|
-
|
|
193
|
+
with_service_scope(params: params, map: map) do |journal, _registration|
|
|
195
194
|
{data: workspace_prune_preview_context_at!(journal: journal, params: params, map: map, peer: peer, role: role), replayed: false}
|
|
196
195
|
end
|
|
197
196
|
end
|
|
@@ -203,11 +202,11 @@ module Ace
|
|
|
203
202
|
|
|
204
203
|
def workspace_prune_preview_context_at!(journal:, params:, map:, peer:, role:)
|
|
205
204
|
service_policy!.visible!(project: map.fetch("project_id"), uid: map.fetch("worker_uid"))
|
|
206
|
-
|
|
205
|
+
service_journal!(journal)
|
|
207
206
|
commit = journal.ref_value
|
|
208
|
-
events =
|
|
209
|
-
origin =
|
|
210
|
-
|
|
207
|
+
events = service_preview_events!(journal: journal, commit: commit, params: params, map: map)
|
|
208
|
+
origin = service_origin!(events, params)
|
|
209
|
+
service_worker!(params.fetch("worker_process_binding"), :worker, map, origin)
|
|
211
210
|
receiver = service_receiver!(peer, role, map, params.fetch("service_id"))
|
|
212
211
|
service_policy!.workspace_prune_receiver!(project: map.fetch("project_id"), uid: map.fetch("worker_uid"),
|
|
213
212
|
service_id: params.fetch("service_id"), executor_uid: receiver.fetch("executor_uid"))
|
|
@@ -220,7 +219,7 @@ module Ace
|
|
|
220
219
|
"journal_commit" => commit,
|
|
221
220
|
"maintenance" => params.slice("mapping_id", "assignment_id", "attempt_id").merge("project_id" => map.fetch("project_id")),
|
|
222
221
|
"head" => selected.fetch("head"), "candidate_generation" => selected.fetch("candidate_generation"),
|
|
223
|
-
"worker_process_binding" => origin
|
|
222
|
+
"worker_process_binding" => service_origin_fields!(origin).fetch("worker_process_binding"),
|
|
224
223
|
"caller_process_binding" => params.fetch("worker_process_binding")
|
|
225
224
|
}
|
|
226
225
|
raise AttemptErrors::Conflict, "preview canonical selection changed" unless journal.ref_value == commit
|
|
@@ -232,8 +231,8 @@ module Ace
|
|
|
232
231
|
private
|
|
233
232
|
|
|
234
233
|
def service_status(_request, params, map, peer, role)
|
|
235
|
-
|
|
236
|
-
|
|
234
|
+
with_service_scope(params: params, map: map) do |journal, _registration|
|
|
235
|
+
service_journal!(journal)
|
|
237
236
|
commit = journal.ref_value
|
|
238
237
|
read = role == :executor && journal.service_settlement_read(params.fetch("request_id"), commit: commit)
|
|
239
238
|
record = read ? read.fetch(:record) : journal.service_request(params.fetch("request_id"), commit: commit)
|
|
@@ -261,7 +260,7 @@ module Ace
|
|
|
261
260
|
review && review["reviewer_uid"] == peer["uid"] && review["head"] == params["head"] &&
|
|
262
261
|
review["candidate_generation"] == params["candidate_generation"] && @kernel.descendant?(peer, review.fetch("reviewer_process_binding"))
|
|
263
262
|
when :supervisor
|
|
264
|
-
|
|
263
|
+
service_project!(map.fetch("project_id")).fetch("supervisor_uids").include?(peer["uid"])
|
|
265
264
|
else
|
|
266
265
|
false
|
|
267
266
|
end
|
|
@@ -287,8 +286,8 @@ module Ace
|
|
|
287
286
|
authorize_service_transfer!(request, params, map, peer, role)
|
|
288
287
|
raise ArgumentError, "structured service input transfer is missing" unless transfer && transfer.count == 1
|
|
289
288
|
bytes = transfer.bytes.dup.freeze
|
|
290
|
-
|
|
291
|
-
|
|
289
|
+
with_service_scope(params: params, map: map) do |journal, _registration|
|
|
290
|
+
service_journal!(journal)
|
|
292
291
|
record = journal.service_request(params.fetch("request_id"))
|
|
293
292
|
service_executor!(peer, role, record)
|
|
294
293
|
service_ticket!(record, params, map, peer)
|
|
@@ -296,10 +295,9 @@ module Ace
|
|
|
296
295
|
raise AttemptErrors::Conflict, "authorization input binding differs"
|
|
297
296
|
end
|
|
298
297
|
events = attempt_events(journal, params)
|
|
299
|
-
|
|
300
|
-
origin =
|
|
301
|
-
unless record
|
|
302
|
-
record["launch_ticket"] == origin.fetch("launch_ticket") && record["reservation_generation"] == origin.fetch("reservation_generation")
|
|
298
|
+
service_scope_open!(events: events, params: params, map: map)
|
|
299
|
+
origin = service_origin!(events, params)
|
|
300
|
+
unless service_origin_matches!(record, origin)
|
|
303
301
|
raise AttemptErrors::UnauthorizedIdentity, "authorization launch origin differs"
|
|
304
302
|
end
|
|
305
303
|
current = exact_candidate!(candidate(events), params)
|
|
@@ -307,7 +305,7 @@ module Ace
|
|
|
307
305
|
raise AttemptErrors::Conflict, "authorization candidate changed"
|
|
308
306
|
end
|
|
309
307
|
service_receiver!(peer, role, map, record.fetch("service_id"))
|
|
310
|
-
|
|
308
|
+
service_review!(journal, events, params, map, current, service_operation: record.fetch("operation")) unless %w[create update].include?(record.fetch("operation"))
|
|
311
309
|
prepared = service_policy!.prepare!(record, input_bytes: bytes)
|
|
312
310
|
data = params.slice("request_id", "claim_binding", "head", "candidate_generation").merge(
|
|
313
311
|
"policy_digest" => prepared.fetch(:policy_digest), "operation_digest" => prepared.fetch(:operation_digest))
|
|
@@ -324,8 +322,8 @@ module Ace
|
|
|
324
322
|
raise ArgumentError, "structured service input transfer is missing" unless transfer && transfer.count == 1
|
|
325
323
|
transfer.bytes.dup.freeze
|
|
326
324
|
end
|
|
327
|
-
|
|
328
|
-
|
|
325
|
+
with_service_scope(params: params, map: map) do |journal, _registration|
|
|
326
|
+
service_journal!(journal)
|
|
329
327
|
unless request.fetch("operation") == "complete_service"
|
|
330
328
|
input_record = request.fetch("operation") == "request_service" ? params : journal.service_request(params.fetch("request_id"))
|
|
331
329
|
# Replay bypasses the mutation callback, never input validation.
|
|
@@ -348,8 +346,8 @@ module Ace
|
|
|
348
346
|
service_ticket!(record, params, map, peer)
|
|
349
347
|
completion_binding!(record, params, admitted) if request.fetch("operation") == "complete_service"
|
|
350
348
|
elsif !retained
|
|
351
|
-
origin =
|
|
352
|
-
|
|
349
|
+
origin = service_origin!(attempt_events(journal, params), params)
|
|
350
|
+
service_worker!(params.fetch("worker_process_binding"), :worker, map, origin)
|
|
353
351
|
service_receiver!(peer, role, map, params.fetch("service_id"))
|
|
354
352
|
end
|
|
355
353
|
result = journal.mutate(assignment_id: params.fetch("assignment_id"), attempt_id: params.fetch("attempt_id"),
|
|
@@ -433,7 +431,7 @@ module Ace
|
|
|
433
431
|
end
|
|
434
432
|
|
|
435
433
|
def service_receiver!(peer, role, map, service_id)
|
|
436
|
-
project =
|
|
434
|
+
project = service_project!(map.fetch("project_id"))
|
|
437
435
|
receiver = project.fetch("service_receivers").fetch(service_id)
|
|
438
436
|
unless role == :executor && peer["uid"] == receiver.fetch("executor_uid") &&
|
|
439
437
|
project.fetch("service_executor_uids").include?(peer["uid"]) &&
|
|
@@ -447,18 +445,17 @@ module Ace
|
|
|
447
445
|
end
|
|
448
446
|
|
|
449
447
|
def service_claim_plan(journal, events, params, map, peer, role, input_bytes)
|
|
450
|
-
origin =
|
|
448
|
+
origin = service_origin!(events, params)
|
|
451
449
|
worker = params.fetch("worker_process_binding")
|
|
452
|
-
|
|
450
|
+
service_worker!(worker, :worker, map, origin)
|
|
453
451
|
current = exact_candidate!(candidate(events), params)
|
|
454
|
-
|
|
452
|
+
service_review!(journal, events, params, map, current, service_operation: params.fetch("operation")) unless %w[create update].include?(params.fetch("operation"))
|
|
455
453
|
receiver = service_receiver!(peer, role, map, params.fetch("service_id"))
|
|
456
454
|
binding = params.slice("request_id", "assignment_id", "attempt_id", "operation", "input_digest", "target",
|
|
457
455
|
"authorization", "service_id").merge("project_id" => map.fetch("project_id"),
|
|
458
456
|
"caller_uid" => map.fetch("worker_uid"), "mapping_id" => params.fetch("mapping_id"), "candidate_head" => current.fetch("head"),
|
|
459
|
-
"candidate_generation" => current.fetch("candidate_generation")
|
|
460
|
-
|
|
461
|
-
"launch_ticket" => origin.fetch("launch_ticket"), "reservation_generation" => origin.fetch("reservation_generation"))
|
|
457
|
+
"candidate_generation" => current.fetch("candidate_generation"))
|
|
458
|
+
.merge(service_origin_fields!(origin))
|
|
462
459
|
prepared = service_policy!.prepare!(binding, input_bytes: input_bytes)
|
|
463
460
|
binding = prepared.fetch(:binding).merge("policy_digest" => prepared.fetch(:policy_digest))
|
|
464
461
|
unless binding["executor_uid"] == receiver.fetch("executor_uid")
|
|
@@ -488,16 +485,15 @@ module Ace
|
|
|
488
485
|
# A repeated begin never grants a second invocation, including after
|
|
489
486
|
# lease expiry or terminality. It reports only retained ticket truth.
|
|
490
487
|
return {data: service_projection(record).merge("invocation" => "already_started")} unless record["dispatch_phase"] == "issued"
|
|
491
|
-
origin =
|
|
492
|
-
unless record
|
|
493
|
-
record["launch_ticket"] == origin.fetch("launch_ticket") && record["reservation_generation"] == origin.fetch("reservation_generation")
|
|
488
|
+
origin = service_origin!(events, params)
|
|
489
|
+
unless service_origin_matches!(record, origin)
|
|
494
490
|
raise AttemptErrors::UnauthorizedIdentity, "dispatch launch origin differs"
|
|
495
491
|
end
|
|
496
492
|
current = exact_candidate!(candidate(events), params)
|
|
497
493
|
unless record["candidate_head"] == current["head"] && record["candidate_generation"] == current["candidate_generation"]
|
|
498
494
|
raise AttemptErrors::Conflict, "dispatch candidate changed"
|
|
499
495
|
end
|
|
500
|
-
|
|
496
|
+
service_review!(journal, events, params, map, current, service_operation: record.fetch("operation")) unless %w[create update].include?(record.fetch("operation"))
|
|
501
497
|
service_policy!.prepare!(record, input_bytes: input_bytes)
|
|
502
498
|
replacement = record.merge("dispatch_phase" => "dispatch_started")
|
|
503
499
|
if record.fetch("operation") == "prune-preserved-workspace"
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
require_relative "../molecules/protected_control_exclusion"
|
|
3
3
|
require_relative "campaign_execution"
|
|
4
|
+
require_relative "assignment_definition"
|
|
4
5
|
|
|
5
6
|
module Ace
|
|
6
7
|
module Assign
|
|
@@ -117,23 +118,8 @@ module Ace
|
|
|
117
118
|
end
|
|
118
119
|
|
|
119
120
|
def validated_registration_definition!(params, map)
|
|
120
|
-
bytes
|
|
121
|
-
|
|
122
|
-
Digest::SHA256.hexdigest(bytes) == params["definition_digest"]
|
|
123
|
-
raise ArgumentError, "invalid assignment definition digest or size"
|
|
124
|
-
end
|
|
125
|
-
value = JSON.parse(bytes)
|
|
126
|
-
allowed = %w[session_id name description created_at updated_at source_config parent task_id project_id prepared_work review_campaign campaign_execution]
|
|
127
|
-
unless value.is_a?(Hash) && (value.keys - allowed).empty? &&
|
|
128
|
-
%w[session_id name created_at source_config task_id project_id].all? { |key| value[key].is_a?(String) && !value[key].empty? } &&
|
|
129
|
-
value["session_id"] == params["assignment_id"] && value["project_id"] == map["project_id"]
|
|
130
|
-
raise ArgumentError, "invalid managed assignment definition"
|
|
131
|
-
end
|
|
132
|
-
CampaignExecution.validate_parent!(value.fetch("review_campaign")) if value.key?("review_campaign")
|
|
133
|
-
assignment = Models::Assignment.from_h(value)
|
|
134
|
-
raise ArgumentError, "definition is not managed" unless assignment.managed?
|
|
135
|
-
token!(assignment.task_id)
|
|
136
|
-
assignment
|
|
121
|
+
AssignmentDefinition.parse(bytes: params.fetch("definition_bytes"), digest: params["definition_digest"],
|
|
122
|
+
assignment_id: params["assignment_id"], project_id: map["project_id"])
|
|
137
123
|
end
|
|
138
124
|
end
|
|
139
125
|
end
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ace
|
|
4
|
+
module Assign
|
|
5
|
+
module Authority
|
|
6
|
+
module OrdinaryAttemptStart
|
|
7
|
+
START_KEYS = %w[assignment_id attempt_id expected_generation head pane scope].freeze
|
|
8
|
+
|
|
9
|
+
private
|
|
10
|
+
|
|
11
|
+
def start_attempt(request, peer, role, deadline)
|
|
12
|
+
params = request.fetch("params")
|
|
13
|
+
strict!(params, START_KEYS)
|
|
14
|
+
%w[assignment_id attempt_id].each { |key| token!(params.fetch(key)) }
|
|
15
|
+
if params.fetch("attempt_id").start_with?("definition-")
|
|
16
|
+
raise ArgumentError, "ordinary attempt ID uses the registration namespace"
|
|
17
|
+
end
|
|
18
|
+
token!(request.fetch("mutation_id"))
|
|
19
|
+
unless params.fetch("head").is_a?(String) && params.fetch("head").match?(/\A[0-9a-f]{40}\z/)
|
|
20
|
+
raise ArgumentError, "ordinary start HEAD is invalid"
|
|
21
|
+
end
|
|
22
|
+
unless %i[supervisor worker].include?(role) && params.fetch("expected_generation").is_a?(Integer) && params.fetch("expected_generation") == 0
|
|
23
|
+
raise AttemptErrors::UnauthorizedIdentity, "ordinary start requires an installed worker and a new attempt"
|
|
24
|
+
end
|
|
25
|
+
commit = @journal.ref_value
|
|
26
|
+
registered = registration(params.fetch("assignment_id"), commit: commit)
|
|
27
|
+
raise AttemptErrors::NotFound, "ordinary work is not registered" unless registered
|
|
28
|
+
work = registered_work(registered, commit, deadline)
|
|
29
|
+
scope = params.fetch("scope")
|
|
30
|
+
unless work.manifest.fetch("steps").any? { |step| step.fetch("number") == scope }
|
|
31
|
+
raise AttemptErrors::InvalidScope, "ordinary scope is not in the accepted work"
|
|
32
|
+
end
|
|
33
|
+
keys = [@exclusion.task_key(registered.fetch("task_id")), @exclusion.assignment_key(params.fetch("assignment_id"))]
|
|
34
|
+
@exclusion.with_shared_multi(keys, deadline: deadline) do
|
|
35
|
+
if @journal.mutation_result(request.fetch("mutation_id"))
|
|
36
|
+
# Work may legitimately have advanced since an acknowledged or
|
|
37
|
+
# lost start reply. Recover its original head, never recapture it.
|
|
38
|
+
identity = @admission.identity!(peer: peer, pane: params.fetch("pane"), deadline: deadline)
|
|
39
|
+
return @journal.mutate(assignment_id: params.fetch("assignment_id"), attempt_id: params.fetch("attempt_id"),
|
|
40
|
+
mutation_id: request.fetch("mutation_id"), operation: "start_attempt", parameters_digest: start_digest(params, peer, identity),
|
|
41
|
+
expected_generation: 0, with_replay: true, deadline: deadline,
|
|
42
|
+
replay_authorizer: -> { authorize_start_replay!(request, peer, role, deadline) }) do
|
|
43
|
+
raise AttemptErrors::EvidenceUnavailable, "accepted ordinary start disappeared"
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
# Only the installed admission can observe the actual native owner
|
|
47
|
+
# and worker checkout. It performs no launch or delivery effect.
|
|
48
|
+
observed = @admission.execution!(peer: peer, pane: params.fetch("pane"), head: params.fetch("head"), deadline: deadline)
|
|
49
|
+
identity = observed.fetch(:identity)
|
|
50
|
+
principal = {"uid" => peer.fetch("uid"), "actor" => identity.actor}
|
|
51
|
+
digest = start_digest(params, peer, identity)
|
|
52
|
+
authorize!(request, peer, role, deadline)
|
|
53
|
+
@journal.mutate(assignment_id: params.fetch("assignment_id"), attempt_id: params.fetch("attempt_id"),
|
|
54
|
+
mutation_id: request.fetch("mutation_id"), operation: "start_attempt", parameters_digest: digest,
|
|
55
|
+
expected_generation: 0, with_replay: true, deadline: deadline,
|
|
56
|
+
replay_authorizer: -> { authorize_start_replay!(request, peer, role, deadline) }) do |events, prefix, _generation|
|
|
57
|
+
authorize!(request, peer, role, deadline)
|
|
58
|
+
unless @admission.identity!(peer: peer, pane: params.fetch("pane"), deadline: deadline) == identity
|
|
59
|
+
raise AttemptErrors::UnauthorizedIdentity, "ordinary native worker changed before start acceptance"
|
|
60
|
+
end
|
|
61
|
+
unless events.empty? && registration(params.fetch("assignment_id"), commit: prefix) == registered
|
|
62
|
+
raise AttemptErrors::Conflict, "ordinary start registration or attempt changed"
|
|
63
|
+
end
|
|
64
|
+
if @journal.assignment_ids(commit: prefix).any? { |assignment|
|
|
65
|
+
assignment != params.fetch("assignment_id") && @journal.read_events(assignment, commit: prefix).any? { |event|
|
|
66
|
+
event.fetch("attempt_id") == params.fetch("attempt_id") } }
|
|
67
|
+
raise AttemptErrors::Conflict, "ordinary attempt ID belongs to another assignment"
|
|
68
|
+
end
|
|
69
|
+
# Sibling scopes may run independently. A different request ID
|
|
70
|
+
# never repeats an already consumed scope or any part of it.
|
|
71
|
+
if @journal.derived_attempts(params.fetch("assignment_id"), commit: prefix).any? { |attempt|
|
|
72
|
+
Atoms::AssignmentScope.overlap?(attempt.binding.scope, scope) }
|
|
73
|
+
raise AttemptErrors::Conflict, "ordinary scope already has an attempt; reconcile its original execution"
|
|
74
|
+
end
|
|
75
|
+
record = current_queue_record(registered, prefix)
|
|
76
|
+
queue = OrdinaryQueue.new(work: work, root: @input_root, bytes: queue_bytes(record, prefix))
|
|
77
|
+
.transition(operation: :activate, scope: scope)
|
|
78
|
+
queue_sha = Digest::SHA256.hexdigest(queue.bytes)
|
|
79
|
+
queue_path = "execution/definitions/#{params.fetch('assignment_id')}-queue-#{queue_sha}.json"
|
|
80
|
+
{events: [
|
|
81
|
+
{type: "intent", payload: {"assignment_id" => params.fetch("assignment_id"), "scope" => scope,
|
|
82
|
+
"project_id" => @project, "task_id" => registered.fetch("task_id"), "base_head" => observed.fetch(:checkout).fetch("head")}},
|
|
83
|
+
{type: "process_start", payload: {"actor" => identity.actor, "role" => identity.role, "runtime" => identity.runtime,
|
|
84
|
+
"pid" => identity.process_pid, "process_identity" => identity.runtime_binding.fetch("process_identity"),
|
|
85
|
+
"runtime_binding" => identity.runtime_binding, "checkout" => observed.fetch(:checkout)}}
|
|
86
|
+
], blobs: {queue_path => queue.bytes}, data: {
|
|
87
|
+
"authority" => "ordinary", "assignment_id" => params.fetch("assignment_id"), "attempt_id" => params.fetch("attempt_id"),
|
|
88
|
+
"project_id" => @project, "scope" => scope, "state" => "running", "started_by" => principal,
|
|
89
|
+
"base_head" => observed.fetch(:checkout).fetch("head"), "runtime_binding" => identity.runtime_binding,
|
|
90
|
+
"queue_ref" => queue_path, "queue_sha256" => queue_sha}}
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def start_digest(params, peer, identity)
|
|
96
|
+
Atoms::EvidenceDigest.digest({"params" => params, "project_id" => @project,
|
|
97
|
+
"principal" => {"uid" => peer.fetch("uid"), "actor" => identity.actor}})
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def authorize_start_replay!(request, peer, role, deadline)
|
|
101
|
+
authorize!(request, peer, role, deadline)
|
|
102
|
+
params = request.fetch("params")
|
|
103
|
+
identity = @admission.identity!(peer: peer, pane: params.fetch("pane"), deadline: deadline)
|
|
104
|
+
start_replay_owner!(params, identity)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def registered_work(registered, commit, deadline)
|
|
108
|
+
reference = registered.fetch("prepared_work")
|
|
109
|
+
bytes = @journal.bounded_blob(registered.fetch("prepared_bundle_ref"), commit: commit,
|
|
110
|
+
max_bytes: registered.fetch("prepared_bundle_bytes"))
|
|
111
|
+
work = PreparedWork.admit(bytes: bytes, head: reference.fetch("prepared_head"), tree: reference.fetch("prepared_tree"),
|
|
112
|
+
sha256: registered.fetch("prepared_bundle_sha256"), size: registered.fetch("prepared_bundle_bytes"),
|
|
113
|
+
root: @input_root, deadline: deadline)
|
|
114
|
+
unless work.reference(head: reference.fetch("prepared_head"), tree: reference.fetch("prepared_tree")) == reference &&
|
|
115
|
+
work.manifest.values_at("assignment_id", "project_id", "task_id") == registered.values_at("assignment_id", "project_id", "task_id") &&
|
|
116
|
+
Digest::SHA256.hexdigest(work.definition_bytes(head: reference.fetch("prepared_head"), tree: reference.fetch("prepared_tree"))) == registered.fetch("definition_digest")
|
|
117
|
+
raise AttemptErrors::EvidenceUnavailable, "ordinary prepared registration bytes differ"
|
|
118
|
+
end
|
|
119
|
+
work
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def start_replay_owner!(params, identity)
|
|
123
|
+
events = @journal.read_events(params.fetch("assignment_id"))
|
|
124
|
+
.select { |event| event.fetch("attempt_id") == params.fetch("attempt_id") }
|
|
125
|
+
unless Models::EvidenceEvent.chain_valid?(events) && events.count { |event| event["type"] == "process_start" } == 1
|
|
126
|
+
raise AttemptErrors::EvidenceUnavailable, "ordinary start evidence is unavailable"
|
|
127
|
+
end
|
|
128
|
+
owner = events.find { |event| event["type"] == "process_start" }.fetch("payload")
|
|
129
|
+
unless owner.values_at("actor", "role", "runtime", "runtime_binding") ==
|
|
130
|
+
[identity.actor, identity.role, identity.runtime, identity.runtime_binding]
|
|
131
|
+
raise AttemptErrors::UnauthorizedIdentity, "ordinary replay belongs to its original native worker"
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
end
|