ace-assign 0.63.1 → 0.66.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.ace-defaults/assign/catalog/steps/auto-merge.step.yml +11 -0
- data/.ace-defaults/assign/catalog/steps/create-pr.step.yml +2 -2
- data/.ace-defaults/assign/catalog/steps/mark-pr-ready.step.yml +1 -1
- data/.ace-defaults/assign/catalog/steps/release-minor.step.yml +4 -2
- data/.ace-defaults/assign/catalog/steps/release.step.yml +4 -2
- data/.ace-defaults/assign/catalog/steps/update-pr-desc.step.yml +1 -1
- data/.ace-defaults/assign/presets/fix-bug.yml +15 -3
- data/.ace-defaults/assign/presets/work-on-task-ace-development.yml +16 -4
- data/.ace-defaults/assign/presets/work-on-task-auto-merge.yml +15 -3
- data/.ace-defaults/assign/presets/work-on-task.yml +16 -4
- data/CHANGELOG.md +62 -0
- data/docs/demo/canonical-skill-source.tape.yml +4 -4
- data/docs/protected-authority.md +282 -0
- data/docs/usage.md +205 -3
- data/exe/ace-scope-ready +55 -0
- data/handbook/workflow-instructions/assign/drive.wf.md +28 -3
- data/handbook/workflow-instructions/assign/prepare.wf.md +2 -2
- data/lib/ace/assign/atoms/attempt_state_machine.rb +8 -0
- data/lib/ace/assign/atoms/delivery_parameters.rb +19 -0
- data/lib/ace/assign/authority/assignment_inventory.rb +290 -0
- data/lib/ace/assign/authority/campaign_consumer_policy.rb +100 -0
- data/lib/ace/assign/authority/campaign_execution.rb +119 -0
- data/lib/ace/assign/authority/campaign_round_transfer.rb +82 -0
- data/lib/ace/assign/authority/candidate_transfer.rb +327 -0
- data/lib/ace/assign/authority/cleanup_result_protection.rb +53 -0
- data/lib/ace/assign/authority/client.rb +257 -0
- data/lib/ace/assign/authority/deployment.rb +627 -0
- data/lib/ace/assign/authority/deployment_history.rb +146 -0
- data/lib/ace/assign/authority/endcap.rb +455 -0
- data/lib/ace/assign/authority/endcap_attempts.rb +274 -0
- data/lib/ace/assign/authority/endcap_campaign_results.rb +232 -0
- data/lib/ace/assign/authority/endcap_campaign_rounds.rb +266 -0
- data/lib/ace/assign/authority/endcap_contexts.rb +23 -0
- data/lib/ace/assign/authority/endcap_inboxes.rb +37 -0
- data/lib/ace/assign/authority/endcap_prepared_work.rb +80 -0
- data/lib/ace/assign/authority/endcap_publication.rb +207 -0
- data/lib/ace/assign/authority/endcap_results.rb +398 -0
- data/lib/ace/assign/authority/endcap_review_requests.rb +183 -0
- data/lib/ace/assign/authority/endcap_reviews.rb +130 -0
- data/lib/ace/assign/authority/endcap_service_settlement.rb +152 -0
- data/lib/ace/assign/authority/endcap_services.rb +598 -0
- data/lib/ace/assign/authority/evidence_transfer.rb +67 -0
- data/lib/ace/assign/authority/execution_scope_observation.rb +903 -0
- data/lib/ace/assign/authority/inbox_context_original.rb +113 -0
- data/lib/ace/assign/authority/launch_attempt_consumers.rb +51 -0
- data/lib/ace/assign/authority/launch_campaign_registration.rb +214 -0
- data/lib/ace/assign/authority/launch_control_channel.rb +313 -0
- data/lib/ace/assign/authority/launch_control_exclusions.rb +141 -0
- data/lib/ace/assign/authority/launch_driver.rb +393 -0
- data/lib/ace/assign/authority/launch_input_inhibition.rb +132 -0
- data/lib/ace/assign/authority/launch_lifecycle.rb +997 -0
- data/lib/ace/assign/authority/launch_prepared_work.rb +122 -0
- data/lib/ace/assign/authority/launch_review.rb +91 -0
- data/lib/ace/assign/authority/launch_scope_abort.rb +81 -0
- data/lib/ace/assign/authority/launch_scope_admission.rb +215 -0
- data/lib/ace/assign/authority/launch_scope_close.rb +146 -0
- data/lib/ace/assign/authority/launch_scope_parent.rb +44 -0
- data/lib/ace/assign/authority/launch_scope_release.rb +859 -0
- data/lib/ace/assign/authority/launch_steering.rb +353 -0
- data/lib/ace/assign/authority/launch_stop.rb +254 -0
- data/lib/ace/assign/authority/maintenance_workspace_retirement.rb +229 -0
- data/lib/ace/assign/authority/original_inbox_selection.rb +41 -0
- data/lib/ace/assign/authority/posix_acl.rb +59 -0
- data/lib/ace/assign/authority/prepared_input.rb +130 -0
- data/lib/ace/assign/authority/prepared_queue.rb +185 -0
- data/lib/ace/assign/authority/prepared_task_context.rb +116 -0
- data/lib/ace/assign/authority/prepared_work.rb +304 -0
- data/lib/ace/assign/authority/prepared_worker.rb +69 -0
- data/lib/ace/assign/authority/private_directory.rb +33 -0
- data/lib/ace/assign/authority/protected_assignment_context.rb +226 -0
- data/lib/ace/assign/authority/readiness_hook.rb +120 -0
- data/lib/ace/assign/authority/receipt_transfer.rb +48 -0
- data/lib/ace/assign/authority/router.rb +76 -0
- data/lib/ace/assign/authority/server.rb +300 -0
- data/lib/ace/assign/authority/service_cleanup_dispatch.rb +88 -0
- data/lib/ace/assign/authority/service_cleanup_evidence.rb +180 -0
- data/lib/ace/assign/authority/service_delivery_evidence.rb +101 -0
- data/lib/ace/assign/authority/service_evidence.rb +286 -0
- data/lib/ace/assign/authority/service_publication_evidence.rb +110 -0
- data/lib/ace/assign/authority/task_context_entry.rb +32 -0
- data/lib/ace/assign/authority/transfer_codec.rb +187 -0
- data/lib/ace/assign/cli/commands/assignment_target.rb +15 -2
- data/lib/ace/assign/cli/commands/attempt/base.rb +49 -0
- data/lib/ace/assign/cli/commands/attempt/finish.rb +20 -4
- data/lib/ace/assign/cli/commands/attempt/reconcile.rb +11 -0
- data/lib/ace/assign/cli/commands/authority/inbox_context_principal.rb +25 -0
- data/lib/ace/assign/cli/commands/authority/inbox_context_selection.rb +28 -0
- data/lib/ace/assign/cli/commands/authority/launch.rb +97 -0
- data/lib/ace/assign/cli/commands/authority/serve.rb +31 -0
- data/lib/ace/assign/cli/commands/authority/status.rb +26 -0
- data/lib/ace/assign/cli/commands/authority/task_context.rb +31 -0
- data/lib/ace/assign/cli/commands/authority/task_context_principal.rb +25 -0
- data/lib/ace/assign/cli/commands/authority/task_context_selection.rb +30 -0
- data/lib/ace/assign/cli/commands/authority/terminate.rb +29 -0
- data/lib/ace/assign/cli/commands/authority/worker.rb +23 -0
- data/lib/ace/assign/cli/commands/campaign_export_result.rb +55 -0
- data/lib/ace/assign/cli/commands/campaign_record_round.rb +57 -0
- data/lib/ace/assign/cli/commands/create.rb +11 -1
- data/lib/ace/assign/cli/commands/delivery.rb +107 -0
- data/lib/ace/assign/cli/commands/fail.rb +6 -0
- data/lib/ace/assign/cli/commands/finish.rb +23 -2
- data/lib/ace/assign/cli/commands/fork_session.rb +6 -2
- data/lib/ace/assign/cli/commands/inbox_bind.rb +34 -0
- data/lib/ace/assign/cli/commands/protected_submission.rb +61 -0
- data/lib/ace/assign/cli/commands/resume.rb +38 -0
- data/lib/ace/assign/cli/commands/select.rb +2 -0
- data/lib/ace/assign/cli/commands/start.rb +2 -0
- data/lib/ace/assign/cli/commands/status.rb +4 -2
- data/lib/ace/assign/cli/commands/step.rb +3 -1
- data/lib/ace/assign/cli/commands/submit_candidate.rb +34 -0
- data/lib/ace/assign/cli/commands/submit_result.rb +61 -0
- data/lib/ace/assign/cli.rb +56 -4
- data/lib/ace/assign/errors.rb +139 -0
- data/lib/ace/assign/models/assignment.rb +26 -4
- data/lib/ace/assign/models/evidence_event.rb +4 -2
- data/lib/ace/assign/molecules/assignment_manager.rb +12 -2
- data/lib/ace/assign/molecules/attempt_reconciler.rb +51 -12
- data/lib/ace/assign/molecules/canonical_attempt_state.rb +151 -0
- data/lib/ace/assign/molecules/canonical_evidence.rb +153 -0
- data/lib/ace/assign/molecules/canonical_read_snapshot.rb +374 -0
- data/lib/ace/assign/molecules/evidence_journal.rb +922 -91
- data/lib/ace/assign/molecules/execution_identity_resolver.rb +49 -9
- data/lib/ace/assign/molecules/execution_scope_lineage.rb +345 -0
- data/lib/ace/assign/molecules/fork_session_launcher.rb +16 -6
- data/lib/ace/assign/molecules/journal_input_inhibition.rb +78 -0
- data/lib/ace/assign/molecules/journal_mutation.rb +271 -0
- data/lib/ace/assign/molecules/journal_prompt_mutation.rb +241 -0
- data/lib/ace/assign/molecules/lifecycle_exclusion.rb +182 -26
- data/lib/ace/assign/molecules/original_launch_binding.rb +37 -0
- data/lib/ace/assign/molecules/proposal_journal.rb +151 -0
- data/lib/ace/assign/molecules/protected_control_exclusion.rb +303 -0
- data/lib/ace/assign/molecules/protected_workspace_exclusion.rb +330 -0
- data/lib/ace/assign/molecules/protected_workspace_provisioner.rb +164 -0
- data/lib/ace/assign/molecules/queue_scanner.rb +26 -11
- data/lib/ace/assign/molecules/receipt_verifier.rb +68 -32
- data/lib/ace/assign/molecules/step_writer.rb +27 -5
- data/lib/ace/assign/molecules/terminal_scope_receipt.rb +88 -0
- data/lib/ace/assign/organisms/assignment_executor.rb +10 -4
- data/lib/ace/assign/organisms/attempt_coordinator.rb +302 -13
- data/lib/ace/assign/organisms/delivery_coordinator.rb +342 -0
- data/lib/ace/assign/organisms/prepared_work_builder.rb +194 -0
- data/lib/ace/assign/organisms/protected_delivery_coordinator.rb +100 -0
- data/lib/ace/assign/organisms/task_assignment_creator.rb +11 -4
- data/lib/ace/assign/version.rb +1 -1
- data/lib/ace/assign.rb +4 -102
- metadata +186 -8
|
@@ -5,6 +5,8 @@ require "fileutils"
|
|
|
5
5
|
require "json"
|
|
6
6
|
require "open3"
|
|
7
7
|
require "time"
|
|
8
|
+
require "securerandom"
|
|
9
|
+
require_relative "../atoms/evidence_digest"
|
|
8
10
|
|
|
9
11
|
module Ace
|
|
10
12
|
module Assign
|
|
@@ -25,6 +27,95 @@ module Ace
|
|
|
25
27
|
# present. Legitimate fresh provisioning (a new worktree for a task)
|
|
26
28
|
# clears the marker explicitly via `reset_removed`.
|
|
27
29
|
class LifecycleExclusion
|
|
30
|
+
autoload :ControlExclusion, File.expand_path("protected_control_exclusion", __dir__)
|
|
31
|
+
|
|
32
|
+
def self.control_exclusion(authority:, project_id:, descriptor_sha256:, root_identity: nil, **boundaries)
|
|
33
|
+
ControlExclusion.new(authority: authority, project_id: project_id,
|
|
34
|
+
descriptor_sha256: descriptor_sha256, root_identity: root_identity, **boundaries)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
autoload :WorkspaceNativeReader, File.expand_path("protected_workspace_exclusion", __dir__)
|
|
38
|
+
autoload :WorkspaceReader, File.expand_path("protected_workspace_exclusion", __dir__)
|
|
39
|
+
|
|
40
|
+
autoload :WorkspaceWriter, File.expand_path("protected_workspace_exclusion", __dir__)
|
|
41
|
+
|
|
42
|
+
autoload :WorkspaceFenceReader, File.expand_path("protected_workspace_exclusion", __dir__)
|
|
43
|
+
|
|
44
|
+
def self.workspace_fence_reader(projection:, protection: nil, files: File)
|
|
45
|
+
WorkspaceFenceReader.new(projection: projection, protection: protection, files: files)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def self.workspace_writer(projection:, protection: nil, files: File)
|
|
49
|
+
WorkspaceWriter.new(projection: projection, protection: protection, files: files)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
autoload :WorkspaceProvisioner, File.expand_path("protected_workspace_provisioner", __dir__)
|
|
53
|
+
|
|
54
|
+
def self.provision_workspace!(mapping_id:, project_id:, authority:, cwd_resource:, **boundaries)
|
|
55
|
+
WorkspaceProvisioner.new(mapping_id: mapping_id, project_id: project_id,
|
|
56
|
+
authority: authority, cwd_resource: cwd_resource, **boundaries).provision!
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def self.workspace_reader(projection:, protection: nil, files: File)
|
|
60
|
+
WorkspaceReader.new(projection: projection, protection: protection, files: files)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def self.workspace_selection(mapping_id:, project_id:, authority:, cwd_resource:)
|
|
64
|
+
fields = %w[device filesystem_type gid host_path inode mount_id uid view_path]
|
|
65
|
+
unless authority.is_a?(Hash) && cwd_resource.is_a?(Hash) && cwd_resource.keys.sort == fields &&
|
|
66
|
+
%w[device gid inode uid].all? { |field| cwd_resource[field].is_a?(Integer) && cwd_resource[field] >= 0 } &&
|
|
67
|
+
cwd_resource["mount_id"].is_a?(Integer) && cwd_resource["mount_id"].positive? &&
|
|
68
|
+
%w[host_path view_path].all? { |field| cwd_resource[field].is_a?(String) &&
|
|
69
|
+
cwd_resource[field].valid_encoding? && cwd_resource[field].bytesize.between?(1, 4096) &&
|
|
70
|
+
!cwd_resource[field].include?("\0") && cwd_resource[field].start_with?("/") &&
|
|
71
|
+
File.expand_path(cwd_resource[field]) == cwd_resource[field] } &&
|
|
72
|
+
%w[ext4 xfs btrfs tmpfs].include?(cwd_resource["filesystem_type"]) &&
|
|
73
|
+
[mapping_id, project_id].all? { |value| value.is_a?(String) && value.match?(/\A[A-Za-z0-9][A-Za-z0-9._-]{0,127}\z/) } &&
|
|
74
|
+
%w[uid gid].all? { |field| authority[field].is_a?(Integer) && authority[field].positive? } &&
|
|
75
|
+
authority["state_root"].is_a?(String) && authority["state_root"].valid_encoding? &&
|
|
76
|
+
authority["state_root"].bytesize.between?(1, 4096) && !authority["state_root"].include?("\0") &&
|
|
77
|
+
authority["state_root"].start_with?("/") &&
|
|
78
|
+
File.expand_path(authority["state_root"]) == authority["state_root"]
|
|
79
|
+
raise AttemptErrors::EvidenceUnavailable, "Original lifecycle workspace selection is malformed"
|
|
80
|
+
end
|
|
81
|
+
digest = Atoms::EvidenceDigest.digest(cwd_resource)
|
|
82
|
+
selection = {"key" => "workspace:#{mapping_id}:#{digest}",
|
|
83
|
+
"host_path" => File.join(authority.fetch("state_root"), "lifecycle-exclusion", project_id,
|
|
84
|
+
"workspaces", mapping_id, digest), "view_path" => "/run/ace/lifecycle-exclusion/#{mapping_id}"}
|
|
85
|
+
unless selection.fetch("host_path").bytesize <= 4096
|
|
86
|
+
raise AttemptErrors::EvidenceUnavailable, "Original lifecycle workspace path is oversized"
|
|
87
|
+
end
|
|
88
|
+
selection.each_value(&:freeze)
|
|
89
|
+
selection.freeze
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def self.workspace_initial_marker(key:)
|
|
93
|
+
unless key.is_a?(String) && key.match?(/\Aworkspace:[A-Za-z0-9][A-Za-z0-9._-]{0,127}:[0-9a-f]{64}\z/)
|
|
94
|
+
raise AttemptErrors::EvidenceUnavailable, "Original lifecycle workspace key is malformed"
|
|
95
|
+
end
|
|
96
|
+
Atoms::EvidenceDigest.canonical_json({"key" => key, "removed" => false, "removed_at" => nil}).freeze
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def self.workspace_marker!(bytes:, key:)
|
|
100
|
+
unless bytes.is_a?(String) && bytes.bytesize.between?(1, 8192)
|
|
101
|
+
raise AttemptErrors::EvidenceUnavailable, "Lifecycle marker bytes differ"
|
|
102
|
+
end
|
|
103
|
+
bytes = bytes.dup.force_encoding(Encoding::UTF_8)
|
|
104
|
+
unless bytes.valid_encoding?
|
|
105
|
+
raise AttemptErrors::EvidenceUnavailable, "Lifecycle marker is not bounded UTF8"
|
|
106
|
+
end
|
|
107
|
+
state = JSON.parse(bytes, create_additions: false, max_nesting: 4, allow_duplicate_key: false)
|
|
108
|
+
unless state.is_a?(Hash) && state.keys.sort == %w[key removed removed_at] && state.fetch("key") == key &&
|
|
109
|
+
(state.values_at("removed", "removed_at") == [false, nil] || state["removed"] == true &&
|
|
110
|
+
state["removed_at"].is_a?(String) && Time.iso8601(state["removed_at"]).utc.iso8601 == state["removed_at"])
|
|
111
|
+
raise AttemptErrors::EvidenceUnavailable, "Lifecycle marker schema differs"
|
|
112
|
+
end
|
|
113
|
+
state.each_value { |value| value.freeze }
|
|
114
|
+
state.freeze
|
|
115
|
+
rescue JSON::ParserError, ArgumentError, KeyError
|
|
116
|
+
raise AttemptErrors::EvidenceUnavailable, "Lifecycle marker schema differs"
|
|
117
|
+
end
|
|
118
|
+
|
|
28
119
|
# @param repo_root [String, nil] Repository used to resolve the
|
|
29
120
|
# shared Git common dir (default: project root)
|
|
30
121
|
# @param root [String, nil] Explicit exclusion root (tests)
|
|
@@ -33,27 +124,27 @@ module Ace
|
|
|
33
124
|
end
|
|
34
125
|
|
|
35
126
|
# The exclusion root shared by every writer and prune for this repo.
|
|
36
|
-
#
|
|
37
|
-
#
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
cache_base = ENV["CACHE_BASE"]
|
|
41
|
-
if cache_base && !cache_base.empty?
|
|
42
|
-
return File.join(File.expand_path(cache_base), ".exclusion")
|
|
43
|
-
end
|
|
44
|
-
|
|
45
|
-
repo_root = Ace::Support::Fs::Molecules::ProjectRootFinder.find_or_current
|
|
127
|
+
# Assignment cache configuration does not select this repository
|
|
128
|
+
# identity. The Git common dir survives linked worktree removal.
|
|
129
|
+
def self.default_root(repo_root = nil)
|
|
130
|
+
repo_root ||= Ace::Support::Fs::Molecules::ProjectRootFinder.find_or_current
|
|
46
131
|
common_dir, _stderr, status = Open3.capture3(
|
|
47
132
|
"git", "-C", repo_root, "rev-parse", "--path-format=absolute", "--git-common-dir"
|
|
48
133
|
)
|
|
49
|
-
|
|
50
|
-
|
|
134
|
+
unless status.success? && !common_dir.to_s.strip.empty? && File.absolute_path(common_dir.strip) == common_dir.strip
|
|
135
|
+
raise AttemptErrors::Conflict, "Repository lifecycle root is unavailable"
|
|
136
|
+
end
|
|
137
|
+
File.join(common_dir.strip, "ace", "lifecycle-exclusion")
|
|
51
138
|
end
|
|
52
139
|
|
|
53
140
|
def assignment_key(assignment_id)
|
|
54
141
|
"assignment:#{assignment_id}"
|
|
55
142
|
end
|
|
56
143
|
|
|
144
|
+
def slot_key(slot_id)
|
|
145
|
+
"execution-slot:#{slot_id}"
|
|
146
|
+
end
|
|
147
|
+
|
|
57
148
|
# Task identity is the canonical worktree identity: work-on derives
|
|
58
149
|
# the worktree path from the task ref, and prune candidates carry
|
|
59
150
|
# the same task id.
|
|
@@ -75,12 +166,17 @@ module Ace
|
|
|
75
166
|
#
|
|
76
167
|
# @param key [String] Identity key
|
|
77
168
|
# @yield Required block executed under exclusive exclusion
|
|
78
|
-
def with_exclusive(key)
|
|
169
|
+
def with_exclusive(key, deadline: nil)
|
|
79
170
|
raise ArgumentError, "exclusion key is required" if key.to_s.empty?
|
|
80
171
|
|
|
81
|
-
|
|
82
|
-
lock.
|
|
172
|
+
maintenance_deadline!(deadline) unless deadline.nil?
|
|
173
|
+
lock = open_lock(key, nonblock: !deadline.nil?)
|
|
83
174
|
begin
|
|
175
|
+
mode = File::LOCK_EX | (deadline ? File::LOCK_NB : 0)
|
|
176
|
+
unless lock.flock(mode)
|
|
177
|
+
raise AttemptErrors::MaintenanceBusy, "maintenance exclusion is busy"
|
|
178
|
+
end
|
|
179
|
+
maintenance_deadline!(deadline) unless deadline.nil?
|
|
84
180
|
yield
|
|
85
181
|
ensure
|
|
86
182
|
lock.flock(File::LOCK_UN)
|
|
@@ -122,8 +218,9 @@ module Ace
|
|
|
122
218
|
ordered = Array(keys).compact.reject { |key| key.to_s.empty? }.uniq
|
|
123
219
|
raise ArgumentError, "exclusion keys are required" if ordered.empty?
|
|
124
220
|
|
|
125
|
-
locks =
|
|
221
|
+
locks = []
|
|
126
222
|
begin
|
|
223
|
+
ordered.each { |key| locks << open_lock(key) }
|
|
127
224
|
locks.each { |lock| lock.flock(File::LOCK_SH) }
|
|
128
225
|
ordered.each do |key|
|
|
129
226
|
if removed?(key)
|
|
@@ -153,25 +250,67 @@ module Ace
|
|
|
153
250
|
end
|
|
154
251
|
|
|
155
252
|
def removed?(key)
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
253
|
+
path = state_path(key)
|
|
254
|
+
opened = false
|
|
255
|
+
File.open(path, File::RDONLY | File::NOFOLLOW | File::NONBLOCK) do |file|
|
|
256
|
+
opened = true
|
|
257
|
+
before = file.stat
|
|
258
|
+
unless before.file? && before.nlink == 1 && before.size.between?(1, 8192) && (before.mode & 0022).zero?
|
|
259
|
+
raise AttemptErrors::Conflict, "Lifecycle marker is unsafe"
|
|
260
|
+
end
|
|
261
|
+
content = file.read(8193).force_encoding(Encoding::UTF_8)
|
|
262
|
+
unless content.valid_encoding? && content.bytesize == before.size && same_file?(before, file.stat) && same_file?(before, File.lstat(path))
|
|
263
|
+
raise AttemptErrors::Conflict, "Lifecycle marker changed"
|
|
264
|
+
end
|
|
265
|
+
state = JSON.parse(content, create_additions: false, max_nesting: 4, allow_duplicate_key: false)
|
|
266
|
+
unless state.is_a?(Hash) && state.keys.sort == %w[key removed removed_at] &&
|
|
267
|
+
state["key"] == key && state["removed"] == true && state["removed_at"].is_a?(String)
|
|
268
|
+
raise AttemptErrors::Conflict, "Lifecycle marker is malformed"
|
|
269
|
+
end
|
|
270
|
+
Time.iso8601(state.fetch("removed_at"))
|
|
271
|
+
true
|
|
272
|
+
end
|
|
273
|
+
rescue Errno::ENOENT
|
|
274
|
+
raise AttemptErrors::Conflict, "Lifecycle marker disappeared during read" if opened
|
|
159
275
|
false
|
|
276
|
+
rescue JSON::ParserError, ArgumentError, TypeError, SystemCallError, IOError
|
|
277
|
+
raise AttemptErrors::Conflict, "Lifecycle marker is unavailable or malformed"
|
|
160
278
|
end
|
|
161
279
|
|
|
162
280
|
def clear_removed!(key)
|
|
281
|
+
return unless removed?(key)
|
|
163
282
|
File.delete(state_path(key))
|
|
164
|
-
|
|
165
|
-
nil
|
|
283
|
+
sync_directory(root)
|
|
166
284
|
end
|
|
167
285
|
|
|
168
286
|
private
|
|
169
287
|
|
|
170
288
|
attr_reader :root
|
|
171
289
|
|
|
172
|
-
def open_lock(key)
|
|
290
|
+
def open_lock(key, nonblock: false)
|
|
291
|
+
retained = false
|
|
173
292
|
FileUtils.mkdir_p(root)
|
|
174
|
-
|
|
293
|
+
flags = File::RDWR | File::CREAT | File::NONBLOCK | File::NOFOLLOW
|
|
294
|
+
file = File.open(lock_path(key), flags, 0600)
|
|
295
|
+
unless file.stat.file? && file.stat.nlink == 1 && (file.stat.mode & 0022).zero? &&
|
|
296
|
+
same_file?(file.stat, File.lstat(lock_path(key)))
|
|
297
|
+
raise AttemptErrors::MaintenanceBusy, "maintenance exclusion is not a regular file"
|
|
298
|
+
end
|
|
299
|
+
retained = true
|
|
300
|
+
file
|
|
301
|
+
rescue SystemCallError
|
|
302
|
+
raise AttemptErrors::Conflict, "Lifecycle lock is unavailable"
|
|
303
|
+
ensure
|
|
304
|
+
file&.close unless retained
|
|
305
|
+
end
|
|
306
|
+
|
|
307
|
+
def maintenance_deadline!(deadline)
|
|
308
|
+
unless (deadline.is_a?(Integer) || deadline.is_a?(Float)) && deadline.finite?
|
|
309
|
+
raise ArgumentError, "maintenance deadline must be finite"
|
|
310
|
+
end
|
|
311
|
+
if Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
|
|
312
|
+
raise AttemptErrors::MaintenanceBusy, "maintenance admission deadline expired"
|
|
313
|
+
end
|
|
175
314
|
end
|
|
176
315
|
|
|
177
316
|
def lock_path(key)
|
|
@@ -194,15 +333,32 @@ module Ace
|
|
|
194
333
|
end
|
|
195
334
|
end
|
|
196
335
|
|
|
336
|
+
def same_file?(left, right)
|
|
337
|
+
[left.dev, left.ino, left.size, left.mtime, left.ctime] == [right.dev, right.ino, right.size, right.mtime, right.ctime]
|
|
338
|
+
end
|
|
339
|
+
|
|
340
|
+
def sync_directory(path)
|
|
341
|
+
File.open(path, File::RDONLY | File::NOFOLLOW | File::NONBLOCK) { |directory| directory.fsync }
|
|
342
|
+
end
|
|
343
|
+
|
|
197
344
|
def atomic_write(path, content)
|
|
198
345
|
dir = File.dirname(path)
|
|
199
|
-
FileUtils.mkdir_p(dir)
|
|
200
|
-
temp = File.join(dir, ".tmp-#{
|
|
201
|
-
File.
|
|
346
|
+
FileUtils.mkdir_p(dir, mode: 0700)
|
|
347
|
+
temp = File.join(dir, ".tmp-#{SecureRandom.hex(16)}")
|
|
348
|
+
File.open(temp, File::WRONLY | File::CREAT | File::EXCL | File::NOFOLLOW | File::NONBLOCK, 0600) do |file|
|
|
349
|
+
file.write(content)
|
|
350
|
+
file.flush
|
|
351
|
+
file.fsync
|
|
352
|
+
end
|
|
353
|
+
if File.exist?(path) || File.symlink?(path)
|
|
354
|
+
removed?(JSON.parse(content).fetch("key"))
|
|
355
|
+
end
|
|
202
356
|
File.rename(temp, path)
|
|
357
|
+
sync_directory(dir)
|
|
203
358
|
ensure
|
|
204
359
|
File.delete(temp) if temp && File.exist?(temp)
|
|
205
360
|
end
|
|
361
|
+
|
|
206
362
|
end
|
|
207
363
|
end
|
|
208
364
|
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "ace/herdr/molecules/guarded_native_origin"
|
|
4
|
+
require_relative "../models/evidence_event"
|
|
5
|
+
|
|
6
|
+
module Ace
|
|
7
|
+
module Assign
|
|
8
|
+
module Molecules
|
|
9
|
+
# Exact accepted launch record; this reader grants no native effect.
|
|
10
|
+
module OriginalLaunchBinding
|
|
11
|
+
def self.verify!(events:, state:, params:)
|
|
12
|
+
unless state.is_a?(Hash) && state["guarded_origin"].is_a?(Hash) && state["process_binding"].is_a?(Hash)
|
|
13
|
+
raise AttemptErrors::EvidenceUnavailable, "Original guarded record is unavailable"
|
|
14
|
+
end
|
|
15
|
+
unless Models::EvidenceEvent.chain_valid?(events)
|
|
16
|
+
raise AttemptErrors::EvidenceUnavailable, "Original guarded record chain is corrupt"
|
|
17
|
+
end
|
|
18
|
+
records = events.select { |event| event["type"] == "authority_mutation" && event.dig("payload", "operation") == "record_launch" }
|
|
19
|
+
raise AttemptErrors::EvidenceUnavailable, "Original guarded record is ambiguous or absent" unless records.size == 1
|
|
20
|
+
record = records.first
|
|
21
|
+
data = record.fetch("payload").fetch("data")
|
|
22
|
+
unless data.is_a?(Hash) && record["attempt_id"] == params.fetch("attempt_id") &&
|
|
23
|
+
%w[mapping_id assignment_id attempt_id].all? { |key| data[key] == params.fetch(key) } &&
|
|
24
|
+
data["process_binding"] == state.fetch("process_binding") && data["guarded_origin"] == state.fetch("guarded_origin")
|
|
25
|
+
raise AttemptErrors::EvidenceUnavailable, "Original guarded record differs from accepted launch"
|
|
26
|
+
end
|
|
27
|
+
binding = data.fetch("process_binding")
|
|
28
|
+
origin = Ace::Herdr::Molecules::GuardedNativeOrigin.verify!(data.fetch("guarded_origin"),
|
|
29
|
+
terminal_id: binding.fetch("terminal_id"), child: binding.fetch("process_identity"))
|
|
30
|
+
{"binding_digest" => record.fetch("digest").dup.freeze, "origin" => origin}.freeze
|
|
31
|
+
rescue KeyError, TypeError, Ace::Runtime::RuntimeUnavailableError
|
|
32
|
+
raise AttemptErrors::EvidenceUnavailable, "Original guarded record is malformed"
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
@@ -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
|