ace-assign 0.69.0 → 0.70.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 +7 -0
- data/lib/ace/assign/atoms/assignment_scope.rb +5 -0
- data/lib/ace/assign/authority/assignment_definition.rb +35 -0
- data/lib/ace/assign/authority/candidate_transfer.rb +2 -2
- data/lib/ace/assign/authority/endcap_attempts.rb +1 -1
- 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 +94 -0
- data/lib/ace/assign/authority/ordinary_effect_settlement.rb +118 -0
- data/lib/ace/assign/authority/ordinary_lifecycle.rb +191 -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_results.rb +196 -0
- data/lib/ace/assign/authority/ordinary_server.rb +75 -0
- data/lib/ace/assign/authority/prepared_work.rb +2 -2
- data/lib/ace/assign/authority/protected_assignment_context.rb +3 -3
- 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 +2 -2
- data/lib/ace/assign/cli/commands/attempt/evidence.rb +2 -2
- 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 +141 -34
- data/lib/ace/assign/molecules/installed_journal_read.rb +143 -0
- data/lib/ace/assign/molecules/journal_mutation.rb +5 -3
- data/lib/ace/assign/molecules/lifecycle_exclusion.rb +13 -2
- data/lib/ace/assign/molecules/proposal_journal.rb +32 -4
- data/lib/ace/assign/organisms/attempt_coordinator.rb +51 -36
- data/lib/ace/assign/organisms/ordinary_merge_consumer.rb +5 -5
- data/lib/ace/assign/version.rb +1 -1
- metadata +23 -9
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 9de34921fd3a299eabfd530c8df3c76fed9405606cb0e717a0458fed9ecf614e
|
|
4
|
+
data.tar.gz: a9a2d22448d0c100b5404ce9ca9666b72ed0e6fb3d9bf158a5051ce001b703b1
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: f68696b349c145768cf15634945c0fe40a659a80394f6053b37fd4270c244d8884345dbc9ba438a257de66593a0e44d88feca77c3c2580ea0e5208221d896746
|
|
7
|
+
data.tar.gz: 07c573b0f8160c1929fbba896362180ad2bd08097ab306139ca0e5a28c00762650845840f8709e16f825660add6057b6569ce49a1f7595370c2f83a6740c4e07
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
# Usage: ace-assign create --task TASK[,TASK...] --preset deliver-release
|
|
3
3
|
|
|
4
4
|
name: deliver-release
|
|
5
|
-
description:
|
|
5
|
+
description: Deliver a bound release cut through the project-authorized publisher and installation owners
|
|
6
6
|
|
|
7
7
|
parameters:
|
|
8
8
|
taskrefs:
|
|
@@ -37,15 +37,18 @@ steps:
|
|
|
37
37
|
final artifact digest, and update the manifest before it is accepted. Preparation
|
|
38
38
|
is not publication, installation, deployment, or proof of any of those effects.
|
|
39
39
|
|
|
40
|
-
- name:
|
|
40
|
+
- name: request-authorized-publication
|
|
41
41
|
number: "040"
|
|
42
42
|
instructions: |
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
43
|
+
Resolve the concrete publication workflow and executor from project policy and
|
|
44
|
+
invoke that existing workflow with the exact accepted manifest through the separate
|
|
45
|
+
publication authority. This step grants no credentials or permission by itself.
|
|
46
|
+
Missing publisher identity, executable binding, or admission is BLOCKED(provision, owner).
|
|
47
|
+
Use the project's existing publisher, dependency order and scoped HITL OTP route.
|
|
48
|
+
Record the accepted operation reference and reconciliation path before WAIT.
|
|
49
|
+
Track published, pending and uncertain artifacts separately. Verify registry version
|
|
50
|
+
and digest before retry; never republish an already matching artifact or repeat an
|
|
51
|
+
uncertain effect blindly. Keep delivery open after partial or unknown publication.
|
|
49
52
|
|
|
50
53
|
- name: record-release-outcome
|
|
51
54
|
number: "050"
|
|
@@ -32,11 +32,16 @@ steps:
|
|
|
32
32
|
- name: delegate-renewed-evidence
|
|
33
33
|
number: "030"
|
|
34
34
|
instructions: |
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
35
|
+
Require an initial independent reviewer verdict, then record an accepted-head
|
|
36
|
+
assessment after every candidate movement. Retain old/new head and base SHAs,
|
|
37
|
+
the executed range-diff/content comparison, full repository context, risks and
|
|
38
|
+
fresh relevant tests. Proven identical rebases may carry the initial review;
|
|
39
|
+
patch-id alone is insufficient. An integrator may accept a minor safe correction
|
|
40
|
+
with recorded reasoning and tests. Material or uncertain changes, including small
|
|
41
|
+
authority/security changes, require independent delta review. Keep the initial
|
|
42
|
+
review and formal forge approval actor/state/SHA separate from this assessment.
|
|
43
|
+
Post and read back the evidence before merge; use the exact accepted head in the
|
|
44
|
+
server-side merge guard. Failed publication of evidence blocks integration.
|
|
40
45
|
|
|
41
46
|
- name: reevaluate-integration-readiness
|
|
42
47
|
number: "040"
|
|
@@ -20,8 +20,8 @@ steps:
|
|
|
20
20
|
|
|
21
21
|
These are references to producer tasks; never reparent, duplicate, reopen, or
|
|
22
22
|
change producer task status. A PR-ready worker remains terminal even while this
|
|
23
|
-
delivery assignment stays open.
|
|
24
|
-
be created. Treat an empty observation as current input only: Transiently empty
|
|
23
|
+
delivery assignment stays open. A local objective queue may store goals, next actions, blockers and references;
|
|
24
|
+
no second execution queue, journal, daemon, or cache may be created. Treat an empty observation as current input only: Transiently empty
|
|
25
25
|
is not accepted completion.
|
|
26
26
|
|
|
27
27
|
- name: supervise-delivery-queue
|
|
@@ -42,8 +42,10 @@ steps:
|
|
|
42
42
|
- policy/plan revision and evidence references used for the decision.
|
|
43
43
|
|
|
44
44
|
For every entry, evaluate dependency state, exact candidate head SHA, fresh
|
|
45
|
-
|
|
46
|
-
|
|
45
|
+
tests, initial independent review and a documented accepted-head assessment.
|
|
46
|
+
An identical rebase needs executed content-identity proof; a minor safe correction
|
|
47
|
+
needs integrator reasoning and tests; material or uncertain delta needs independent
|
|
48
|
+
delta review. Evaluate required authority, transport and grouping constraints. Re-record the decision after dependency,
|
|
47
49
|
candidate head, evidence, authority, transport, or target-branch movement.
|
|
48
50
|
|
|
49
51
|
- name: reconcile-integration-operation
|
data/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.70.0] - 2026-10-16
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- Admit ordinary assignments, native starts, progress, submitted results and terminal settlement through the installed canonical owner. Retain original mutations and immutable snapshots across restart, isolate sibling scopes, and join current HITL, service and Inbox evidence.
|
|
15
|
+
- Preserve historical review checks at their recorded head and align delivery presets with accepted-head assessment and serial integration.
|
|
16
|
+
|
|
10
17
|
## [0.69.0] - 2026-10-16
|
|
11
18
|
|
|
12
19
|
### Changed
|
|
@@ -47,6 +47,11 @@ module Ace
|
|
|
47
47
|
rescue Ace::Assign::AttemptErrors::InvalidScope
|
|
48
48
|
false
|
|
49
49
|
end
|
|
50
|
+
|
|
51
|
+
# An owner of a subtree also owns every descendant scope.
|
|
52
|
+
def self.overlap?(left, right)
|
|
53
|
+
equal?(left, right) || left.start_with?("#{right}.") || right.start_with?("#{left}.")
|
|
54
|
+
end
|
|
50
55
|
end
|
|
51
56
|
end
|
|
52
57
|
end
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
require "json"
|
|
5
|
+
require_relative "campaign_execution"
|
|
6
|
+
|
|
7
|
+
module Ace
|
|
8
|
+
module Assign
|
|
9
|
+
module Authority
|
|
10
|
+
# Shared byte/schema validation; each lifecycle separately authenticates
|
|
11
|
+
# the registration's owner, campaign provenance and original selection.
|
|
12
|
+
module AssignmentDefinition
|
|
13
|
+
def self.parse(bytes:, digest:, assignment_id:, project_id:)
|
|
14
|
+
unless bytes.is_a?(String) && bytes.bytesize <= 32_768 && digest.is_a?(String) &&
|
|
15
|
+
Digest::SHA256.hexdigest(bytes) == digest
|
|
16
|
+
raise ArgumentError, "invalid assignment definition digest or size"
|
|
17
|
+
end
|
|
18
|
+
value = JSON.parse(bytes)
|
|
19
|
+
allowed = %w[session_id name description created_at updated_at source_config parent task_id project_id prepared_work review_campaign campaign_execution]
|
|
20
|
+
unless value.is_a?(Hash) && (value.keys - allowed).empty? &&
|
|
21
|
+
%w[session_id name created_at source_config task_id project_id].all? { |key| value[key].is_a?(String) && !value[key].empty? } &&
|
|
22
|
+
value["session_id"] == assignment_id && value["project_id"] == project_id
|
|
23
|
+
raise ArgumentError, "invalid managed assignment definition"
|
|
24
|
+
end
|
|
25
|
+
CampaignExecution.validate_parent!(value.fetch("review_campaign")) if value.key?("review_campaign")
|
|
26
|
+
assignment = Models::Assignment.from_h(value)
|
|
27
|
+
unless assignment.managed? && assignment.task_id.match?(/\A[a-zA-Z0-9][a-zA-Z0-9_.-]{0,127}\z/)
|
|
28
|
+
raise ArgumentError, "definition is not managed or task identity is invalid"
|
|
29
|
+
end
|
|
30
|
+
assignment
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -115,14 +115,14 @@ module Ace
|
|
|
115
115
|
reject!("Prepared export filesystem is unavailable")
|
|
116
116
|
end
|
|
117
117
|
|
|
118
|
-
def admit(bytes:, sha256:, size:, head:, &consumer)
|
|
118
|
+
def admit(bytes:, sha256:, size:, head:, deadline: nil, &consumer)
|
|
119
119
|
unless bytes.is_a?(String) && size.is_a?(Integer) && size.positive? && size <= MAX_BYTES &&
|
|
120
120
|
size == bytes.bytesize && sha256.is_a?(String) &&
|
|
121
121
|
Digest::SHA256.hexdigest(bytes.b) == sha256 && head.is_a?(String) && head.match?(SHA)
|
|
122
122
|
reject!("Candidate bundle binding or size is invalid")
|
|
123
123
|
end
|
|
124
124
|
verify_root!
|
|
125
|
-
deadline = monotonic + DEADLINE
|
|
125
|
+
deadline = deadline ? [deadline, monotonic + DEADLINE].min : monotonic + DEADLINE
|
|
126
126
|
Dir.mktmpdir("quarantine-", @root) do |directory|
|
|
127
127
|
File.chmod(0700, directory)
|
|
128
128
|
bundle = File.join(directory, "candidate.bundle")
|
|
@@ -179,7 +179,7 @@ module Ace
|
|
|
179
179
|
@launch.campaign_children_settled!(journal: selected, commit: prefix, params: params, map: map)
|
|
180
180
|
@launch.completion_scope!(journal: selected, events: fresh, params: params, map: map, commit: prefix)
|
|
181
181
|
service_settlement_evidence!(journal: selected, events: fresh, params: params, map: map, commit: prefix)
|
|
182
|
-
plan = Organisms::AttemptCoordinator.finished_transition_plan(events: fresh, receipt: result.fetch("receipt"))
|
|
182
|
+
plan = Organisms::AttemptCoordinator.finished_transition_plan(events: fresh, receipt: result.fetch("receipt"), authority: :protected)
|
|
183
183
|
plan.merge(data: params.slice("attempt_id", "result_id", "head", "candidate_generation").merge(
|
|
184
184
|
"receipt_digest" => result.fetch("receipt_digest"), "state" => result.dig("receipt", "verdict")))
|
|
185
185
|
end
|
|
@@ -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
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "ordinary_peer"
|
|
4
|
+
require_relative "transfer_codec"
|
|
5
|
+
|
|
6
|
+
module Ace
|
|
7
|
+
module Assign
|
|
8
|
+
module Authority
|
|
9
|
+
# Installed selection is supplied by the caller's trusted composition.
|
|
10
|
+
# This client never selects a journal, local fallback or execution identity.
|
|
11
|
+
class OrdinaryClient
|
|
12
|
+
Reply = Struct.new(:data, :replayed, :parts, keyword_init: true)
|
|
13
|
+
|
|
14
|
+
def initialize(project_id:, service:, verify_selection:, peer: OrdinaryPeer.new, scratch_root: nil)
|
|
15
|
+
@project_id, @service, @verify_selection, @peer, @scratch_root = project_id, service, verify_selection, peer, scratch_root
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def call(operation, params, mutation_id: nil, upload_parts: nil, download: false, purpose: nil, timeout: 30)
|
|
19
|
+
unless operation.is_a?(String) && params.is_a?(Hash) && !params.key?("transfer") &&
|
|
20
|
+
timeout.is_a?(Numeric) && timeout.finite? && timeout.positive?
|
|
21
|
+
raise ArgumentError, "invalid ordinary authority request"
|
|
22
|
+
end
|
|
23
|
+
raise ArgumentError, "ordinary download needs private scratch and forbids upload" if download && (upload_parts || !@scratch_root)
|
|
24
|
+
@verify_selection.call
|
|
25
|
+
wire = Ace::Runtime::Molecules::ProtectedSocket
|
|
26
|
+
path = @service.fetch("socket_path")
|
|
27
|
+
wire.root_path!(File.dirname(path), directory: true, owner: @service.fetch("uid"))
|
|
28
|
+
before = wire.socket_identity(path, mode: 0o660)
|
|
29
|
+
raise AttemptErrors::UnauthorizedIdentity, "ordinary endpoint owner differs" unless before.last == @service.fetch("uid")
|
|
30
|
+
codec = TransferCodec.new(root: download ? @scratch_root : nil)
|
|
31
|
+
descriptor = codec.descriptor(upload_parts, purpose: purpose) if upload_parts
|
|
32
|
+
parameters = descriptor ? params.merge("transfer" => descriptor) : params
|
|
33
|
+
deadline = wire.deadline([timeout, 30].min)
|
|
34
|
+
wire.connect(path, deadline: deadline) do |socket|
|
|
35
|
+
observed = @peer.capture(socket, deadline: deadline)
|
|
36
|
+
unless observed.values_at("uid", "gid") == @service.values_at("uid", "gid") && wire.socket_identity(path, mode: 0o660) == before
|
|
37
|
+
raise AttemptErrors::UnauthorizedIdentity, "ordinary endpoint peer changed"
|
|
38
|
+
end
|
|
39
|
+
@verify_selection.call
|
|
40
|
+
wire.write(socket, {"version" => 1, "operation" => operation, "mutation_id" => mutation_id,
|
|
41
|
+
"project_id" => @project_id, "params" => parameters}, deadline: deadline, limit: 16_384)
|
|
42
|
+
codec.send(socket, parts: upload_parts, descriptor: descriptor, purpose: purpose, deadline: deadline) if upload_parts
|
|
43
|
+
socket.shutdown(Socket::SHUT_WR)
|
|
44
|
+
ingress = wire::Ingress.new(socket)
|
|
45
|
+
result = wire.read(ingress, deadline: deadline, limit: 16_384)
|
|
46
|
+
unless result.is_a?(Hash) && result.keys.sort == %w[data status transport] && result["status"] == "ok" &&
|
|
47
|
+
result["data"].is_a?(Hash) && result["transport"].is_a?(Hash) &&
|
|
48
|
+
result["transport"].keys == ["replayed"] && [true, false].include?(result["transport"]["replayed"])
|
|
49
|
+
code = result.is_a?(Hash) && result["error"].is_a?(Hash) ? result["error"]["code"] : nil
|
|
50
|
+
raise AttemptErrors::EvidenceUnavailable, "ordinary authority refused (#{code || 'invalid_response'}); retain original mutation"
|
|
51
|
+
end
|
|
52
|
+
parts = if download
|
|
53
|
+
codec.receive(ingress, descriptor: result.fetch("data").fetch("transfer"), purpose: purpose, deadline: deadline) do |input|
|
|
54
|
+
Array.new(input.count) { |index| input.bytes(index: index) }
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
@verify_selection.call
|
|
58
|
+
@peer.live!(observed, deadline: deadline)
|
|
59
|
+
unless wire.socket_identity(path, mode: 0o660) == before
|
|
60
|
+
raise AttemptErrors::UnauthorizedIdentity, "ordinary endpoint changed before response acceptance"
|
|
61
|
+
end
|
|
62
|
+
Reply.new(data: result.fetch("data"), replayed: result.fetch("transport").fetch("replayed"), parts: parts)
|
|
63
|
+
end
|
|
64
|
+
rescue SystemCallError, IOError, Ace::Runtime::RuntimeUnavailableError
|
|
65
|
+
raise AttemptErrors::EvidenceUnavailable, "ordinary authority outcome unavailable; retain original mutation; no local fallback"
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "ordinary_effect_settlement"
|
|
4
|
+
require_relative "../organisms/attempt_coordinator"
|
|
5
|
+
|
|
6
|
+
module Ace
|
|
7
|
+
module Assign
|
|
8
|
+
module Authority
|
|
9
|
+
# Terminal acceptance consumes the submitted canonical receipt. The
|
|
10
|
+
# supervisor cannot replace its bytes, worker, scope or candidate.
|
|
11
|
+
module OrdinaryCompletion
|
|
12
|
+
FINISH_KEYS = %w[assignment_id attempt_id expected_generation pane head result_id receipt_digest].freeze
|
|
13
|
+
|
|
14
|
+
private
|
|
15
|
+
|
|
16
|
+
def finish_result(request, peer, role, deadline)
|
|
17
|
+
params = request.fetch("params")
|
|
18
|
+
strict!(params, FINISH_KEYS)
|
|
19
|
+
%w[assignment_id attempt_id result_id].each { |key| token!(params.fetch(key)) }
|
|
20
|
+
token!(request.fetch("mutation_id"))
|
|
21
|
+
unless role == :supervisor
|
|
22
|
+
raise AttemptErrors::UnauthorizedIdentity, "ordinary completion requires the installed supervisor"
|
|
23
|
+
end
|
|
24
|
+
unless params.fetch("expected_generation").is_a?(Integer) && params.fetch("expected_generation").positive? &&
|
|
25
|
+
params.fetch("head").is_a?(String) && params.fetch("head").match?(/\A[0-9a-f]{40}\z/) &&
|
|
26
|
+
params.fetch("receipt_digest").is_a?(String) && params.fetch("receipt_digest").match?(/\A[0-9a-f]{64}\z/)
|
|
27
|
+
raise ArgumentError, "ordinary completion selection is invalid"
|
|
28
|
+
end
|
|
29
|
+
registered = registration(params.fetch("assignment_id"), commit: @journal.ref_value)
|
|
30
|
+
raise AttemptErrors::NotFound, "ordinary work is not registered" unless registered
|
|
31
|
+
digest = Atoms::EvidenceDigest.digest({"params" => params, "project_id" => @project, "peer_uid" => peer.fetch("uid")})
|
|
32
|
+
@exclusion.with_shared_multi([@exclusion.task_key(registered.fetch("task_id"))], deadline: deadline) do
|
|
33
|
+
@exclusion.with_exclusive(@exclusion.assignment_key(params.fetch("assignment_id")), deadline: deadline) do
|
|
34
|
+
authorize!(request, peer, role, deadline)
|
|
35
|
+
with_result_transport(request, deadline) do |transport|
|
|
36
|
+
@journal.mutate(assignment_id: params.fetch("assignment_id"), attempt_id: params.fetch("attempt_id"),
|
|
37
|
+
mutation_id: request.fetch("mutation_id"), operation: "finish", parameters_digest: digest,
|
|
38
|
+
expected_generation: params.fetch("expected_generation"), with_replay: true, deadline: deadline,
|
|
39
|
+
replay_authorizer: -> { authorize!(request, peer, role, deadline) }) do |events, prefix, _generation|
|
|
40
|
+
authorize!(request, peer, role, deadline)
|
|
41
|
+
unless Molecules::CanonicalAttemptState.derive(events) == "running"
|
|
42
|
+
raise AttemptErrors::Conflict, "ordinary completion requires a running attempt"
|
|
43
|
+
end
|
|
44
|
+
result = retained_ordinary_result(params, events, prefix)
|
|
45
|
+
unless result && result.fetch("result_id") == params.fetch("result_id") &&
|
|
46
|
+
result.dig("receipt", "digest") == params.fetch("receipt_digest") && result.dig("binding", "head") == params.fetch("head")
|
|
47
|
+
raise AttemptErrors::ReceiptRejected, "ordinary completion requires its exact submitted result"
|
|
48
|
+
end
|
|
49
|
+
binding = result.fetch("binding")
|
|
50
|
+
observed = @admission.recorded_execution!(binding: binding.fetch("runtime_binding"), head: binding.fetch("head"), deadline: deadline)
|
|
51
|
+
unless observed.fetch(:runtime_binding) == binding.fetch("runtime_binding") && observed.fetch(:checkout) == binding.fetch("checkout")
|
|
52
|
+
raise AttemptErrors::UnauthorizedIdentity, "ordinary result worker checkout changed"
|
|
53
|
+
end
|
|
54
|
+
references = events.select { |event| event["type"] == "inbox_binding" }.map { |event| event.fetch("payload") }
|
|
55
|
+
delivery = transport.call(references: references, attempt_id: params.fetch("attempt_id"), owner: binding.fetch("runtime_binding"))
|
|
56
|
+
scope = events.find { |event| event["type"] == "intent" }.fetch("payload").fetch("scope")
|
|
57
|
+
work = registered_work(registered, prefix, deadline)
|
|
58
|
+
queue = OrdinaryQueue.new(work: work, root: @input_root, bytes: queue_bytes(current_queue_record(registered, prefix), prefix))
|
|
59
|
+
verdict = result.dig("receipt", "verdict")
|
|
60
|
+
complete = verdict == "succeeded" ? queue.status.subtree_complete?(scope) : queue.status.subtree_failed?(scope)
|
|
61
|
+
raise AttemptErrors::Conflict, "ordinary result scope has unfinished progress" unless complete
|
|
62
|
+
settlement = OrdinaryEffectSettlement.new(journal: @journal).verify!(project: @project,
|
|
63
|
+
assignment: params.fetch("assignment_id"), attempt: params.fetch("attempt_id"), events: events, commit: prefix)
|
|
64
|
+
authorize!(request, peer, role, deadline)
|
|
65
|
+
plan = Organisms::AttemptCoordinator.finished_transition_plan(events: events, receipt: result.fetch("receipt"), authority: :ordinary)
|
|
66
|
+
plan.merge(data: params.slice("assignment_id", "attempt_id", "result_id", "head", "receipt_digest").merge(
|
|
67
|
+
"authority" => "ordinary", "scope" => scope, "state" => verdict, "settlement" => settlement, "inbox_deliveries" => delivery))
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
rescue Ace::Herdr::Error => error
|
|
73
|
+
raise AttemptErrors::EvidenceUnavailable, "ordinary transport settlement is unavailable: #{error.message}"
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def with_result_transport(request, deadline)
|
|
77
|
+
if @journal.mutation_result(request.fetch("mutation_id"), commit: @journal.ref_value)
|
|
78
|
+
# A terminal reply is immutable. Current peer authority is still
|
|
79
|
+
# checked by mutate, but retired transport files are not replay input.
|
|
80
|
+
return yield nil
|
|
81
|
+
end
|
|
82
|
+
if @inbox
|
|
83
|
+
@inbox.with_delivery_settlement(deadline: deadline) { |verify| yield verify }
|
|
84
|
+
else
|
|
85
|
+
yield lambda { |references:, **_|
|
|
86
|
+
raise AttemptErrors::EvidenceUnavailable, "ordinary Inbox owner is not installed" unless references.empty?
|
|
87
|
+
[]
|
|
88
|
+
}
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|