ace-assign 0.64.0 → 0.66.1
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/CHANGELOG.md +25 -0
- data/docs/protected-authority.md +23 -7
- data/docs/usage.md +117 -4
- data/exe/ace-scope-ready +55 -0
- data/handbook/workflow-instructions/assign/drive.wf.md +26 -1
- data/lib/ace/assign/atoms/attempt_state_machine.rb +8 -0
- data/lib/ace/assign/atoms/delivery_parameters.rb +4 -39
- 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 +169 -2
- data/lib/ace/assign/authority/cleanup_result_protection.rb +53 -0
- data/lib/ace/assign/authority/client.rb +156 -6
- data/lib/ace/assign/authority/deployment.rb +201 -39
- data/lib/ace/assign/authority/deployment_history.rb +146 -0
- data/lib/ace/assign/authority/endcap.rb +131 -18
- 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 +3 -280
- 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 +36 -18
- 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 +221 -18
- data/lib/ace/assign/authority/execution_scope_observation.rb +468 -41
- 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 +298 -19
- data/lib/ace/assign/authority/launch_input_inhibition.rb +132 -0
- data/lib/ace/assign/authority/launch_lifecycle.rb +275 -100
- 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 +0 -7
- data/lib/ace/assign/authority/launch_scope_admission.rb +136 -5
- data/lib/ace/assign/authority/launch_scope_close.rb +68 -37
- data/lib/ace/assign/authority/launch_scope_release.rb +696 -17
- 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 +5 -2
- 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/protected_assignment_context.rb +226 -0
- data/lib/ace/assign/authority/readiness_hook.rb +120 -0
- data/lib/ace/assign/authority/router.rb +24 -2
- data/lib/ace/assign/authority/server.rb +103 -33
- 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 +243 -12
- 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 +44 -12
- 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 +58 -6
- data/lib/ace/assign/cli/commands/authority/serve.rb +2 -1
- data/lib/ace/assign/cli/commands/authority/status.rb +1 -1
- 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/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 +2 -0
- data/lib/ace/assign/cli/commands/delivery.rb +54 -4
- 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 +12 -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 +36 -5
- 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 +3 -3
- data/lib/ace/assign/molecules/assignment_manager.rb +12 -2
- data/lib/ace/assign/molecules/attempt_reconciler.rb +20 -0
- data/lib/ace/assign/molecules/canonical_attempt_state.rb +151 -0
- data/lib/ace/assign/molecules/canonical_evidence.rb +5 -2
- data/lib/ace/assign/molecules/canonical_read_snapshot.rb +374 -0
- data/lib/ace/assign/molecules/evidence_journal.rb +720 -44
- data/lib/ace/assign/molecules/execution_scope_lineage.rb +27 -9
- 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 +82 -13
- data/lib/ace/assign/molecules/journal_prompt_mutation.rb +241 -0
- data/lib/ace/assign/molecules/lifecycle_exclusion.rb +178 -26
- data/lib/ace/assign/molecules/original_launch_binding.rb +37 -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 +21 -15
- 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 +4 -4
- data/lib/ace/assign/organisms/attempt_coordinator.rb +66 -76
- 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 +4 -2
- data/lib/ace/assign/version.rb +1 -1
- data/lib/ace/assign.rb +1 -112
- metadata +90 -11
- data/lib/ace/assign/cli/commands/inbox_reconcile.rb +0 -36
|
@@ -11,6 +11,10 @@ module Ace
|
|
|
11
11
|
# Handles creation of new step files and updating existing ones,
|
|
12
12
|
# including appending reports and updating frontmatter.
|
|
13
13
|
class StepWriter
|
|
14
|
+
PRIVATE_MAX_BYTES = 1024 * 1024
|
|
15
|
+
def initialize(private_files: false)
|
|
16
|
+
@private_files = private_files
|
|
17
|
+
end
|
|
14
18
|
# Create a new step file
|
|
15
19
|
#
|
|
16
20
|
# @param steps_dir [String] Path to steps directory
|
|
@@ -101,6 +105,8 @@ module Ace
|
|
|
101
105
|
|
|
102
106
|
# Extract number and name from filename for report file
|
|
103
107
|
filename_info = Atoms::StepFileParser.parse_filename(File.basename(file_path))
|
|
108
|
+
encoded_report = report_bytes(filename_info[:number], filename_info[:name], report_content)
|
|
109
|
+
validate_private_bytes!(encoded_report)
|
|
104
110
|
|
|
105
111
|
# Update frontmatter only (status + completed_at)
|
|
106
112
|
new_frontmatter = parsed[:frontmatter].merge({
|
|
@@ -119,7 +125,7 @@ module Ace
|
|
|
119
125
|
)
|
|
120
126
|
report_path = File.join(reports_dir, report_filename)
|
|
121
127
|
|
|
122
|
-
write_report(report_path, filename_info[:number], filename_info[:name], report_content)
|
|
128
|
+
write_report(report_path, filename_info[:number], filename_info[:name], report_content, encoded: encoded_report)
|
|
123
129
|
|
|
124
130
|
file_path
|
|
125
131
|
end
|
|
@@ -204,8 +210,15 @@ module Ace
|
|
|
204
210
|
# Write content atomically using temp file + rename pattern.
|
|
205
211
|
# Prevents partial writes if process crashes mid-write.
|
|
206
212
|
def atomic_write(path, content)
|
|
213
|
+
validate_private_bytes!(content)
|
|
207
214
|
temp_path = "#{path}.tmp.#{Process.pid}"
|
|
208
|
-
|
|
215
|
+
if @private_files
|
|
216
|
+
File.open(temp_path, File::WRONLY | File::CREAT | File::EXCL | File::NOFOLLOW, 0600) do |file|
|
|
217
|
+
file.write(content); file.flush; file.fsync
|
|
218
|
+
end
|
|
219
|
+
else
|
|
220
|
+
File.write(temp_path, content)
|
|
221
|
+
end
|
|
209
222
|
File.rename(temp_path, path)
|
|
210
223
|
end
|
|
211
224
|
|
|
@@ -230,15 +243,24 @@ module Ace
|
|
|
230
243
|
# @param number [String] Step number
|
|
231
244
|
# @param name [String] Step name
|
|
232
245
|
# @param content [String] Report content
|
|
233
|
-
def write_report(report_path, number, name, content)
|
|
246
|
+
def write_report(report_path, number, name, content, encoded: nil)
|
|
247
|
+
atomic_write(report_path, encoded || report_bytes(number, name, content))
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
def report_bytes(number, name, content)
|
|
234
251
|
frontmatter = {
|
|
235
252
|
"step" => number,
|
|
236
253
|
"name" => name,
|
|
237
254
|
"completed_at" => Time.now.utc.iso8601
|
|
238
255
|
}
|
|
239
256
|
yaml = frontmatter.to_yaml
|
|
240
|
-
|
|
241
|
-
|
|
257
|
+
"#{yaml}---\n\n#{content}\n"
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
def validate_private_bytes!(content)
|
|
261
|
+
if @private_files && (!content.is_a?(String) || !content.bytesize.between?(1, PRIVATE_MAX_BYTES))
|
|
262
|
+
raise ArgumentError, "private queue output exceeds captured text bound"
|
|
263
|
+
end
|
|
242
264
|
end
|
|
243
265
|
end
|
|
244
266
|
end
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
require_relative "receipt_verifier"
|
|
3
|
+
require_relative "canonical_evidence"
|
|
4
|
+
|
|
5
|
+
module Ace
|
|
6
|
+
module Assign
|
|
7
|
+
module Molecules
|
|
8
|
+
# Pure reader over already accepted terminal owner evidence. A submitted
|
|
9
|
+
# result alone carries no terminal authority; local filesystem artifacts
|
|
10
|
+
# are never substituted for its imported protected bytes.
|
|
11
|
+
class TerminalScopeReceipt
|
|
12
|
+
BINDING_FIELDS = %w[mapping_id project_id assignment_id attempt_id result_id worker_uid worker_actor
|
|
13
|
+
launch_ticket process_binding head candidate_generation uploaded_receipt_sha256 original_receipt_digest].freeze
|
|
14
|
+
PAYLOAD_FIELDS = %w[version result_id binding uploaded_receipt_sha256 original_receipt_digest receipt_digest receipt artifacts].freeze
|
|
15
|
+
|
|
16
|
+
def self.verify!(events:, lineage:, journal:, commit:, mapping:)
|
|
17
|
+
raise AttemptErrors::EvidenceUnavailable, "terminal receipt chain differs" unless Models::EvidenceEvent.chain_valid?(events)
|
|
18
|
+
terminal = events.select { |event| event["type"] == "receipt_accepted" &&
|
|
19
|
+
%w[succeeded failed].include?(event.dig("payload", "receipt", "verdict")) }
|
|
20
|
+
raise AttemptErrors::EvidenceUnavailable, "no unique accepted terminal receipt" unless terminal.size == 1
|
|
21
|
+
accepted = terminal.first
|
|
22
|
+
receipt = accepted.fetch("payload").fetch("receipt")
|
|
23
|
+
before = events.take_while { |event| event.fetch("digest") != accepted.fetch("digest") }
|
|
24
|
+
submitted = before.select { |event| event["type"] == "result_submitted" &&
|
|
25
|
+
event.dig("payload", "receipt_digest") == receipt.fetch("digest") }
|
|
26
|
+
raise AttemptErrors::EvidenceUnavailable, "accepted terminal result provenance differs" unless submitted.size == 1
|
|
27
|
+
payload = submitted.first.fetch("payload")
|
|
28
|
+
binding = payload.fetch("binding")
|
|
29
|
+
reservation = before.find { |event| event["type"] == "authority_mutation" && event.dig("payload", "operation") == "reserve_attempt" }
|
|
30
|
+
origin = reservation.fetch("payload").fetch("data")
|
|
31
|
+
expected = {"mapping_id" => origin.fetch("mapping_id"), "project_id" => mapping.fetch("project_id"),
|
|
32
|
+
"assignment_id" => origin.fetch("assignment_id"), "attempt_id" => accepted.fetch("attempt_id"),
|
|
33
|
+
"worker_uid" => mapping.fetch("worker_uid"), "worker_actor" => mapping.fetch("worker_actor"),
|
|
34
|
+
"launch_ticket" => origin.fetch("launch_ticket"), "process_binding" => lineage.child_event.fetch("payload").fetch("original_process_binding")}
|
|
35
|
+
candidates = before.select { |event| event["type"] == "authority_mutation" &&
|
|
36
|
+
event.dig("payload", "operation") == "submit_candidate" &&
|
|
37
|
+
event.dig("payload", "data", "candidate_generation") == binding.fetch("candidate_generation") }
|
|
38
|
+
candidate = candidates.one? && candidates.first.dig("payload", "data")
|
|
39
|
+
unless payload.keys.sort == PAYLOAD_FIELDS.sort && binding.keys.sort == BINDING_FIELDS.sort &&
|
|
40
|
+
payload["version"] == 1 && expected.all? { |key, value| binding[key] == value } &&
|
|
41
|
+
candidate && candidate["head"] == binding["head"] && receipt["head"] == binding["head"] &&
|
|
42
|
+
payload["result_id"] == binding["result_id"] && payload["uploaded_receipt_sha256"] == binding["uploaded_receipt_sha256"] &&
|
|
43
|
+
payload["original_receipt_digest"] == binding["original_receipt_digest"] &&
|
|
44
|
+
Models::ExecutionReceipt.from_h(payload.fetch("receipt")).digest_payload == Models::ExecutionReceipt.from_h(receipt).digest_payload &&
|
|
45
|
+
(receipt.keys - Models::ExecutionReceipt.from_h(receipt).to_h.keys).empty? &&
|
|
46
|
+
payload["artifacts"] == receipt["artifacts"] &&
|
|
47
|
+
receipt["producer"] == {"actor" => mapping.fetch("worker_actor"), "role" => "worker", "runtime" => "herdr"} &&
|
|
48
|
+
journal.canonical_attempt_state(events) == receipt.fetch("verdict")
|
|
49
|
+
raise AttemptErrors::EvidenceUnavailable, "accepted terminal receipt selectors differ"
|
|
50
|
+
end
|
|
51
|
+
model = Models::ExecutionReceipt.from_h(receipt)
|
|
52
|
+
unless model.digest == Atoms::EvidenceDigest.digest(model.digest_payload) && model.digest == payload.fetch("receipt_digest")
|
|
53
|
+
raise AttemptErrors::EvidenceUnavailable, "accepted terminal receipt digest differs"
|
|
54
|
+
end
|
|
55
|
+
finishes = events.select { |event| event["type"] == "authority_mutation" && event.dig("payload", "operation") == "finish" }
|
|
56
|
+
unless finishes.empty?
|
|
57
|
+
transition = events[events.index(accepted) + 1]
|
|
58
|
+
finish = events[events.index(accepted) + 2]
|
|
59
|
+
expected_reply = binding.slice("attempt_id", "result_id", "head", "candidate_generation").merge(
|
|
60
|
+
"receipt_digest" => payload.fetch("receipt_digest"), "state" => receipt.fetch("verdict"))
|
|
61
|
+
unless finishes.one? && finishes.first == finish && transition && transition["type"] == "transition" &&
|
|
62
|
+
transition.fetch("payload") == {"from" => "running", "to" => receipt.fetch("verdict"), "reason" => "protected_result_accepted"} &&
|
|
63
|
+
finish.dig("payload", "data").except("generation") == expected_reply &&
|
|
64
|
+
finish.dig("payload", "data", "generation") == events.take(events.index(finish) + 1).count { |event| event["type"] == "authority_mutation" }
|
|
65
|
+
raise AttemptErrors::EvidenceUnavailable, "accepted finish reply or terminal transition differs"
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
canonical = CanonicalEvidence.new(journal: journal)
|
|
69
|
+
context = {kind: "result", project_id: binding.fetch("project_id"), assignment_id: binding.fetch("assignment_id"),
|
|
70
|
+
attempt_id: binding.fetch("attempt_id"), peer_uid: binding.fetch("worker_uid"), binding: binding,
|
|
71
|
+
request_id_or_event_id: binding.fetch("result_id"), generation: binding.fetch("candidate_generation")}
|
|
72
|
+
reader = ->(_data, artifact) { canonical.read({"ref" => artifact.fetch("path"), "sha256" => artifact.fetch("sha256")},
|
|
73
|
+
**context, commit: commit) }
|
|
74
|
+
Array(receipt["artifacts"]).each { |artifact| reader.call(receipt, artifact) }
|
|
75
|
+
intent = before.find { |event| event["type"] == "intent" }
|
|
76
|
+
attempt = journal.send(:build_attempt, binding.fetch("assignment_id"), binding.fetch("attempt_id"),
|
|
77
|
+
intent.fetch("payload"), before, "running")
|
|
78
|
+
verifier = ReceiptVerifier.new(artifact_reader: reader)
|
|
79
|
+
verifier.verify_result!(receipt, attempt: attempt, live_head: binding.fetch("head"), repo_root: journal.repo_root)
|
|
80
|
+
verifier.verify_accepted_evidence!(receipt, live_head: binding.fetch("head"), repo_root: journal.repo_root, historical: true)
|
|
81
|
+
accepted
|
|
82
|
+
rescue KeyError, TypeError, ArgumentError, NoMethodError, AttemptErrors::ReceiptRejected
|
|
83
|
+
raise AttemptErrors::EvidenceUnavailable, "authenticated terminal scope receipt unavailable"
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
@@ -37,10 +37,10 @@ module Ace
|
|
|
37
37
|
end
|
|
38
38
|
end
|
|
39
39
|
|
|
40
|
-
def initialize(cache_base: nil, skill_source_resolver: nil, step_catalog: nil)
|
|
41
|
-
@assignment_manager = Molecules::AssignmentManager.new(cache_base: cache_base)
|
|
42
|
-
@queue_scanner = Molecules::QueueScanner.new
|
|
43
|
-
@step_writer = Molecules::StepWriter.new
|
|
40
|
+
def initialize(cache_base: nil, skill_source_resolver: nil, step_catalog: nil, selected_assignment: nil, queue_scanner: nil)
|
|
41
|
+
@assignment_manager = Molecules::AssignmentManager.new(cache_base: cache_base, selected_assignment: selected_assignment)
|
|
42
|
+
@queue_scanner = queue_scanner || Molecules::QueueScanner.new
|
|
43
|
+
@step_writer = Molecules::StepWriter.new(private_files: !selected_assignment.nil?)
|
|
44
44
|
@skill_source_resolver = skill_source_resolver || Molecules::SkillAssignSourceResolver.new
|
|
45
45
|
@step_catalog = nil
|
|
46
46
|
@step_catalog_from_fixture = step_catalog
|
|
@@ -4,7 +4,10 @@ require "digest"
|
|
|
4
4
|
require "json"
|
|
5
5
|
require "open3"
|
|
6
6
|
require "pathname"
|
|
7
|
-
require "ace/herdr"
|
|
7
|
+
require "ace/herdr/errors"
|
|
8
|
+
require "ace/herdr/models/delivery_record"
|
|
9
|
+
require "ace/herdr/molecules/delivery_record_store"
|
|
10
|
+
require_relative "../molecules/canonical_attempt_state"
|
|
8
11
|
|
|
9
12
|
module Ace
|
|
10
13
|
module Assign
|
|
@@ -46,6 +49,59 @@ module Ace
|
|
|
46
49
|
|
|
47
50
|
attr_reader :store
|
|
48
51
|
|
|
52
|
+
# Pure producer plan. The protected lifecycle authenticates the original
|
|
53
|
+
# proof and both exhaustive owner projections anew at each CAS prefix.
|
|
54
|
+
# No caller receipt, cache state, native wait or store mutation enters it.
|
|
55
|
+
def self.stopped_transition_plan(events:, selection:, service_evidence:, commit:)
|
|
56
|
+
Atoms::AttemptStateMachine.proof_stopped_transition!(Molecules::CanonicalAttemptState.derive(events))
|
|
57
|
+
payload = Molecules::CanonicalAttemptState.stopped_payload!(events: events, selection: selection,
|
|
58
|
+
service_evidence: service_evidence, commit: commit)
|
|
59
|
+
{events: [{type: "attempt_stopped", payload: payload}], blobs: {}}
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# The protected result owner authenticates imported bytes, candidate,
|
|
63
|
+
# closure and independent settlements at the same CAS prefix.
|
|
64
|
+
def self.finished_transition_plan(events:, receipt:)
|
|
65
|
+
state = Molecules::CanonicalAttemptState.derive(events)
|
|
66
|
+
unless state == "running" && %w[succeeded failed].include?(receipt.fetch("verdict"))
|
|
67
|
+
raise AttemptErrors::Conflict, "Result finish requires a running attempt"
|
|
68
|
+
end
|
|
69
|
+
Atoms::AttemptStateMachine.transition!(state, receipt.fetch("verdict"))
|
|
70
|
+
{events: [{type: "receipt_accepted", payload: {"receipt" => receipt}},
|
|
71
|
+
{type: "transition", payload: {"from" => state, "to" => receipt.fetch("verdict"), "reason" => "protected_result_accepted"}}], blobs: {}}
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def self.protected_recovery_decision(state:, effects_pending:, bound:, live:, checkpoint:)
|
|
75
|
+
return ["restart-required", "terminal_attempt"] if %w[succeeded failed stopped].include?(state)
|
|
76
|
+
reason = if effects_pending
|
|
77
|
+
"unresolved_effect"
|
|
78
|
+
elsif state == "uncertain"
|
|
79
|
+
"attempt_uncertain"
|
|
80
|
+
elsif !bound
|
|
81
|
+
"launch_unbound"
|
|
82
|
+
elsif !checkpoint
|
|
83
|
+
"checkpoint_unavailable"
|
|
84
|
+
elsif !live
|
|
85
|
+
"scope_unverifiable"
|
|
86
|
+
end
|
|
87
|
+
reason ? ["reconcile-required", reason] : ["adopt", "live_owner"]
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def self.protected_recovery_plan(events:, decision:, reason:, observation:)
|
|
91
|
+
state = Molecules::CanonicalAttemptState.derive(events)
|
|
92
|
+
if %w[succeeded failed stopped].include?(state)
|
|
93
|
+
raise AttemptErrors::Conflict, "Terminal recovery is read-only"
|
|
94
|
+
end
|
|
95
|
+
planned = [{type: "recovery_observation", payload: {"decision" => decision,
|
|
96
|
+
"recovery_reason" => reason, "observation" => observation}}]
|
|
97
|
+
if state == "running" && decision != "adopt"
|
|
98
|
+
Atoms::AttemptStateMachine.transition!(state, "uncertain")
|
|
99
|
+
planned << {type: "transition", payload: {"from" => state, "to" => "uncertain", "reason" => reason}}
|
|
100
|
+
state = "uncertain"
|
|
101
|
+
end
|
|
102
|
+
{events: planned, blobs: {}, state: state}
|
|
103
|
+
end
|
|
104
|
+
|
|
49
105
|
# Read-only exact native reverse binding from accepted history. A PID
|
|
50
106
|
# supplied by a trusted boundary must be kernel-derived; no command
|
|
51
107
|
# flag, environment address or local cache grants this authority.
|
|
@@ -572,14 +628,10 @@ input:#{Regexp.escape(request.fetch("input_digest"))} outcome:(\S+)( no-effect:(
|
|
|
572
628
|
recovered = attempts.sort_by { |attempt| [attempt.binding.created_at, attempt.attempt_id] }.map do |attempt|
|
|
573
629
|
observation = reconciler.observation(attempt)
|
|
574
630
|
blocked = unresolved.any? { |request| request["attempt_id"] == attempt.attempt_id }
|
|
575
|
-
inbox_blocked = inbox_records.any? do |record|
|
|
576
|
-
[nil, attempt.attempt_id].include?(record["attempt_id"]) &&
|
|
577
|
-
%w[unknown claimed uncertain delivered].include?(record["state"])
|
|
578
|
-
end
|
|
579
631
|
unstarted = !reconciler.process_started?(attempt)
|
|
580
|
-
decision, reason = if blocked ||
|
|
632
|
+
decision, reason = if blocked || attempt.uncertain?
|
|
581
633
|
["reconcile-required", blocked ? "unresolved external effect" :
|
|
582
|
-
|
|
634
|
+
"attempt remains uncertain"]
|
|
583
635
|
elsif attempt.terminal?
|
|
584
636
|
["restart-required", "attempt ended; restart requires a new attributable attempt"]
|
|
585
637
|
elsif unstarted
|
|
@@ -657,60 +709,7 @@ input:#{Regexp.escape(request.fetch("input_digest"))} outcome:(\S+)( no-effect:(
|
|
|
657
709
|
# Inbox delivery is transport evidence, never business-effect proof.
|
|
658
710
|
# Herdr owns signature, pinned key, generation and native identity
|
|
659
711
|
# verification; this consumer records only its verified references.
|
|
660
|
-
def reconcile_inbox(attempt_id:, event_id:, receipt_path:, inbox:, identity: nil)
|
|
661
|
-
identity ||= @identity_resolver.resolve
|
|
662
|
-
unless @identity_resolver.trusted?(identity)
|
|
663
|
-
raise AttemptErrors::UnauthorizedIdentity, "Inbox recovery requires a trusted supervisor"
|
|
664
|
-
end
|
|
665
|
-
attempt = @store.find(attempt_id) || recover_managed_attempt(nil, attempt_id)
|
|
666
|
-
raise AttemptErrors::NotFound, "Attempt '#{attempt_id}' not found" unless attempt
|
|
667
712
|
|
|
668
|
-
lifecycle_exclusion.with_exclusive(lifecycle_exclusion.assignment_key(attempt.binding.assignment_id)) do
|
|
669
|
-
@store.with_lock(attempt.binding.assignment_id) do
|
|
670
|
-
attempt = attempt.managed? ? recover_managed_attempt(attempt.binding.assignment_id, attempt_id) :
|
|
671
|
-
@store.load(attempt.binding.assignment_id, attempt_id)
|
|
672
|
-
record = inbox.status(event: event_id)
|
|
673
|
-
unless record["attempt_id"] == attempt_id
|
|
674
|
-
raise AttemptErrors::ReceiptRejected, "Inbox event does not belong to this attempt"
|
|
675
|
-
end
|
|
676
|
-
registered = reconciler.events(attempt).find do |event|
|
|
677
|
-
event["type"] == "inbox_binding" && event.dig("payload", "event_id") == event_id
|
|
678
|
-
end
|
|
679
|
-
expected = record.slice("event_id", "attempt_id", "payload_sha256", "receipt_key_sha256")
|
|
680
|
-
unless registered && registered["payload"] == expected
|
|
681
|
-
raise AttemptErrors::ReceiptRejected, "Inbox event does not match its registered binding"
|
|
682
|
-
end
|
|
683
|
-
bytes = File.binread(receipt_path)
|
|
684
|
-
receipt = JSON.parse(bytes)
|
|
685
|
-
result = inbox.reconcile(event: event_id, receipt: receipt,
|
|
686
|
-
expected_registration: registered.fetch("payload"),
|
|
687
|
-
signed_bytes: bytes, signature: File.binread("#{receipt_path}.sig"))
|
|
688
|
-
return result if result["reconciliation_refusal"]
|
|
689
|
-
|
|
690
|
-
payload = result.slice("event_id", "attempt_id", "claim_generation", "payload_sha256",
|
|
691
|
-
"receipt_key_sha256", "binding", "state")
|
|
692
|
-
.merge("receipt_sha256" => Digest::SHA256.hexdigest(bytes),
|
|
693
|
-
"receipt_ref" => File.expand_path(receipt_path),
|
|
694
|
-
"outcome" => receipt["outcome"], "observer" => receipt["observer"],
|
|
695
|
-
"evidence" => receipt["evidence"].slice("kind", "native_reference"))
|
|
696
|
-
accepted = reconciler.events(attempt).any? do |event|
|
|
697
|
-
event["type"] == "inbox_reconciliation" &&
|
|
698
|
-
event["payload"].reject { |key, _| %w[receipt_ref receipt_sha256].include?(key) } ==
|
|
699
|
-
payload.reject { |key, _| %w[receipt_ref receipt_sha256].include?(key) }
|
|
700
|
-
end
|
|
701
|
-
unless accepted
|
|
702
|
-
event = Models::EvidenceEvent.build(type: "inbox_reconciliation", attempt_id: attempt_id,
|
|
703
|
-
payload: payload, previous_digest: last_event_digest(attempt))
|
|
704
|
-
@store.save(append_events(attempt, [event]))
|
|
705
|
-
end
|
|
706
|
-
result
|
|
707
|
-
end
|
|
708
|
-
end
|
|
709
|
-
rescue JSON::ParserError, SystemCallError => e
|
|
710
|
-
raise AttemptErrors::ReceiptRejected, "Inbox proof unavailable (#{e.class})"
|
|
711
|
-
rescue Ace::Herdr::Error => e
|
|
712
|
-
raise AttemptErrors::ReceiptRejected, "Inbox proof rejected: #{e.message}"
|
|
713
|
-
end
|
|
714
713
|
|
|
715
714
|
# Register a transport reference in the existing attempt journal so
|
|
716
715
|
# a missing delivery record cannot be mistaken for no pending effect.
|
|
@@ -730,7 +729,7 @@ input:#{Regexp.escape(request.fetch("input_digest"))} outcome:(\S+)( no-effect:(
|
|
|
730
729
|
unless record["attempt_id"] == attempt_id
|
|
731
730
|
raise AttemptErrors::ReceiptRejected, "Inbox event does not belong to this attempt"
|
|
732
731
|
end
|
|
733
|
-
payload = record.slice("event_id", "attempt_id", "payload_sha256"
|
|
732
|
+
payload = record.slice("event_id", "attempt_id", "payload_sha256")
|
|
734
733
|
existing = reconciler.events(attempt).find do |event|
|
|
735
734
|
event["type"] == "inbox_binding" && event.dig("payload", "event_id") == event_id
|
|
736
735
|
end
|
|
@@ -859,6 +858,9 @@ input:#{Regexp.escape(request.fetch("input_digest"))} outcome:(\S+)( no-effect:(
|
|
|
859
858
|
end
|
|
860
859
|
|
|
861
860
|
def recovery_inboxes(attempts)
|
|
861
|
+
# Ordinary local recovery selects Herdr's configuration only here;
|
|
862
|
+
# protected launch admission must not initialize that broad entry.
|
|
863
|
+
require "ace/herdr"
|
|
862
864
|
root = File.expand_path(Ace::Herdr.config["deliveries_dir"] || ".ace-local/herdr/deliveries", @repo_root)
|
|
863
865
|
entries = Ace::Herdr::Molecules::DeliveryRecordStore.list_records(root)
|
|
864
866
|
registrations = attempts.flat_map do |attempt|
|
|
@@ -869,21 +871,9 @@ input:#{Regexp.escape(request.fetch("input_digest"))} outcome:(\S+)( no-effect:(
|
|
|
869
871
|
record = Ace::Herdr::Molecules::DeliveryRecordStore.load(root, payload["event_id"])
|
|
870
872
|
attributed = record && record.inbox && record.event_id == payload["event_id"] &&
|
|
871
873
|
record.inbox["attempt_id"] == attempt.attempt_id && payload["attempt_id"] == attempt.attempt_id &&
|
|
872
|
-
record.answer_digest == payload["payload_sha256"]
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
next false unless event["type"] == "inbox_reconciliation"
|
|
876
|
-
proof = event["payload"]
|
|
877
|
-
proof["event_id"] == record.event_id && proof["claim_generation"] == record.inbox["claim_generation"] &&
|
|
878
|
-
proof["payload_sha256"] == record.answer_digest && proof["binding"] == record.inbox["binding"] &&
|
|
879
|
-
proof["receipt_key_sha256"] == record.inbox["receipt_key_sha256"] && proof["state"] == record.state
|
|
880
|
-
end
|
|
881
|
-
state = if !attributed || ((record.state == "completed" || record.state == "queued" && record.inbox["reconciliation"]) && !verified)
|
|
882
|
-
"unknown"
|
|
883
|
-
else
|
|
884
|
-
record.state
|
|
885
|
-
end
|
|
886
|
-
payload.slice("event_id", "attempt_id", "receipt_key_sha256").merge("state" => state)
|
|
874
|
+
record.answer_digest == payload["payload_sha256"]
|
|
875
|
+
state = attributed ? record.state : "unknown"
|
|
876
|
+
payload.slice("event_id", "attempt_id").merge("state" => state)
|
|
887
877
|
rescue JSON::ParserError, ArgumentError, SystemCallError
|
|
888
878
|
payload.slice("event_id", "attempt_id").merge("state" => "unknown")
|
|
889
879
|
end
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "ace/bundle"
|
|
4
|
+
require_relative "task_assignment_creator"
|
|
5
|
+
require_relative "../authority/prepared_work"
|
|
6
|
+
|
|
7
|
+
module Ace
|
|
8
|
+
module Assign
|
|
9
|
+
module Organisms
|
|
10
|
+
# Captures managed preparation once. This owner neither registers nor
|
|
11
|
+
# launches work; callers retain its exact output across uncertain replies.
|
|
12
|
+
class PreparedWorkBuilder
|
|
13
|
+
def initialize(export_root:, task_manager: nil, executor: nil, bundle_loader: nil)
|
|
14
|
+
@tasks = task_manager || Ace::Task::Organisms::TaskManager.new
|
|
15
|
+
@executor = executor || AssignmentExecutor.new
|
|
16
|
+
@bundles = bundle_loader || Ace::Bundle::Organisms::BundleLoader.new
|
|
17
|
+
@transfer = Authority::CandidateTransfer.new(root: export_root)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def call(task_ref:, project_id:, dependency_reports:)
|
|
21
|
+
token!(project_id)
|
|
22
|
+
@reads = {}
|
|
23
|
+
@inventories = {}
|
|
24
|
+
tasks = closure(task_ref)
|
|
25
|
+
selected = tasks.fetch(task_ref)
|
|
26
|
+
refuse!("selected task is not a reviewed leaf") unless
|
|
27
|
+
%w[pending in-progress].include?(selected.status.to_s) && Array(selected.subtasks).empty?
|
|
28
|
+
contexts = tasks.sort.to_h do |id, task|
|
|
29
|
+
refuse!("task still needs review") unless task.metadata.fetch("needs_review", false) == false
|
|
30
|
+
[id, capture_context(task)]
|
|
31
|
+
end
|
|
32
|
+
reports = capture_reports(dependency_reports, tasks, task_ref, project_id)
|
|
33
|
+
result = TaskAssignmentCreator.new(task_manager: @tasks, executor: @executor)
|
|
34
|
+
.call(task_refs: [task_ref], primary_task_ref: task_ref, project_id: project_id)
|
|
35
|
+
assignment = result.fetch(:assignment)
|
|
36
|
+
refuse!("managed association changed") unless assignment.task_id == task_ref && assignment.project_id == project_id
|
|
37
|
+
job = read(assignment.source_config)
|
|
38
|
+
read(assignment.assignment_file)
|
|
39
|
+
inventory = inventory(assignment.steps_dir)
|
|
40
|
+
step_files = inventory.to_h { |name| [name, read(File.join(assignment.steps_dir, name))] }
|
|
41
|
+
roots = step_files.filter_map do |name, bytes|
|
|
42
|
+
fm = Atoms::StepFileParser.parse(bytes).fetch(:frontmatter)
|
|
43
|
+
Atoms::StepFileParser.parse_filename(name).fetch(:number) if fm["context"] == "fork" && fm["taskref"] == task_ref
|
|
44
|
+
end
|
|
45
|
+
refuse!("selected fork root is not unique") unless roots.one? && roots.first
|
|
46
|
+
scope = roots.first
|
|
47
|
+
files = {"definition.json" => JSON.generate(assignment.to_h), "job.yaml" => job}
|
|
48
|
+
selected_steps = step_files.filter_map do |name, bytes|
|
|
49
|
+
number = Atoms::StepFileParser.parse_filename(name).fetch(:number)
|
|
50
|
+
refuse!("unexpected queue entry") unless number && name.end_with?(".st.md")
|
|
51
|
+
next unless number == scope || number.start_with?(scope + ".")
|
|
52
|
+
files["steps/#{name}"] = bytes
|
|
53
|
+
record(name, bytes).merge("number" => number)
|
|
54
|
+
end.sort_by { |entry| number_key(entry.fetch("number")) }
|
|
55
|
+
context = contexts.map do |id, captured|
|
|
56
|
+
spec_path = "context/#{id}/spec.md"
|
|
57
|
+
text_path = "context/#{id}/bundle.txt"
|
|
58
|
+
files[spec_path], files[text_path] = captured.values_at(:spec, :text)
|
|
59
|
+
selected_reports = reports.fetch(id, []).map do |report|
|
|
60
|
+
path = "context/#{id}/reports/#{report.fetch(:assignment_id)}/#{report.fetch(:number)}.r.md"
|
|
61
|
+
files[path] = report.fetch(:bytes)
|
|
62
|
+
{"assignment_id" => report.fetch(:assignment_id), "number" => report.fetch(:number),
|
|
63
|
+
"file" => record(path, report.fetch(:bytes))}
|
|
64
|
+
end
|
|
65
|
+
{"uri" => "task://#{id}", "task_id" => id, "spec" => record(spec_path, files.fetch(spec_path)),
|
|
66
|
+
"text" => record(text_path, files.fetch(text_path)), "reports" => selected_reports}
|
|
67
|
+
end
|
|
68
|
+
manifest = {"version" => 1, "assignment_id" => assignment.id, "project_id" => project_id,
|
|
69
|
+
"task_id" => task_ref, "scope" => scope, "job" => record("job.yaml", job),
|
|
70
|
+
"steps" => selected_steps, "context" => context}
|
|
71
|
+
files["manifest.json"] = Authority::PreparedWork.canonical_manifest(manifest) + "\n"
|
|
72
|
+
work = Authority::PreparedWork.new(files: files)
|
|
73
|
+
current = closure(task_ref)
|
|
74
|
+
refuse!("task dependency inventory changed") unless current.keys.sort == tasks.keys.sort
|
|
75
|
+
refuse!("selected task expanded during capture") unless Array(current.fetch(task_ref).subtasks).empty?
|
|
76
|
+
current.each { |id, task| refuse!("task context changed") unless capture_context(task) == contexts.fetch(id) }
|
|
77
|
+
@inventories.each { |path, names| refuse!("input inventory changed") unless Dir.children(path).sort == names }
|
|
78
|
+
@reads.each { |path, bytes| refuse!("captured input changed") unless read_bytes(path) == bytes }
|
|
79
|
+
@transfer.export_prepared(work: work).merge("assignment_id" => assignment.id,
|
|
80
|
+
"scope" => scope.freeze, "selection_sha256" => work.selection_sha256).freeze
|
|
81
|
+
ensure
|
|
82
|
+
@reads = nil
|
|
83
|
+
@inventories = nil
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
private
|
|
87
|
+
|
|
88
|
+
def closure(ref)
|
|
89
|
+
found = {}; visiting = []
|
|
90
|
+
walk = lambda do |id|
|
|
91
|
+
token!(id)
|
|
92
|
+
refuse!("dependency cycle: #{(visiting + [id]).join(' -> ')}") if visiting.include?(id)
|
|
93
|
+
return if found.key?(id)
|
|
94
|
+
refuse!("too many task contexts") if found.size >= 256
|
|
95
|
+
task = @tasks.show(id)
|
|
96
|
+
refuse!("missing or noncanonical task") unless task && task.id == id
|
|
97
|
+
found[id] = task
|
|
98
|
+
visiting << id
|
|
99
|
+
Array(task.dependencies).each { |dependency| walk.call(dependency) }
|
|
100
|
+
visiting.pop
|
|
101
|
+
end
|
|
102
|
+
walk.call(ref)
|
|
103
|
+
found
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def capture_context(task)
|
|
107
|
+
spec = read(task.file_path)
|
|
108
|
+
bundle = @bundles.load_file(task.file_path)
|
|
109
|
+
errors = bundle.metadata.values_at(:error, :errors, "error", "errors").flatten.compact
|
|
110
|
+
refuse!("unresolvable task context") unless errors.empty?
|
|
111
|
+
text = bounded_text(bundle.content)
|
|
112
|
+
# Deep-copy owner inventories; a loader may reuse mutable objects.
|
|
113
|
+
{spec: spec, text: text, path: task.file_path,
|
|
114
|
+
inventory: Marshal.dump([bundle.files, bundle.source_files])}
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def capture_reports(selections, tasks, selected, project)
|
|
118
|
+
refuse!("report selections must be explicit") unless selections.is_a?(Array) && selections.size <= 4096
|
|
119
|
+
seen = {}; grouped = Hash.new { |hash, key| hash[key] = [] }
|
|
120
|
+
selections.each do |selection|
|
|
121
|
+
refuse!("report selector") unless selection.is_a?(Hash) && selection.keys.sort == %w[assignment_id number task_id]
|
|
122
|
+
id, assignment_id, number = selection.values_at("task_id", "assignment_id", "number")
|
|
123
|
+
token!(id); token!(assignment_id)
|
|
124
|
+
refuse!("report scope") unless number.is_a?(String) && number.match?(Authority::PreparedWork::SCOPE)
|
|
125
|
+
refuse!("report is not a dependency") unless id != selected && tasks.key?(id)
|
|
126
|
+
identity = [id, assignment_id, number]
|
|
127
|
+
refuse!("duplicate report") if seen[identity]
|
|
128
|
+
seen[identity] = true
|
|
129
|
+
assignment = @executor.assignment_manager.load(assignment_id)
|
|
130
|
+
refuse!("report assignment association") unless assignment && assignment.task_id == id && assignment.project_id == project
|
|
131
|
+
metadata = read(assignment.assignment_file)
|
|
132
|
+
parsed_metadata = YAML.safe_load(metadata, permitted_classes: [Time, Date])
|
|
133
|
+
refuse!("report assignment changed") unless parsed_metadata["session_id"] == assignment_id &&
|
|
134
|
+
parsed_metadata["task_id"] == id && parsed_metadata["project_id"] == project
|
|
135
|
+
inventory(assignment.reports_dir)
|
|
136
|
+
matches = inventory(assignment.steps_dir).select do |name|
|
|
137
|
+
name.end_with?(".st.md") && Atoms::StepFileParser.parse_filename(name)[:number] == number
|
|
138
|
+
end
|
|
139
|
+
refuse!("report step association") unless matches.one?
|
|
140
|
+
step = matches.first
|
|
141
|
+
read(File.join(assignment.steps_dir, step))
|
|
142
|
+
parsed = Atoms::StepFileParser.parse_filename(step)
|
|
143
|
+
filename = Atoms::StepFileParser.generate_report_filename(number, parsed.fetch(:name))
|
|
144
|
+
grouped[id] << {assignment_id: assignment_id, number: number,
|
|
145
|
+
bytes: read(File.join(assignment.reports_dir, filename))}
|
|
146
|
+
end
|
|
147
|
+
grouped.each_value { |entries| entries.sort_by! { |entry| [entry.fetch(:assignment_id), number_key(entry.fetch(:number))] } }
|
|
148
|
+
grouped
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def record(path, bytes)
|
|
152
|
+
{"filename" => path, "bytes" => bytes.bytesize, "sha256" => Digest::SHA256.hexdigest(bytes)}
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def read(path)
|
|
156
|
+
bytes = read_bytes(path)
|
|
157
|
+
refuse!("captured input changed") if @reads.key?(path) && @reads.fetch(path) != bytes
|
|
158
|
+
@reads[path] = bytes
|
|
159
|
+
refuse!("capture inventory bound") if @reads.size > 4096 || @reads.values.sum(&:bytesize) > Authority::PreparedWork::MAX_TOTAL
|
|
160
|
+
bytes
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def inventory(path)
|
|
164
|
+
names = Dir.children(path).sort.freeze
|
|
165
|
+
refuse!("capture inventory bound") if names.size > 4096
|
|
166
|
+
refuse!("input inventory changed") if @inventories.key?(path) && @inventories.fetch(path) != names
|
|
167
|
+
@inventories[path] = names
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def read_bytes(path)
|
|
171
|
+
File.open(path, File::RDONLY | File::NOFOLLOW | File::NONBLOCK) do |file|
|
|
172
|
+
refuse!("input is not a bounded regular file") unless file.stat.file? && file.stat.size <= Authority::PreparedWork::MAX_TEXT
|
|
173
|
+
bounded_text(file.read(Authority::PreparedWork::MAX_TEXT + 1))
|
|
174
|
+
end
|
|
175
|
+
rescue SystemCallError
|
|
176
|
+
refuse!("input unavailable")
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def bounded_text(bytes)
|
|
180
|
+
refuse!("input text bound") unless bytes.is_a?(String) && bytes.bytesize.between?(1, Authority::PreparedWork::MAX_TEXT)
|
|
181
|
+
text = bytes.dup.force_encoding(Encoding::UTF_8)
|
|
182
|
+
refuse!("input encoding") unless text.valid_encoding? && !text.include?("\0")
|
|
183
|
+
text.freeze
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def number_key(number) = number.split(".").map(&:to_i)
|
|
187
|
+
def token!(value)
|
|
188
|
+
refuse!("invalid identity") unless value.is_a?(String) && value.match?(Authority::PreparedWork::TOKEN)
|
|
189
|
+
end
|
|
190
|
+
def refuse!(message) = raise(AttemptErrors::ReceiptRejected, "prepared_input_invalid: #{message}")
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
end
|