ace-assign 0.63.1 → 0.64.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/create-pr.step.yml +2 -2
- data/.ace-defaults/assign/catalog/steps/mark-pr-ready.step.yml +1 -1
- data/.ace-defaults/assign/catalog/steps/release-minor.step.yml +4 -2
- data/.ace-defaults/assign/catalog/steps/release.step.yml +4 -2
- data/.ace-defaults/assign/catalog/steps/update-pr-desc.step.yml +1 -1
- data/.ace-defaults/assign/presets/fix-bug.yml +15 -3
- data/.ace-defaults/assign/presets/work-on-task-ace-development.yml +16 -4
- data/.ace-defaults/assign/presets/work-on-task-auto-merge.yml +15 -3
- data/.ace-defaults/assign/presets/work-on-task.yml +16 -4
- data/CHANGELOG.md +43 -0
- data/docs/demo/canonical-skill-source.tape.yml +4 -4
- data/docs/protected-authority.md +266 -0
- data/docs/usage.md +91 -2
- data/handbook/workflow-instructions/assign/drive.wf.md +2 -2
- data/handbook/workflow-instructions/assign/prepare.wf.md +2 -2
- data/lib/ace/assign/atoms/delivery_parameters.rb +54 -0
- data/lib/ace/assign/authority/candidate_transfer.rb +160 -0
- data/lib/ace/assign/authority/client.rb +107 -0
- data/lib/ace/assign/authority/deployment.rb +465 -0
- data/lib/ace/assign/authority/endcap.rb +342 -0
- data/lib/ace/assign/authority/endcap_inboxes.rb +314 -0
- data/lib/ace/assign/authority/endcap_results.rb +380 -0
- data/lib/ace/assign/authority/endcap_services.rb +395 -0
- data/lib/ace/assign/authority/evidence_transfer.rb +67 -0
- data/lib/ace/assign/authority/execution_scope_observation.rb +476 -0
- data/lib/ace/assign/authority/launch_driver.rb +114 -0
- data/lib/ace/assign/authority/launch_lifecycle.rb +822 -0
- data/lib/ace/assign/authority/launch_scope_abort.rb +88 -0
- data/lib/ace/assign/authority/launch_scope_admission.rb +84 -0
- data/lib/ace/assign/authority/launch_scope_close.rb +115 -0
- data/lib/ace/assign/authority/launch_scope_parent.rb +44 -0
- data/lib/ace/assign/authority/launch_scope_release.rb +180 -0
- data/lib/ace/assign/authority/posix_acl.rb +56 -0
- data/lib/ace/assign/authority/private_directory.rb +33 -0
- data/lib/ace/assign/authority/receipt_transfer.rb +48 -0
- data/lib/ace/assign/authority/router.rb +54 -0
- data/lib/ace/assign/authority/server.rb +230 -0
- data/lib/ace/assign/authority/service_evidence.rb +55 -0
- data/lib/ace/assign/authority/transfer_codec.rb +155 -0
- data/lib/ace/assign/cli/commands/authority/launch.rb +45 -0
- data/lib/ace/assign/cli/commands/authority/serve.rb +30 -0
- data/lib/ace/assign/cli/commands/authority/status.rb +26 -0
- data/lib/ace/assign/cli/commands/authority/terminate.rb +29 -0
- data/lib/ace/assign/cli/commands/create.rb +9 -1
- data/lib/ace/assign/cli/commands/delivery.rb +57 -0
- data/lib/ace/assign/cli/commands/inbox_reconcile.rb +36 -0
- data/lib/ace/assign/cli/commands/resume.rb +26 -0
- data/lib/ace/assign/cli.rb +22 -1
- data/lib/ace/assign/models/evidence_event.rb +3 -1
- data/lib/ace/assign/molecules/attempt_reconciler.rb +31 -12
- data/lib/ace/assign/molecules/canonical_evidence.rb +150 -0
- data/lib/ace/assign/molecules/evidence_journal.rb +218 -63
- data/lib/ace/assign/molecules/execution_identity_resolver.rb +49 -9
- data/lib/ace/assign/molecules/execution_scope_lineage.rb +327 -0
- data/lib/ace/assign/molecules/journal_mutation.rb +202 -0
- data/lib/ace/assign/molecules/lifecycle_exclusion.rb +4 -0
- data/lib/ace/assign/molecules/proposal_journal.rb +151 -0
- data/lib/ace/assign/molecules/receipt_verifier.rb +49 -19
- data/lib/ace/assign/organisms/assignment_executor.rb +6 -0
- data/lib/ace/assign/organisms/attempt_coordinator.rb +312 -13
- data/lib/ace/assign/organisms/delivery_coordinator.rb +342 -0
- data/lib/ace/assign/organisms/task_assignment_creator.rb +8 -3
- data/lib/ace/assign/version.rb +1 -1
- data/lib/ace/assign.rb +13 -0
- metadata +107 -8
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "securerandom"
|
|
4
|
+
|
|
5
|
+
module Ace
|
|
6
|
+
module Assign
|
|
7
|
+
module Molecules
|
|
8
|
+
# A decision projection of the existing qjl event chain. No effect
|
|
9
|
+
# executor, extra ref, file registry, or claim ledger lives here.
|
|
10
|
+
module ProposalJournal
|
|
11
|
+
PROPOSAL_REFERENCE = /\Aproposal-[0-9a-f]{24}-r[1-9][0-9]*\z/
|
|
12
|
+
PROPOSAL_BINDING = %w[operation project_id assignment_id attempt_id input_digest target candidate_head caller_uid].freeze
|
|
13
|
+
|
|
14
|
+
PROPOSAL_IMMUTABLE = (PROPOSAL_BINDING + %w[schema proposal_id revision revision_id request_id requester content_digest created_at authorization context options recommendation rationale prerequisites lifecycle_request revision_operation_id expected_source_revision]).freeze
|
|
15
|
+
|
|
16
|
+
def proposal_history(id)
|
|
17
|
+
assignment_ids.flat_map do |assignment|
|
|
18
|
+
events = read_events(assignment)
|
|
19
|
+
events.group_by { |event| event["attempt_id"] }.each_value do |chain|
|
|
20
|
+
unless Models::EvidenceEvent.chain_valid?(chain)
|
|
21
|
+
raise AttemptErrors::EvidenceUnavailable, "Proposal evidence chain is corrupt"
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
events.select { |event| event["type"] == "proposal_state" && event.dig("payload", "record", "proposal_id") == id }
|
|
25
|
+
.map { |event| event.fetch("payload").fetch("record") }
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def proposal_record(id)
|
|
30
|
+
proposal_history(id).last
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def proposals
|
|
34
|
+
assignment_ids.flat_map do |assignment|
|
|
35
|
+
read_events(assignment).select { |event| event["type"] == "proposal_state" }
|
|
36
|
+
.map { |event| event.dig("payload", "record", "proposal_id") }
|
|
37
|
+
end.uniq.map { |id| proposal_record(id) }
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def proposal_operation_record(operation_id)
|
|
41
|
+
proposals.each do |record|
|
|
42
|
+
found = proposal_history(record["proposal_id"]).reverse.find do |entry|
|
|
43
|
+
entry["revision_operation_id"] == operation_id
|
|
44
|
+
end
|
|
45
|
+
return found if found
|
|
46
|
+
end
|
|
47
|
+
nil
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Admitted HITL policy runs this block while the same canonical
|
|
51
|
+
# mutation lock/CAS boundary excludes service claims and other replies.
|
|
52
|
+
def change_proposal(id, assignment_id:, attempt_id:, &policy)
|
|
53
|
+
3.times do
|
|
54
|
+
events = read_events(assignment_id).select { |event| event["attempt_id"] == attempt_id }
|
|
55
|
+
generation = events.count { |event| event["type"] == "authority_mutation" }
|
|
56
|
+
begin
|
|
57
|
+
return mutate(assignment_id: assignment_id, attempt_id: attempt_id,
|
|
58
|
+
mutation_id: "proposal-#{SecureRandom.hex(16)}", operation: "proposal-decision",
|
|
59
|
+
parameters_digest: Digest::SHA256.hexdigest(id), expected_generation: generation) do |current, _commit, _generation|
|
|
60
|
+
record = current.reverse.find do |event|
|
|
61
|
+
event["type"] == "proposal_state" && event.dig("payload", "record", "proposal_id") == id
|
|
62
|
+
end&.dig("payload", "record")
|
|
63
|
+
global = proposal_record(id)
|
|
64
|
+
if global && (global["assignment_id"] != assignment_id || global["attempt_id"] != attempt_id)
|
|
65
|
+
raise AttemptErrors::ReceiptRejected, "Proposal identity belongs to another assignment or attempt"
|
|
66
|
+
end
|
|
67
|
+
updated = policy.call(record)
|
|
68
|
+
return record if updated == record
|
|
69
|
+
unless updated.is_a?(Hash) && updated["proposal_id"] == id &&
|
|
70
|
+
updated["assignment_id"] == assignment_id && updated["attempt_id"] == attempt_id
|
|
71
|
+
raise AttemptErrors::ReceiptRejected, "Proposal policy changed immutable ownership"
|
|
72
|
+
end
|
|
73
|
+
validate_proposal_revision!(record, updated)
|
|
74
|
+
entries = []
|
|
75
|
+
if record && record["revision_id"] != updated["revision_id"]
|
|
76
|
+
entries << {type: "proposal_state", payload: {"record" => record.merge("state" => "superseded", "resolution" => "revised")}}
|
|
77
|
+
end
|
|
78
|
+
if record.nil? || record["revision_id"] != updated["revision_id"]
|
|
79
|
+
entries << {type: "proposal_state", payload: {"record" => updated.merge("state" => "proposed")}}
|
|
80
|
+
end
|
|
81
|
+
entries << {type: "proposal_state", payload: {"record" => updated}}
|
|
82
|
+
{events: entries, data: {"record" => updated}}
|
|
83
|
+
end.fetch("record")
|
|
84
|
+
rescue AttemptErrors::Conflict => e
|
|
85
|
+
raise unless e.message == "Authority registration generation changed"
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
raise AttemptErrors::EvidenceUnavailable, "Proposal generation stayed conflicting"
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def validate_proposal_revision!(previous, updated)
|
|
92
|
+
if previous && previous["revision_id"] == updated["revision_id"]
|
|
93
|
+
unless PROPOSAL_IMMUTABLE.all? { |field| previous[field] == updated[field] }
|
|
94
|
+
raise AttemptErrors::ReceiptRejected, "Proposal revision content is immutable"
|
|
95
|
+
end
|
|
96
|
+
return
|
|
97
|
+
end
|
|
98
|
+
expected = previous ? previous.fetch("revision") + 1 : 1
|
|
99
|
+
reference = "#{updated.fetch("proposal_id")}-r#{expected}"
|
|
100
|
+
valid = updated["revision"] == expected && updated["revision_id"] == reference &&
|
|
101
|
+
updated["request_id"] == reference && updated["authorization"] == reference &&
|
|
102
|
+
updated["state"] == "awaiting-delivery" && PROPOSAL_REFERENCE.match?(reference)
|
|
103
|
+
if previous
|
|
104
|
+
valid &&= updated["expected_source_revision"] == previous["revision"] &&
|
|
105
|
+
updated["revision_operation_id"].is_a?(String) &&
|
|
106
|
+
updated["revision_operation_id"].match?(/\Arevision-[0-9a-f]{24}\z/) &&
|
|
107
|
+
proposal_operation_record(updated["revision_operation_id"]).nil? &&
|
|
108
|
+
%w[assignment_id attempt_id project_id requester caller_uid].all? { |field| previous[field] == updated[field] }
|
|
109
|
+
else
|
|
110
|
+
valid &&= updated["revision_operation_id"].nil? && updated["expected_source_revision"].nil?
|
|
111
|
+
end
|
|
112
|
+
unresolved = previous && proposal_claims(previous["revision_id"]).any? do |claim|
|
|
113
|
+
claim["state"] != "succeeded" &&
|
|
114
|
+
!(claim["state"] == "failed-settled" && settlement_evidence_intact?(claim))
|
|
115
|
+
end
|
|
116
|
+
raise AttemptErrors::ReceiptRejected, "Claimed proposal cannot be revised; reconcile the existing effect" if unresolved
|
|
117
|
+
raise AttemptErrors::ReceiptRejected, "Proposal revision must begin with exact immutable delivery scope" unless valid
|
|
118
|
+
end
|
|
119
|
+
private :validate_proposal_revision!
|
|
120
|
+
|
|
121
|
+
def proposal_claims(reference)
|
|
122
|
+
service_request_records.select do |request|
|
|
123
|
+
request["authorization"] == reference &&
|
|
124
|
+
!(request["state"] == "rejected" && request["consumed"] == false)
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def proposal_authorize!(reference, binding)
|
|
129
|
+
unless reference.is_a?(String) && PROPOSAL_REFERENCE.match?(reference)
|
|
130
|
+
raise AttemptErrors::ReceiptRejected, "Invalid proposal authorization reference"
|
|
131
|
+
end
|
|
132
|
+
id = reference.sub(/-r[1-9][0-9]*\z/, "")
|
|
133
|
+
record = proposal_record(id)
|
|
134
|
+
unless record && record["revision_id"] == reference &&
|
|
135
|
+
record["stop_requested"] != true &&
|
|
136
|
+
%w[approved-explicitly approved-by-silence].include?(record["state"]) &&
|
|
137
|
+
PROPOSAL_BINDING.all? { |field| record[field] == binding[field] }
|
|
138
|
+
raise AttemptErrors::ReceiptRejected, "Proposal is not authorized for this exact effect"
|
|
139
|
+
end
|
|
140
|
+
attempt = derived_attempts(binding.fetch("assignment_id")).find do |candidate|
|
|
141
|
+
candidate.attempt_id == binding.fetch("attempt_id")
|
|
142
|
+
end
|
|
143
|
+
unless attempt && attempt.state == "running"
|
|
144
|
+
raise AttemptErrors::ReceiptRejected, "Proposal effect needs an authoritative bound running attempt"
|
|
145
|
+
end
|
|
146
|
+
record
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
end
|
|
@@ -22,8 +22,14 @@ module Ace
|
|
|
22
22
|
APPROVED_VERDICT = "approved"
|
|
23
23
|
|
|
24
24
|
# @param identity_resolver [ExecutionIdentityResolver] Trust boundary
|
|
25
|
-
|
|
25
|
+
# @param artifact_reader [#call, nil] Source-owned canonical byte
|
|
26
|
+
# reader for protected composition; nil selects explicit local files
|
|
27
|
+
def initialize(identity_resolver: nil, artifact_reader: nil)
|
|
26
28
|
@identity_resolver = identity_resolver || ExecutionIdentityResolver.new
|
|
29
|
+
unless artifact_reader.nil? || artifact_reader.respond_to?(:call)
|
|
30
|
+
raise ArgumentError, "artifact reader must be a source-owned callable"
|
|
31
|
+
end
|
|
32
|
+
@artifact_reader = artifact_reader
|
|
27
33
|
end
|
|
28
34
|
|
|
29
35
|
# Validate a submitted receipt against an attempt.
|
|
@@ -36,6 +42,18 @@ module Ace
|
|
|
36
42
|
# @return [Models::ExecutionReceipt] Normalized receipt with digest
|
|
37
43
|
# @raise [AttemptErrors::ReceiptRejected] on any validation failure
|
|
38
44
|
def verify!(data, attempt:, identity:, live_head:, repo_root:)
|
|
45
|
+
verify_receipt!(data, attempt: attempt, live_head: live_head, repo_root: repo_root) do
|
|
46
|
+
verify_authority(data, identity)
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Submission validates evidence without granting terminal acceptance.
|
|
51
|
+
# The authenticated owner separately checks the exact worker attribution.
|
|
52
|
+
def verify_result!(data, attempt:, live_head:, repo_root:)
|
|
53
|
+
verify_receipt!(data, attempt: attempt, live_head: live_head, repo_root: repo_root)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def verify_receipt!(data, attempt:, live_head:, repo_root:)
|
|
39
57
|
reject_unless(data.is_a?(Hash), "receipt must be a JSON object")
|
|
40
58
|
|
|
41
59
|
if Models::ExecutionReceipt.forbidden_field?(data)
|
|
@@ -51,7 +69,7 @@ module Ace
|
|
|
51
69
|
|
|
52
70
|
verify_binding(data, attempt)
|
|
53
71
|
verify_producer(data)
|
|
54
|
-
|
|
72
|
+
yield if block_given?
|
|
55
73
|
verify_head(data, live_head)
|
|
56
74
|
verify_artifacts(data, repo_root) if data["verdict"] == "succeeded"
|
|
57
75
|
verify_checks(data)
|
|
@@ -63,18 +81,22 @@ module Ace
|
|
|
63
81
|
|
|
64
82
|
receipt
|
|
65
83
|
end
|
|
84
|
+
private :verify_receipt!
|
|
85
|
+
|
|
66
86
|
# Recheck the source artifacts/checks of an already accepted receipt.
|
|
67
|
-
#
|
|
87
|
+
# Local historical reads (recorded at a past head) validate acceptance from
|
|
68
88
|
# the append-only journal without re-hashing artifacts: the review
|
|
69
89
|
# lifecycle legitimately archives and annotates finding files after
|
|
70
90
|
# collection, and `.ace-local` working files are disposable, so their
|
|
71
91
|
# current existence proves nothing about an accepted past receipt.
|
|
72
|
-
# Current-head reads keep full artifact re-verification.
|
|
92
|
+
# Current-head reads keep full artifact re-verification. Protected
|
|
93
|
+
# readers always reverify imported bytes, including historical reads.
|
|
73
94
|
def verify_accepted_evidence!(data, live_head:, repo_root:, historical: false)
|
|
74
95
|
verify_head(data, live_head)
|
|
75
|
-
verify_artifacts(data, repo_root)
|
|
96
|
+
verify_artifacts(data, repo_root) if @artifact_reader || !historical
|
|
76
97
|
verify_checks(data)
|
|
77
98
|
verify_review(data)
|
|
99
|
+
verify_campaign(data, repo_root: repo_root, live_head: live_head) if @artifact_reader
|
|
78
100
|
end
|
|
79
101
|
|
|
80
102
|
# @param operation [String] Receipt operation
|
|
@@ -147,17 +169,7 @@ module Ace
|
|
|
147
169
|
recorded = artifact["sha256"].to_s
|
|
148
170
|
reject("artifact missing path or sha256") if path.empty? || recorded.empty?
|
|
149
171
|
|
|
150
|
-
|
|
151
|
-
File.expand_path(path, File.realpath(repo_root))
|
|
152
|
-
rescue Errno::ENOENT, Errno::EACCES
|
|
153
|
-
nil
|
|
154
|
-
end
|
|
155
|
-
reject("artifact file not found: #{path}") if expanded.nil? || !File.exist?(expanded)
|
|
156
|
-
|
|
157
|
-
resolved = safe_resolve(repo_root, path)
|
|
158
|
-
reject("artifact path escapes project root: #{path}") if resolved.nil?
|
|
159
|
-
|
|
160
|
-
actual = Digest::SHA256.hexdigest(File.read(resolved))
|
|
172
|
+
actual = Digest::SHA256.hexdigest(artifact_bytes(data, artifact, repo_root))
|
|
161
173
|
reject("artifact digest mismatch for #{path}") unless actual == recorded
|
|
162
174
|
end
|
|
163
175
|
end
|
|
@@ -225,9 +237,7 @@ module Ace
|
|
|
225
237
|
unless Array(data["artifacts"]).include?(reference)
|
|
226
238
|
reject("campaign result must be included in verified receipt artifacts")
|
|
227
239
|
end
|
|
228
|
-
|
|
229
|
-
reject("campaign result path escapes repository or is missing") unless resolved
|
|
230
|
-
result = JSON.parse(File.read(resolved))
|
|
240
|
+
result = JSON.parse(artifact_bytes(data, reference, repo_root))
|
|
231
241
|
reject_unless(result.is_a?(Hash), "campaign result must be an object")
|
|
232
242
|
require "ace/review"
|
|
233
243
|
current = Ace::Review::Organisms::CampaignManager.new(repo_root: repo_root).status(campaign["id"])
|
|
@@ -247,6 +257,26 @@ module Ace
|
|
|
247
257
|
reject("invalid campaign result: #{e.message}")
|
|
248
258
|
end
|
|
249
259
|
|
|
260
|
+
# Protected composition injects the canonical imported-byte owner.
|
|
261
|
+
# Its failure never falls through to the disposable workspace copy.
|
|
262
|
+
def artifact_bytes(data, artifact, repo_root)
|
|
263
|
+
if @artifact_reader
|
|
264
|
+
bytes = @artifact_reader.call(data, artifact)
|
|
265
|
+
reject("canonical artifact reader did not return bytes") unless bytes.is_a?(String)
|
|
266
|
+
return bytes.b
|
|
267
|
+
end
|
|
268
|
+
path = artifact["path"].to_s
|
|
269
|
+
expanded = begin
|
|
270
|
+
File.expand_path(path, File.realpath(repo_root))
|
|
271
|
+
rescue Errno::ENOENT, Errno::EACCES
|
|
272
|
+
nil
|
|
273
|
+
end
|
|
274
|
+
reject("artifact file not found: #{path}") if expanded.nil? || !File.exist?(expanded)
|
|
275
|
+
resolved = safe_resolve(repo_root, path)
|
|
276
|
+
reject("artifact path escapes project root: #{path}") if resolved.nil?
|
|
277
|
+
File.read(resolved)
|
|
278
|
+
end
|
|
279
|
+
|
|
250
280
|
# Resolve a project-relative artifact path to its real location,
|
|
251
281
|
# rejecting traversal and symlink escapes (an in-repo symlink must
|
|
252
282
|
# not smuggle in an external file); returns nil when the path leaves
|
|
@@ -68,6 +68,12 @@ module Ace
|
|
|
68
68
|
|
|
69
69
|
assignment_config = config["assignment"] || {}
|
|
70
70
|
steps_config = config["steps"] || []
|
|
71
|
+
if config.key?("delivery")
|
|
72
|
+
Atoms::DeliveryParameters.validate(config["delivery"])
|
|
73
|
+
unless Array(steps_config).any? { |step| %w[create-pr update-pr-desc mark-pr-ready].include?(step["name"]) }
|
|
74
|
+
raise ArgumentError, "Explicit delivery inputs require remote steps"
|
|
75
|
+
end
|
|
76
|
+
end
|
|
71
77
|
|
|
72
78
|
raise Error, "No steps defined in config" if steps_config.empty?
|
|
73
79
|
|