ace-assign 0.70.0 → 0.73.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 +8 -8
- data/.ace-defaults/assign/catalog/steps/create-pr.step.yml +1 -1
- data/.ace-defaults/assign/catalog/steps/mark-pr-ready.step.yml +1 -1
- data/.ace-defaults/assign/config.yml +0 -13
- data/.ace-defaults/assign/presets/fix-bug.yml +1 -1
- data/.ace-defaults/assign/presets/work-on-task-ace-development.yml +1 -1
- data/.ace-defaults/assign/presets/work-on-task-auto-merge.yml +1 -1
- data/.ace-defaults/assign/presets/work-on-task.yml +1 -1
- data/CHANGELOG.md +3 -2099
- data/README.md +58 -31
- data/docs/exit-codes.md +36 -44
- data/docs/getting-started.md +9 -4
- data/docs/usage.md +129 -522
- data/handbook/skills/as-assign-drive/SKILL.md +1 -1
- data/handbook/workflow-instructions/assign/drive.wf.md +47 -118
- data/handbook/workflow-instructions/assign/prepare.wf.md +1 -1
- data/handbook/workflow-instructions/assign/verify-test-suite.wf.md +8 -2
- data/lib/ace/assign/cli/commands/assignment_target.rb +2 -15
- data/lib/ace/assign/cli/commands/create.rb +1 -8
- data/lib/ace/assign/cli/commands/fail.rb +0 -6
- data/lib/ace/assign/cli/commands/finish.rb +2 -23
- data/lib/ace/assign/cli/commands/fork_session.rb +0 -1
- data/lib/ace/assign/cli/commands/select.rb +0 -1
- data/lib/ace/assign/cli/commands/start.rb +0 -2
- data/lib/ace/assign/cli/commands/status.rb +1 -31
- data/lib/ace/assign/cli/commands/step.rb +1 -3
- data/lib/ace/assign/cli.rb +1 -85
- data/lib/ace/assign/errors.rb +8 -80
- data/lib/ace/assign/models/assignment.rb +5 -28
- data/lib/ace/assign/molecules/assignment_manager.rb +0 -26
- data/lib/ace/assign/molecules/fork_session_launcher.rb +4 -12
- data/lib/ace/assign/molecules/lifecycle_exclusion.rb +15 -112
- data/lib/ace/assign/molecules/runtime_control_surface_runner.rb +2 -1
- data/lib/ace/assign/organisms/assignment_executor.rb +0 -6
- data/lib/ace/assign/organisms/task_assignment_creator.rb +3 -8
- data/lib/ace/assign/version.rb +1 -1
- data/lib/ace/assign.rb +0 -6
- metadata +4 -161
- data/.ace-defaults/assign/presets/close-delivery.yml +0 -57
- data/.ace-defaults/assign/presets/deliver-release.yml +0 -60
- data/.ace-defaults/assign/presets/integrate-batch.yml +0 -72
- data/.ace-defaults/assign/presets/supervise-delivery.yml +0 -69
- data/docs/protected-authority.md +0 -282
- data/exe/ace-scope-ready +0 -55
- data/lib/ace/assign/atoms/assignment_scope.rb +0 -58
- data/lib/ace/assign/atoms/attempt_state_machine.rb +0 -84
- data/lib/ace/assign/atoms/delivery_parameters.rb +0 -19
- data/lib/ace/assign/atoms/evidence_digest.rb +0 -78
- data/lib/ace/assign/atoms/fixed_merge_schemas.rb +0 -398
- data/lib/ace/assign/authority/assignment_definition.rb +0 -35
- data/lib/ace/assign/authority/assignment_inventory.rb +0 -290
- data/lib/ace/assign/authority/campaign_consumer_policy.rb +0 -100
- data/lib/ace/assign/authority/campaign_execution.rb +0 -119
- data/lib/ace/assign/authority/campaign_round_transfer.rb +0 -82
- data/lib/ace/assign/authority/candidate_transfer.rb +0 -327
- data/lib/ace/assign/authority/cleanup_result_protection.rb +0 -53
- data/lib/ace/assign/authority/client.rb +0 -257
- data/lib/ace/assign/authority/deployment.rb +0 -627
- data/lib/ace/assign/authority/deployment_history.rb +0 -146
- data/lib/ace/assign/authority/endcap.rb +0 -455
- data/lib/ace/assign/authority/endcap_attempts.rb +0 -274
- data/lib/ace/assign/authority/endcap_campaign_results.rb +0 -232
- data/lib/ace/assign/authority/endcap_campaign_rounds.rb +0 -266
- data/lib/ace/assign/authority/endcap_contexts.rb +0 -23
- data/lib/ace/assign/authority/endcap_inboxes.rb +0 -37
- data/lib/ace/assign/authority/endcap_prepared_work.rb +0 -80
- data/lib/ace/assign/authority/endcap_publication.rb +0 -207
- data/lib/ace/assign/authority/endcap_results.rb +0 -398
- data/lib/ace/assign/authority/endcap_review_requests.rb +0 -183
- data/lib/ace/assign/authority/endcap_reviews.rb +0 -130
- data/lib/ace/assign/authority/endcap_service_settlement.rb +0 -152
- data/lib/ace/assign/authority/endcap_services.rb +0 -598
- data/lib/ace/assign/authority/evidence_transfer.rb +0 -67
- data/lib/ace/assign/authority/execution_scope_observation.rb +0 -903
- data/lib/ace/assign/authority/inbox_context_original.rb +0 -113
- data/lib/ace/assign/authority/launch_attempt_consumers.rb +0 -51
- data/lib/ace/assign/authority/launch_campaign_registration.rb +0 -214
- data/lib/ace/assign/authority/launch_control_channel.rb +0 -313
- data/lib/ace/assign/authority/launch_control_exclusions.rb +0 -127
- data/lib/ace/assign/authority/launch_driver.rb +0 -393
- data/lib/ace/assign/authority/launch_input_inhibition.rb +0 -132
- data/lib/ace/assign/authority/launch_lifecycle.rb +0 -997
- data/lib/ace/assign/authority/launch_prepared_work.rb +0 -122
- data/lib/ace/assign/authority/launch_review.rb +0 -91
- data/lib/ace/assign/authority/launch_scope_abort.rb +0 -81
- data/lib/ace/assign/authority/launch_scope_admission.rb +0 -215
- data/lib/ace/assign/authority/launch_scope_close.rb +0 -146
- data/lib/ace/assign/authority/launch_scope_parent.rb +0 -44
- data/lib/ace/assign/authority/launch_scope_release.rb +0 -859
- data/lib/ace/assign/authority/launch_steering.rb +0 -353
- data/lib/ace/assign/authority/launch_stop.rb +0 -254
- data/lib/ace/assign/authority/maintenance_workspace_retirement.rb +0 -229
- data/lib/ace/assign/authority/ordinary_attempt_start.rb +0 -137
- data/lib/ace/assign/authority/ordinary_client.rb +0 -70
- data/lib/ace/assign/authority/ordinary_completion.rb +0 -94
- data/lib/ace/assign/authority/ordinary_effect_settlement.rb +0 -118
- data/lib/ace/assign/authority/ordinary_lifecycle.rb +0 -191
- data/lib/ace/assign/authority/ordinary_peer.rb +0 -54
- data/lib/ace/assign/authority/ordinary_progress.rb +0 -162
- data/lib/ace/assign/authority/ordinary_queue.rb +0 -115
- data/lib/ace/assign/authority/ordinary_results.rb +0 -196
- data/lib/ace/assign/authority/ordinary_server.rb +0 -75
- data/lib/ace/assign/authority/original_inbox_selection.rb +0 -41
- data/lib/ace/assign/authority/posix_acl.rb +0 -59
- data/lib/ace/assign/authority/prepared_input.rb +0 -130
- data/lib/ace/assign/authority/prepared_queue.rb +0 -185
- data/lib/ace/assign/authority/prepared_task_context.rb +0 -116
- data/lib/ace/assign/authority/prepared_work.rb +0 -304
- data/lib/ace/assign/authority/prepared_worker.rb +0 -69
- data/lib/ace/assign/authority/private_directory.rb +0 -33
- data/lib/ace/assign/authority/protected_assignment_context.rb +0 -226
- data/lib/ace/assign/authority/readiness_hook.rb +0 -120
- data/lib/ace/assign/authority/receipt_transfer.rb +0 -48
- data/lib/ace/assign/authority/router.rb +0 -81
- data/lib/ace/assign/authority/server.rb +0 -131
- data/lib/ace/assign/authority/service_cleanup_dispatch.rb +0 -88
- data/lib/ace/assign/authority/service_cleanup_evidence.rb +0 -180
- data/lib/ace/assign/authority/service_delivery_evidence.rb +0 -101
- data/lib/ace/assign/authority/service_evidence.rb +0 -286
- data/lib/ace/assign/authority/service_publication_evidence.rb +0 -110
- data/lib/ace/assign/authority/socket_server.rb +0 -255
- data/lib/ace/assign/authority/task_context_entry.rb +0 -32
- data/lib/ace/assign/authority/transfer_codec.rb +0 -187
- data/lib/ace/assign/cli/commands/attempt/base.rb +0 -84
- data/lib/ace/assign/cli/commands/attempt/evidence.rb +0 -28
- data/lib/ace/assign/cli/commands/attempt/finish.rb +0 -51
- data/lib/ace/assign/cli/commands/attempt/reconcile.rb +0 -49
- data/lib/ace/assign/cli/commands/attempt/start.rb +0 -45
- data/lib/ace/assign/cli/commands/attempt/status.rb +0 -54
- data/lib/ace/assign/cli/commands/authority/inbox_context_principal.rb +0 -25
- data/lib/ace/assign/cli/commands/authority/inbox_context_selection.rb +0 -28
- data/lib/ace/assign/cli/commands/authority/launch.rb +0 -97
- data/lib/ace/assign/cli/commands/authority/serve.rb +0 -31
- data/lib/ace/assign/cli/commands/authority/status.rb +0 -26
- data/lib/ace/assign/cli/commands/authority/task_context.rb +0 -31
- data/lib/ace/assign/cli/commands/authority/task_context_principal.rb +0 -25
- data/lib/ace/assign/cli/commands/authority/task_context_selection.rb +0 -30
- data/lib/ace/assign/cli/commands/authority/terminate.rb +0 -29
- data/lib/ace/assign/cli/commands/authority/worker.rb +0 -23
- data/lib/ace/assign/cli/commands/campaign_export_result.rb +0 -55
- data/lib/ace/assign/cli/commands/campaign_record_round.rb +0 -57
- data/lib/ace/assign/cli/commands/delivery.rb +0 -107
- data/lib/ace/assign/cli/commands/inbox_bind.rb +0 -34
- data/lib/ace/assign/cli/commands/protected_submission.rb +0 -61
- data/lib/ace/assign/cli/commands/resume.rb +0 -38
- data/lib/ace/assign/cli/commands/submit_candidate.rb +0 -34
- data/lib/ace/assign/cli/commands/submit_result.rb +0 -61
- data/lib/ace/assign/models/attempt.rb +0 -178
- data/lib/ace/assign/models/attempt_binding.rb +0 -116
- data/lib/ace/assign/models/evidence_event.rb +0 -86
- data/lib/ace/assign/models/execution_receipt.rb +0 -139
- data/lib/ace/assign/molecules/attempt_reconciler.rb +0 -127
- data/lib/ace/assign/molecules/attempt_store.rb +0 -201
- data/lib/ace/assign/molecules/canonical_attempt_state.rb +0 -151
- data/lib/ace/assign/molecules/canonical_evidence.rb +0 -153
- data/lib/ace/assign/molecules/canonical_read_commands.rb +0 -84
- data/lib/ace/assign/molecules/canonical_read_snapshot.rb +0 -313
- data/lib/ace/assign/molecules/evidence_calculator.rb +0 -237
- data/lib/ace/assign/molecules/evidence_journal.rb +0 -1912
- data/lib/ace/assign/molecules/execution_identity_resolver.rb +0 -137
- data/lib/ace/assign/molecules/execution_scope_lineage.rb +0 -345
- data/lib/ace/assign/molecules/fixed_effect_reference.rb +0 -31
- data/lib/ace/assign/molecules/installed_journal_read.rb +0 -143
- data/lib/ace/assign/molecules/journal_input_inhibition.rb +0 -78
- data/lib/ace/assign/molecules/journal_mutation.rb +0 -273
- data/lib/ace/assign/molecules/journal_prompt_mutation.rb +0 -241
- data/lib/ace/assign/molecules/ordinary_merge_cli.rb +0 -68
- data/lib/ace/assign/molecules/ordinary_merge_contract.rb +0 -410
- data/lib/ace/assign/molecules/original_launch_binding.rb +0 -37
- data/lib/ace/assign/molecules/proposal_journal.rb +0 -179
- data/lib/ace/assign/molecules/protected_control_exclusion.rb +0 -303
- data/lib/ace/assign/molecules/protected_workspace_exclusion.rb +0 -330
- data/lib/ace/assign/molecules/protected_workspace_provisioner.rb +0 -164
- data/lib/ace/assign/molecules/receipt_verifier.rb +0 -314
- data/lib/ace/assign/molecules/terminal_scope_receipt.rb +0 -88
- data/lib/ace/assign/organisms/attempt_coordinator.rb +0 -1337
- data/lib/ace/assign/organisms/delivery_coordinator.rb +0 -342
- data/lib/ace/assign/organisms/ordinary_merge_consumer.rb +0 -365
- data/lib/ace/assign/organisms/prepared_work_builder.rb +0 -194
- data/lib/ace/assign/organisms/protected_delivery_coordinator.rb +0 -100
|
@@ -1,127 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
require_relative "canonical_attempt_state"
|
|
3
|
-
|
|
4
|
-
require "json"
|
|
5
|
-
|
|
6
|
-
module Ace
|
|
7
|
-
module Assign
|
|
8
|
-
module Molecules
|
|
9
|
-
# Conservative classification and resolution of interrupted attempts.
|
|
10
|
-
#
|
|
11
|
-
# Classification of a `running` attempt found after a lost session:
|
|
12
|
-
# - no recorded process start -> `uncertain` (launch may be unbound)
|
|
13
|
-
# - recorded process still verifiably live -> keep `running`
|
|
14
|
-
# - otherwise -> `uncertain` (the operation may have completed without
|
|
15
|
-
# a durable receipt)
|
|
16
|
-
#
|
|
17
|
-
# Reconciliation resolves an `uncertain` attempt only against verified
|
|
18
|
-
# receipts whose producer runtime matches the recorded execution
|
|
19
|
-
# boundary. It never replays operations: merge, publish, and deploy
|
|
20
|
-
# effects are never retried automatically, and an uncertain attempt
|
|
21
|
-
# without a receipt stays uncertain.
|
|
22
|
-
class AttemptReconciler
|
|
23
|
-
# The protected boundary supplies authenticated original scope facts
|
|
24
|
-
# and exact kernel identities; never a runtime discovered by pane name.
|
|
25
|
-
def self.protected_observation(kernel:, binding:, observer:, lineage:)
|
|
26
|
-
return {"liveness" => "unknown"} if lineage.sealed?
|
|
27
|
-
kernel.live!(binding.fetch("process_identity"))
|
|
28
|
-
kernel.live!(binding.fetch("native_origin").fetch("server_identity"))
|
|
29
|
-
observation = observer.observe(lineage)
|
|
30
|
-
unless observation.is_a?(Hash) && observation["populated"].is_a?(Integer) && observation["populated"].positive?
|
|
31
|
-
return {"liveness" => "unknown"}
|
|
32
|
-
end
|
|
33
|
-
{"liveness" => "live", "identity" => binding.fetch("process_identity"), "runtime_binding" => binding}
|
|
34
|
-
rescue AttemptErrors::EvidenceUnavailable, Ace::Runtime::RuntimeUnavailableError
|
|
35
|
-
{"liveness" => "unknown"}
|
|
36
|
-
end
|
|
37
|
-
|
|
38
|
-
# @param journal [EvidenceJournal, nil] Managed evidence journal
|
|
39
|
-
# @param verifier [ReceiptVerifier, nil] Receipt verifier (coordinator supplies on use)
|
|
40
|
-
def initialize(journal: nil, verifier: nil, observer: nil, runtime_resolver: Ace::Runtime)
|
|
41
|
-
@journal = journal
|
|
42
|
-
@verifier = verifier
|
|
43
|
-
@observer = observer || Ace::Runtime::Molecules::ProcessIdentity.new
|
|
44
|
-
@runtime_resolver = runtime_resolver
|
|
45
|
-
end
|
|
46
|
-
|
|
47
|
-
# Classify a running attempt after an interruption.
|
|
48
|
-
#
|
|
49
|
-
# @param attempt [Models::Attempt] Running attempt
|
|
50
|
-
# @return [Symbol] :stopped, :live, or :uncertain
|
|
51
|
-
def classify(attempt)
|
|
52
|
-
events = events_for(attempt)
|
|
53
|
-
if events.any? { |event| event["type"] == "attempt_stopped" } && CanonicalAttemptState.derive(events) == "stopped"
|
|
54
|
-
return :stopped
|
|
55
|
-
end
|
|
56
|
-
return :live if process_live?(attempt)
|
|
57
|
-
:uncertain
|
|
58
|
-
end
|
|
59
|
-
|
|
60
|
-
# @param attempt [Models::Attempt] Attempt to inspect
|
|
61
|
-
# @return [Boolean] True when a process start was recorded
|
|
62
|
-
def process_started?(attempt)
|
|
63
|
-
!start_event(attempt).nil?
|
|
64
|
-
end
|
|
65
|
-
|
|
66
|
-
# @param attempt [Models::Attempt] Attempt to inspect
|
|
67
|
-
# @return [Boolean] True when the recorded process is verifiably alive
|
|
68
|
-
def process_live?(attempt)
|
|
69
|
-
observation(attempt)["liveness"] == "live"
|
|
70
|
-
end
|
|
71
|
-
|
|
72
|
-
def observation(attempt)
|
|
73
|
-
payload = start_event(attempt)&.dig("payload")
|
|
74
|
-
observation = @observer.observe(payload&.dig("process_identity"))
|
|
75
|
-
binding = payload&.dig("runtime_binding")
|
|
76
|
-
return observation unless binding && observation["liveness"] == "live"
|
|
77
|
-
|
|
78
|
-
live = @runtime_resolver.resolve(binding["runtime"]).process_binding(
|
|
79
|
-
pane: binding["pane"], caller_pid: binding.dig("process_identity", "pid"))
|
|
80
|
-
return observation if binding == live
|
|
81
|
-
|
|
82
|
-
observation.merge("liveness" => "unknown", "reason" => "native process/session binding changed")
|
|
83
|
-
rescue Ace::Runtime::Error
|
|
84
|
-
{"liveness" => "unknown", "observed_at" => Time.now.utc.iso8601,
|
|
85
|
-
"reason" => "native process/session observation unavailable"}
|
|
86
|
-
end
|
|
87
|
-
|
|
88
|
-
def events(attempt)
|
|
89
|
-
events_for(attempt)
|
|
90
|
-
end
|
|
91
|
-
|
|
92
|
-
# Runtime recorded at the attempt's process start — the boundary any
|
|
93
|
-
# reconciling receipt must attribute to.
|
|
94
|
-
#
|
|
95
|
-
# @param attempt [Models::Attempt] Attempt to inspect
|
|
96
|
-
# @return [String, nil] Recorded runtime identity
|
|
97
|
-
def recorded_runtime(attempt)
|
|
98
|
-
start_event(attempt)&.dig("payload", "runtime")
|
|
99
|
-
end
|
|
100
|
-
|
|
101
|
-
def recorded_binding(attempt)
|
|
102
|
-
binding = start_event(attempt)&.dig("payload", "runtime_binding")
|
|
103
|
-
JSON.parse(JSON.generate(binding)) if binding.is_a?(Hash)
|
|
104
|
-
end
|
|
105
|
-
|
|
106
|
-
private
|
|
107
|
-
|
|
108
|
-
def start_event(attempt)
|
|
109
|
-
events_for(attempt).reverse.find do |event|
|
|
110
|
-
event["type"] == "process_start" && event["attempt_id"] == attempt.attempt_id
|
|
111
|
-
end
|
|
112
|
-
end
|
|
113
|
-
|
|
114
|
-
def events_for(attempt)
|
|
115
|
-
events = if attempt.managed?
|
|
116
|
-
(@journal || Molecules::EvidenceJournal.new(repo_root: Dir.pwd))
|
|
117
|
-
.read_events(attempt.binding.assignment_id)
|
|
118
|
-
else
|
|
119
|
-
attempt.events
|
|
120
|
-
end
|
|
121
|
-
# Only this attempt's events define its execution boundary.
|
|
122
|
-
events.select { |event| event["attempt_id"] == attempt.attempt_id }
|
|
123
|
-
end
|
|
124
|
-
end
|
|
125
|
-
end
|
|
126
|
-
end
|
|
127
|
-
end
|
|
@@ -1,201 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require "fileutils"
|
|
4
|
-
require "json"
|
|
5
|
-
|
|
6
|
-
module Ace
|
|
7
|
-
module Assign
|
|
8
|
-
module Molecules
|
|
9
|
-
# Durable local attempt state: the single authority for attempt records
|
|
10
|
-
# and active subtree ownership.
|
|
11
|
-
#
|
|
12
|
-
# Layout under the assignment cache directory (`.ace-local/assign` by
|
|
13
|
-
# default, which remains disposable session/projection data):
|
|
14
|
-
# - `<assignment-id>/attempts/records/<attempt-id>.json` — full attempt
|
|
15
|
-
# record, written atomically (temp file + rename).
|
|
16
|
-
# - `<assignment-id>/attempts/active/<ownership-key>.json` — pointer to
|
|
17
|
-
# the one active attempt owning an assignment + normalized subtree.
|
|
18
|
-
# - `<assignment-id>/attempts/.lock` — flock serialization for
|
|
19
|
-
# claim/release and record transitions.
|
|
20
|
-
#
|
|
21
|
-
# Only one active attempt owns a subtree; claim replaces the pointer
|
|
22
|
-
# only for the identical binding, and release clears it only when it
|
|
23
|
-
# still references the given attempt.
|
|
24
|
-
class AttemptStore
|
|
25
|
-
# @param cache_base [String] Base cache directory
|
|
26
|
-
def initialize(cache_base: nil)
|
|
27
|
-
@cache_base = cache_base || Ace::Assign.cache_dir
|
|
28
|
-
end
|
|
29
|
-
|
|
30
|
-
# @param assignment_id [String] Assignment ID
|
|
31
|
-
# @return [String] Attempts directory for the assignment
|
|
32
|
-
def attempts_dir(assignment_id)
|
|
33
|
-
File.join(@cache_base, assignment_id, "attempts")
|
|
34
|
-
end
|
|
35
|
-
|
|
36
|
-
# Serialize a block under the assignment's exclusive attempt lock.
|
|
37
|
-
#
|
|
38
|
-
# @param assignment_id [String] Assignment ID
|
|
39
|
-
# @yield Block executed while holding the lock
|
|
40
|
-
def with_lock(assignment_id)
|
|
41
|
-
FileUtils.mkdir_p(attempts_dir(assignment_id))
|
|
42
|
-
File.open(lock_path(assignment_id), File::RDWR | File::CREAT) do |lock|
|
|
43
|
-
lock.flock(File::LOCK_EX)
|
|
44
|
-
begin
|
|
45
|
-
yield
|
|
46
|
-
ensure
|
|
47
|
-
lock.flock(File::LOCK_UN)
|
|
48
|
-
end
|
|
49
|
-
end
|
|
50
|
-
end
|
|
51
|
-
|
|
52
|
-
# Persist an attempt record atomically.
|
|
53
|
-
#
|
|
54
|
-
# @param attempt [Models::Attempt] Attempt to persist
|
|
55
|
-
def save(attempt)
|
|
56
|
-
FileUtils.mkdir_p(records_dir(attempt.binding.assignment_id))
|
|
57
|
-
atomic_write(
|
|
58
|
-
record_path(attempt.binding.assignment_id, attempt.attempt_id),
|
|
59
|
-
JSON.pretty_generate(attempt.to_h)
|
|
60
|
-
)
|
|
61
|
-
end
|
|
62
|
-
|
|
63
|
-
# Load an attempt record.
|
|
64
|
-
#
|
|
65
|
-
# @param assignment_id [String] Assignment ID
|
|
66
|
-
# @param attempt_id [String] Attempt ID
|
|
67
|
-
# @return [Models::Attempt, nil] Attempt or nil when absent/corrupt
|
|
68
|
-
def load(assignment_id, attempt_id)
|
|
69
|
-
path = record_path(assignment_id, attempt_id)
|
|
70
|
-
return nil unless File.exist?(path)
|
|
71
|
-
|
|
72
|
-
Models::Attempt.from_h(JSON.parse(File.read(path)))
|
|
73
|
-
rescue JSON::ParserError, KeyError, ArgumentError, TypeError => e
|
|
74
|
-
warn "ace-assign: corrupt attempt record #{path}: #{e.message}" if Ace::Assign.debug?
|
|
75
|
-
nil
|
|
76
|
-
end
|
|
77
|
-
|
|
78
|
-
# The active attempt owning an assignment + scope, if any.
|
|
79
|
-
#
|
|
80
|
-
# @param assignment_id [String] Assignment ID
|
|
81
|
-
# @param scope [String] Step/subtree scope
|
|
82
|
-
# @return [Models::Attempt, nil] Active attempt or nil
|
|
83
|
-
def active(assignment_id, scope)
|
|
84
|
-
pointer = active_path(assignment_id, scope)
|
|
85
|
-
return nil unless File.exist?(pointer)
|
|
86
|
-
|
|
87
|
-
entry = JSON.parse(File.read(pointer))
|
|
88
|
-
attempt = load(assignment_id, entry["attempt_id"])
|
|
89
|
-
return nil unless attempt&.active?
|
|
90
|
-
|
|
91
|
-
attempt
|
|
92
|
-
rescue JSON::ParserError, TypeError
|
|
93
|
-
nil
|
|
94
|
-
end
|
|
95
|
-
|
|
96
|
-
# Claim subtree ownership for an attempt. Callers must hold
|
|
97
|
-
# {with_lock} for read-modify-write atomicity.
|
|
98
|
-
#
|
|
99
|
-
# @param assignment_id [String] Assignment ID
|
|
100
|
-
# @param scope [String] Canonical scope
|
|
101
|
-
# @param attempt [Models::Attempt] Active attempt claiming the scope
|
|
102
|
-
def claim(assignment_id, scope, attempt)
|
|
103
|
-
FileUtils.mkdir_p(active_dir(assignment_id))
|
|
104
|
-
atomic_write(
|
|
105
|
-
active_path(assignment_id, scope),
|
|
106
|
-
JSON.generate({"attempt_id" => attempt.attempt_id})
|
|
107
|
-
)
|
|
108
|
-
end
|
|
109
|
-
|
|
110
|
-
# Release subtree ownership when it still references the attempt.
|
|
111
|
-
#
|
|
112
|
-
# @param assignment_id [String] Assignment ID
|
|
113
|
-
# @param scope [String] Canonical scope
|
|
114
|
-
# @param attempt_id [String] Attempt releasing ownership
|
|
115
|
-
def release(assignment_id, scope, attempt_id)
|
|
116
|
-
pointer = active_path(assignment_id, scope)
|
|
117
|
-
return unless File.exist?(pointer)
|
|
118
|
-
|
|
119
|
-
entry = JSON.parse(File.read(pointer))
|
|
120
|
-
File.delete(pointer) if entry["attempt_id"] == attempt_id
|
|
121
|
-
rescue JSON::ParserError, TypeError
|
|
122
|
-
nil
|
|
123
|
-
end
|
|
124
|
-
|
|
125
|
-
# Atomically reserve an attempt ID across all assignments
|
|
126
|
-
# (mkdir-based EEXIST check). Returns false when already taken.
|
|
127
|
-
#
|
|
128
|
-
# @param attempt_id [String] Candidate ID
|
|
129
|
-
# @return [Boolean] True when newly reserved
|
|
130
|
-
def reserve_id(attempt_id)
|
|
131
|
-
registry = File.join(@cache_base, ".attempt-ids")
|
|
132
|
-
FileUtils.mkdir_p(registry)
|
|
133
|
-
Dir.mkdir(File.join(registry, attempt_id))
|
|
134
|
-
true
|
|
135
|
-
rescue Errno::EEXIST
|
|
136
|
-
false
|
|
137
|
-
end
|
|
138
|
-
|
|
139
|
-
# Locate an attempt record by ID across all assignments (used by
|
|
140
|
-
# reconcile, which receives only the attempt ID).
|
|
141
|
-
#
|
|
142
|
-
# @param attempt_id [String] Attempt ID
|
|
143
|
-
# @return [Models::Attempt, nil] Attempt or nil
|
|
144
|
-
def find(attempt_id)
|
|
145
|
-
path = Dir.glob(File.join(@cache_base, "*", "attempts", "records", "#{attempt_id}.json")).first
|
|
146
|
-
return nil unless path
|
|
147
|
-
|
|
148
|
-
assignment_id = File.basename(File.dirname(File.dirname(File.dirname(path))))
|
|
149
|
-
load(assignment_id, attempt_id)
|
|
150
|
-
end
|
|
151
|
-
|
|
152
|
-
# All attempt records for an assignment, newest first. Ties (records
|
|
153
|
-
# serialized within the same second) break deterministically by
|
|
154
|
-
# attempt ID.
|
|
155
|
-
#
|
|
156
|
-
# @param assignment_id [String] Assignment ID
|
|
157
|
-
# @return [Array<Models::Attempt>] Persisted attempts
|
|
158
|
-
def list(assignment_id)
|
|
159
|
-
Dir.glob(File.join(records_dir(assignment_id), "*.json"))
|
|
160
|
-
.map { |path| load(assignment_id, File.basename(path, ".json")) }
|
|
161
|
-
.compact
|
|
162
|
-
.sort_by { |attempt| [attempt.updated_at.utc, attempt.attempt_id] }
|
|
163
|
-
.reverse
|
|
164
|
-
end
|
|
165
|
-
|
|
166
|
-
private
|
|
167
|
-
|
|
168
|
-
def records_dir(assignment_id)
|
|
169
|
-
File.join(attempts_dir(assignment_id), "records")
|
|
170
|
-
end
|
|
171
|
-
|
|
172
|
-
def active_dir(assignment_id)
|
|
173
|
-
File.join(attempts_dir(assignment_id), "active")
|
|
174
|
-
end
|
|
175
|
-
|
|
176
|
-
def lock_path(assignment_id)
|
|
177
|
-
File.join(attempts_dir(assignment_id), ".lock")
|
|
178
|
-
end
|
|
179
|
-
|
|
180
|
-
def record_path(assignment_id, attempt_id)
|
|
181
|
-
File.join(records_dir(assignment_id), "#{attempt_id}.json")
|
|
182
|
-
end
|
|
183
|
-
|
|
184
|
-
def active_path(assignment_id, scope)
|
|
185
|
-
File.join(active_dir(assignment_id), Atoms::AssignmentScope.ownership_key(assignment_id, scope))
|
|
186
|
-
end
|
|
187
|
-
|
|
188
|
-
# Atomic same-filesystem write: temp file plus rename.
|
|
189
|
-
def atomic_write(path, content)
|
|
190
|
-
dir = File.dirname(path)
|
|
191
|
-
FileUtils.mkdir_p(dir)
|
|
192
|
-
temp = File.join(dir, ".tmp-#{Process.pid}-#{rand(1_000_000)}")
|
|
193
|
-
File.write(temp, content)
|
|
194
|
-
File.rename(temp, path)
|
|
195
|
-
ensure
|
|
196
|
-
File.delete(temp) if temp && File.exist?(temp)
|
|
197
|
-
end
|
|
198
|
-
end
|
|
199
|
-
end
|
|
200
|
-
end
|
|
201
|
-
end
|
|
@@ -1,151 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require_relative "execution_scope_lineage"
|
|
4
|
-
require_relative "../atoms/attempt_state_machine"
|
|
5
|
-
|
|
6
|
-
module Ace
|
|
7
|
-
module Assign
|
|
8
|
-
module Molecules
|
|
9
|
-
# Canonical state projection, not live closure or settlement authority.
|
|
10
|
-
# The stopped producer and terminal consumer must additionally obtain the
|
|
11
|
-
# existing owners' authenticated original-prefix evidence and fresh proof.
|
|
12
|
-
module CanonicalAttemptState
|
|
13
|
-
STOPPED_FIELDS = %w[project_id mapping_id assignment_id attempt_id descriptor_sha256
|
|
14
|
-
scope_binding_event_id seal_event_id closed_proof_event_id service_settlement_event_digests].freeze
|
|
15
|
-
SELECTION_FIELDS = (STOPPED_FIELDS - %w[service_settlement_event_digests]).freeze
|
|
16
|
-
|
|
17
|
-
def self.derive(events)
|
|
18
|
-
return nil unless events.any? { |event| event["type"] == "intent" }
|
|
19
|
-
state = "reserved"
|
|
20
|
-
stopped = false
|
|
21
|
-
events.each do |event|
|
|
22
|
-
if stopped && %w[process_start receipt_accepted transition reconciliation attempt_stopped].include?(event["type"])
|
|
23
|
-
unavailable!("Accepted stopped attempt cannot be resurrected or replaced")
|
|
24
|
-
end
|
|
25
|
-
case event["type"]
|
|
26
|
-
when "process_start" then state = "running"
|
|
27
|
-
when "receipt_accepted" then state = event.dig("payload", "receipt", "verdict") || state
|
|
28
|
-
when "transition" then state = event.dig("payload", "to") || state
|
|
29
|
-
when "reconciliation" then state = event.dig("payload", "resolution") || state
|
|
30
|
-
when "attempt_stopped"
|
|
31
|
-
verify_stopped_acceptance!(events, event, prior_state: state)
|
|
32
|
-
state = "stopped"
|
|
33
|
-
stopped = true
|
|
34
|
-
end
|
|
35
|
-
end
|
|
36
|
-
state
|
|
37
|
-
end
|
|
38
|
-
|
|
39
|
-
def self.stopped_payload!(events:, selection:, service_evidence:, commit:)
|
|
40
|
-
unless selection.is_a?(Hash) && selection.keys.sort == SELECTION_FIELDS.sort &&
|
|
41
|
-
service_evidence.is_a?(Hash) && service_evidence.keys.sort == %w[commit services] &&
|
|
42
|
-
commit.is_a?(String) && commit.match?(/\A(?:[0-9a-f]{40}|[0-9a-f]{64})\z/) &&
|
|
43
|
-
service_evidence["commit"] == commit
|
|
44
|
-
unavailable!("Stopped plan evidence prefix differs")
|
|
45
|
-
end
|
|
46
|
-
services = service_evidence.fetch("services")
|
|
47
|
-
unless services.is_a?(Array) &&
|
|
48
|
-
services.all? { |item| item.is_a?(Hash) && item.keys.sort == %w[event_digest evidence_refs receipt_digest record_digest request_id state] }
|
|
49
|
-
unavailable!("Stopped settlement projections are malformed")
|
|
50
|
-
end
|
|
51
|
-
service_ids = services.map { |item| item.fetch("request_id") }
|
|
52
|
-
unless service_ids == service_ids.sort && service_ids.uniq == service_ids
|
|
53
|
-
unavailable!("Stopped settlement projection is not ordered unique owner output")
|
|
54
|
-
end
|
|
55
|
-
services.each do |item|
|
|
56
|
-
event = events.find { |entry| entry["digest"] == item.fetch("event_digest") && entry["type"] == "service_transition" }
|
|
57
|
-
unless event && event.dig("payload", "request_id") == item.fetch("request_id") &&
|
|
58
|
-
event.dig("payload", "state") == item.fetch("state") && %w[succeeded failed-settled].include?(item.fetch("state")) &&
|
|
59
|
-
event.dig("payload", "record_digest") == item.fetch("record_digest") &&
|
|
60
|
-
event.dig("payload", "receipt_digest") == item.fetch("receipt_digest")
|
|
61
|
-
unavailable!("Stopped service projection differs from canonical terminal event")
|
|
62
|
-
end
|
|
63
|
-
end
|
|
64
|
-
payload = selection.merge("service_settlement_event_digests" => services.map { |item| item.fetch("event_digest") }.sort)
|
|
65
|
-
validate_stopped_payload!(events, payload)
|
|
66
|
-
payload
|
|
67
|
-
rescue KeyError, TypeError, ArgumentError, NoMethodError
|
|
68
|
-
unavailable!("Stopped plan evidence is malformed")
|
|
69
|
-
end
|
|
70
|
-
|
|
71
|
-
def self.verify_stopped_acceptance!(events, event, prior_state:)
|
|
72
|
-
Atoms::AttemptStateMachine.proof_stopped_transition!(prior_state)
|
|
73
|
-
unless events.count { |entry| entry["type"] == "attempt_stopped" } == 1 && Models::EvidenceEvent.chain_valid?(events)
|
|
74
|
-
unavailable!("Stopped canonical chain is ambiguous or corrupt")
|
|
75
|
-
end
|
|
76
|
-
payload = event.fetch("payload")
|
|
77
|
-
prefix = events.take_while { |entry| entry["digest"] != event.fetch("digest") }
|
|
78
|
-
validate_stopped_payload!(prefix, payload)
|
|
79
|
-
acceptance = events.find { |entry| entry["type"] == "authority_mutation" && entry["previous_digest"] == event.fetch("digest") }
|
|
80
|
-
data = acceptance&.dig("payload", "data")
|
|
81
|
-
generation = acceptance && events.take_while { |entry| entry["digest"] != acceptance["digest"] }.count { |entry| entry["type"] == "authority_mutation" } + 1
|
|
82
|
-
unless acceptance && data.is_a?(Hash) && data.keys.sort == %w[attempt_id generation proof_id required_action state] &&
|
|
83
|
-
data["generation"].is_a?(Integer) && data["generation"] == generation &&
|
|
84
|
-
acceptance.dig("payload", "operation") == "stop_attempt" &&
|
|
85
|
-
acceptance.dig("payload", "assignment_id") == payload.fetch("assignment_id") &&
|
|
86
|
-
acceptance.dig("payload", "attempt_id") == payload.fetch("attempt_id") &&
|
|
87
|
-
acceptance.dig("payload", "data", "attempt_id") == payload.fetch("attempt_id") &&
|
|
88
|
-
acceptance.dig("payload", "data", "state") == "stopped" &&
|
|
89
|
-
acceptance.dig("payload", "data", "proof_id") == payload.fetch("closed_proof_event_id") &&
|
|
90
|
-
acceptance.dig("payload", "data", "required_action").nil?
|
|
91
|
-
unavailable!("Stopped transition has no adjacent accepted owner mutation")
|
|
92
|
-
end
|
|
93
|
-
seal = prefix.find { |entry| entry["digest"] == payload.fetch("seal_event_id") }
|
|
94
|
-
accepted_seal = seal && prefix.find { |entry| entry["type"] == "authority_mutation" && entry["previous_digest"] == seal["digest"] &&
|
|
95
|
-
%w[close_execution_scope stop_attempt].include?(entry.dig("payload", "operation")) }
|
|
96
|
-
proof = prefix.find { |entry| entry["digest"] == payload.fetch("closed_proof_event_id") }
|
|
97
|
-
accepted_proof = proof && prefix.find { |entry| entry["type"] == "authority_mutation" && entry["previous_digest"] == proof["digest"] &&
|
|
98
|
-
%w[close_execution_scope stop_attempt].include?(entry.dig("payload", "operation")) &&
|
|
99
|
-
entry.dig("payload", "data", "proof_id") == proof["digest"] &&
|
|
100
|
-
(entry.dig("payload", "operation") == "close_execution_scope" ?
|
|
101
|
-
entry.dig("payload", "data", "state") == "closed_no_writers" :
|
|
102
|
-
entry.dig("payload", "data").is_a?(Hash) && entry.dig("payload", "data").keys.sort == %w[attempt_id generation proof_id required_action state] &&
|
|
103
|
-
entry.dig("payload", "data", "attempt_id") == payload.fetch("attempt_id") &&
|
|
104
|
-
entry.dig("payload", "data", "generation").is_a?(Integer) &&
|
|
105
|
-
entry.dig("payload", "data", "generation") == prefix.take_while { |item| item["digest"] != entry["digest"] }.count { |item| item["type"] == "authority_mutation" } + 1 &&
|
|
106
|
-
entry.dig("payload", "data", "state") == "uncertain" &&
|
|
107
|
-
entry.dig("payload", "data", "required_action") == "reconcile_scope") }
|
|
108
|
-
unless accepted_seal && (accepted_proof || event["previous_digest"] == proof&.fetch("digest"))
|
|
109
|
-
unavailable!("Stopped canonical seal or proof has no accepted owner")
|
|
110
|
-
end
|
|
111
|
-
event
|
|
112
|
-
rescue KeyError, TypeError, ArgumentError, NoMethodError, AttemptErrors::InvalidTransition
|
|
113
|
-
unavailable!("Stopped canonical acceptance is malformed")
|
|
114
|
-
end
|
|
115
|
-
|
|
116
|
-
def self.validate_stopped_payload!(events, payload)
|
|
117
|
-
unless payload.is_a?(Hash) && payload.keys.sort == STOPPED_FIELDS.sort &&
|
|
118
|
-
%w[project_id mapping_id assignment_id attempt_id].all? { |key| payload[key].is_a?(String) && ExecutionScopeLineage::ID.match?(payload[key]) } &&
|
|
119
|
-
%w[descriptor_sha256 scope_binding_event_id seal_event_id closed_proof_event_id].all? { |key| digest?(payload[key]) } &&
|
|
120
|
-
%w[service_settlement_event_digests].all? { |key|
|
|
121
|
-
values = payload[key]
|
|
122
|
-
values.is_a?(Array) && values.all? { |value| digest?(value) } && values == values.sort && values.uniq == values }
|
|
123
|
-
unavailable!("Stopped canonical payload is not closed")
|
|
124
|
-
end
|
|
125
|
-
lineage = ExecutionScopeLineage.new(events: events, **payload.slice("project_id", "mapping_id", "assignment_id", "attempt_id").transform_keys(&:to_sym))
|
|
126
|
-
provisioning = events.select { |event| event["type"] == "scope_provisioning" }
|
|
127
|
-
unless provisioning.one? && provisioning.first.dig("payload", "descriptor_sha256") == payload.fetch("descriptor_sha256") &&
|
|
128
|
-
lineage.native_event && lineage.child_event &&
|
|
129
|
-
lineage.binding_event&.fetch("digest") == payload.fetch("scope_binding_event_id") &&
|
|
130
|
-
lineage.seal_event&.fetch("digest") == payload.fetch("seal_event_id") &&
|
|
131
|
-
lineage.proof_event&.fetch("digest") == payload.fetch("closed_proof_event_id")
|
|
132
|
-
unavailable!("Stopped canonical original scope selectors differ")
|
|
133
|
-
end
|
|
134
|
-
payload.fetch("service_settlement_event_digests").each do |digest|
|
|
135
|
-
unless events.any? { |event| event["digest"] == digest && event["type"] == "service_transition" &&
|
|
136
|
-
%w[succeeded failed-settled].include?(event.dig("payload", "state")) }
|
|
137
|
-
unavailable!("Stopped canonical service selector is not terminal evidence")
|
|
138
|
-
end
|
|
139
|
-
end
|
|
140
|
-
payload
|
|
141
|
-
rescue KeyError, TypeError, ArgumentError, NoMethodError
|
|
142
|
-
unavailable!("Stopped canonical scope is malformed")
|
|
143
|
-
end
|
|
144
|
-
|
|
145
|
-
def self.digest?(value) = value.is_a?(String) && ExecutionScopeLineage::DIGEST.match?(value)
|
|
146
|
-
def self.unavailable!(message) = raise AttemptErrors::EvidenceUnavailable, message
|
|
147
|
-
private_class_method :digest?, :unavailable!
|
|
148
|
-
end
|
|
149
|
-
end
|
|
150
|
-
end
|
|
151
|
-
end
|
|
@@ -1,153 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require "digest"
|
|
4
|
-
require "securerandom"
|
|
5
|
-
require "time"
|
|
6
|
-
|
|
7
|
-
module Ace
|
|
8
|
-
module Assign
|
|
9
|
-
module Molecules
|
|
10
|
-
# One import/verification contract for protected review, service and
|
|
11
|
-
# inbox evidence. Filesystem projections never supply accepted bytes.
|
|
12
|
-
class CanonicalEvidence
|
|
13
|
-
MAX_ARTIFACT_BYTES = 64 * 1024
|
|
14
|
-
MAX_TOTAL_BYTES = 256 * 1024
|
|
15
|
-
MAX_ARTIFACTS = 16
|
|
16
|
-
ID = /\A[a-zA-Z0-9][a-zA-Z0-9_.-]{0,127}\z/
|
|
17
|
-
ROLES = {"service" => "executor", "review" => "reviewer",
|
|
18
|
-
"result" => "worker"}.freeze
|
|
19
|
-
DESCRIPTOR_FIELDS = %w[version artifact_id kind project_id assignment_id attempt_id peer_uid role
|
|
20
|
-
binding_digest sha256 bytes admitted_at admitted_after_event_digest request_id_or_event_id
|
|
21
|
-
candidate_generation_or_claim_generation].freeze
|
|
22
|
-
|
|
23
|
-
# Shared closed descriptor validation before clients accept any bytes.
|
|
24
|
-
def self.valid_descriptor?(descriptor)
|
|
25
|
-
return false unless descriptor.is_a?(Hash) && descriptor.keys.sort == DESCRIPTOR_FIELDS.sort &&
|
|
26
|
-
descriptor["version"] == 1 && ROLES.key?(descriptor["kind"]) && ROLES[descriptor["kind"]] == descriptor["role"] &&
|
|
27
|
-
%w[artifact_id project_id assignment_id attempt_id request_id_or_event_id].all? { |key| descriptor[key].is_a?(String) && descriptor[key].match?(ID) } &&
|
|
28
|
-
%w[binding_digest sha256 admitted_after_event_digest].all? { |key| descriptor[key].is_a?(String) && descriptor[key].match?(/\A[0-9a-f]{64}\z/) } &&
|
|
29
|
-
descriptor["peer_uid"].is_a?(Integer) && descriptor["peer_uid"] >= 0 &&
|
|
30
|
-
descriptor["bytes"].is_a?(Integer) && descriptor["bytes"].between?(0, MAX_ARTIFACT_BYTES) &&
|
|
31
|
-
descriptor["candidate_generation_or_claim_generation"].is_a?(Integer) && descriptor["candidate_generation_or_claim_generation"] >= 0 &&
|
|
32
|
-
descriptor["admitted_at"].is_a?(String)
|
|
33
|
-
Time.iso8601(descriptor.fetch("admitted_at"))
|
|
34
|
-
true
|
|
35
|
-
rescue ArgumentError, TypeError
|
|
36
|
-
false
|
|
37
|
-
end
|
|
38
|
-
|
|
39
|
-
def initialize(journal:)
|
|
40
|
-
@journal = journal
|
|
41
|
-
end
|
|
42
|
-
|
|
43
|
-
# Construct the immutable bytes/events for EvidenceJournal#mutate.
|
|
44
|
-
# peer_uid comes from the accepted kernel-authenticated connection;
|
|
45
|
-
# callers of the wire API cannot supply provenance or artifact IDs.
|
|
46
|
-
def import_plan(kind:, project_id:, assignment_id:, attempt_id:, peer_uid:, binding:,
|
|
47
|
-
request_id_or_event_id:, generation:, admitted_after_event_digest:, artifacts:)
|
|
48
|
-
role = ROLES.fetch(kind) { raise ArgumentError, "invalid evidence kind" }
|
|
49
|
-
ids = [project_id, assignment_id, attempt_id, request_id_or_event_id]
|
|
50
|
-
unless ids.all? { |id| id.is_a?(String) && id.match?(ID) } && binding.is_a?(Hash) &&
|
|
51
|
-
admitted_after_event_digest.is_a?(String) && admitted_after_event_digest.match?(/\A[0-9a-f]{64}\z/) &&
|
|
52
|
-
peer_uid.is_a?(Integer) && peer_uid >= 0 && generation.is_a?(Integer) && generation >= 0 &&
|
|
53
|
-
artifacts.is_a?(Array) && !artifacts.empty? && artifacts.length <= MAX_ARTIFACTS &&
|
|
54
|
-
artifacts.all? { |bytes| bytes.is_a?(String) && bytes.bytesize <= MAX_ARTIFACT_BYTES } &&
|
|
55
|
-
artifacts.sum(&:bytesize) <= MAX_TOTAL_BYTES
|
|
56
|
-
raise ArgumentError, "invalid or oversized canonical evidence import"
|
|
57
|
-
end
|
|
58
|
-
events = []
|
|
59
|
-
blobs = {}
|
|
60
|
-
references = artifacts.map do |bytes|
|
|
61
|
-
artifact_id = SecureRandom.hex(16)
|
|
62
|
-
artifact_id += "-no-effect" if kind == "service" && binding["no_effect_challenge"]
|
|
63
|
-
digest = Digest::SHA256.hexdigest(bytes.b)
|
|
64
|
-
path = "evidence/imports/#{artifact_id}"
|
|
65
|
-
descriptor = {"version" => 1, "artifact_id" => artifact_id, "kind" => kind,
|
|
66
|
-
"project_id" => project_id, "assignment_id" => assignment_id,
|
|
67
|
-
"attempt_id" => attempt_id, "peer_uid" => peer_uid, "role" => role,
|
|
68
|
-
"binding_digest" => Atoms::EvidenceDigest.digest(binding), "sha256" => digest,
|
|
69
|
-
"bytes" => bytes.bytesize, "admitted_at" => Time.now.utc.iso8601(9),
|
|
70
|
-
"admitted_after_event_digest" => admitted_after_event_digest,
|
|
71
|
-
"request_id_or_event_id" => request_id_or_event_id,
|
|
72
|
-
"candidate_generation_or_claim_generation" => generation}
|
|
73
|
-
events << {type: "evidence_import", payload: descriptor}
|
|
74
|
-
blobs[path] = bytes.b
|
|
75
|
-
{"ref" => path, "sha256" => digest}
|
|
76
|
-
end
|
|
77
|
-
{events: events, blobs: blobs, references: references}
|
|
78
|
-
end
|
|
79
|
-
|
|
80
|
-
def read(reference, kind:, project_id:, assignment_id:, attempt_id:, peer_uid:, binding:,
|
|
81
|
-
request_id_or_event_id:, generation:, commit: @journal.ref_value)
|
|
82
|
-
# One immutable commit supplies both provenance events and bytes.
|
|
83
|
-
events = @journal.read_events(assignment_id, commit: commit)
|
|
84
|
-
.select { |event| event["attempt_id"] == attempt_id }
|
|
85
|
-
verified_bytes(reference, events: events, kind: kind, project_id: project_id,
|
|
86
|
-
assignment_id: assignment_id, attempt_id: attempt_id, peer_uid: peer_uid, binding: binding,
|
|
87
|
-
request_id_or_event_id: request_id_or_event_id, generation: generation) do |path|
|
|
88
|
-
@journal.blob(path, commit: commit)
|
|
89
|
-
end
|
|
90
|
-
end
|
|
91
|
-
|
|
92
|
-
# Only the journal writer supplies this transient pre-CAS view. It
|
|
93
|
-
# validates the exact same provenance/chain/bytes as committed reads;
|
|
94
|
-
# it does not accept a receipt or advance the canonical ref itself.
|
|
95
|
-
def read_pending(reference, current_events:, pending_events:, blobs:, commit:,
|
|
96
|
-
kind:, project_id:, assignment_id:, attempt_id:, peer_uid:, binding:,
|
|
97
|
-
request_id_or_event_id:, generation:)
|
|
98
|
-
events = current_events + pending_events
|
|
99
|
-
unless events.all? { |event| event["attempt_id"] == attempt_id } && blobs.is_a?(Hash)
|
|
100
|
-
unavailable!
|
|
101
|
-
end
|
|
102
|
-
verified_bytes(reference, events: events, kind: kind, project_id: project_id,
|
|
103
|
-
assignment_id: assignment_id, attempt_id: attempt_id, peer_uid: peer_uid, binding: binding,
|
|
104
|
-
request_id_or_event_id: request_id_or_event_id, generation: generation) do |path|
|
|
105
|
-
blobs.key?(path) ? blobs.fetch(path) : @journal.blob(path, commit: commit)
|
|
106
|
-
end
|
|
107
|
-
end
|
|
108
|
-
|
|
109
|
-
private
|
|
110
|
-
|
|
111
|
-
def verified_bytes(reference, events:, kind:, project_id:, assignment_id:, attempt_id:, peer_uid:, binding:,
|
|
112
|
-
request_id_or_event_id:, generation:)
|
|
113
|
-
unless reference.is_a?(Hash) && reference.keys.sort == %w[ref sha256] &&
|
|
114
|
-
reference["ref"].is_a?(String) && reference["ref"].match?(%r{\Aevidence/imports/[a-z0-9-]+\z})
|
|
115
|
-
unavailable!
|
|
116
|
-
end
|
|
117
|
-
unavailable! unless Models::EvidenceEvent.chain_valid?(events)
|
|
118
|
-
artifact_id = reference.fetch("ref").delete_prefix("evidence/imports/")
|
|
119
|
-
imports = events.select do |event|
|
|
120
|
-
event["type"] == "evidence_import" && event.dig("payload", "artifact_id") == artifact_id
|
|
121
|
-
end
|
|
122
|
-
unavailable! unless imports.length == 1
|
|
123
|
-
event = imports.first
|
|
124
|
-
descriptor = event.fetch("payload")
|
|
125
|
-
expected = {"version" => 1, "artifact_id" => artifact_id, "kind" => kind,
|
|
126
|
-
"project_id" => project_id, "assignment_id" => assignment_id,
|
|
127
|
-
"attempt_id" => attempt_id, "peer_uid" => peer_uid, "role" => ROLES.fetch(kind),
|
|
128
|
-
"binding_digest" => Atoms::EvidenceDigest.digest(binding),
|
|
129
|
-
"sha256" => reference.fetch("sha256"), "request_id_or_event_id" => request_id_or_event_id,
|
|
130
|
-
"candidate_generation_or_claim_generation" => generation}
|
|
131
|
-
unavailable! unless self.class.valid_descriptor?(descriptor) &&
|
|
132
|
-
expected.all? { |key, value| descriptor[key] == value }
|
|
133
|
-
before = events.take_while { |entry| entry["digest"] != event["digest"] }
|
|
134
|
-
unavailable! unless before.any? { |entry| entry["digest"] == descriptor["admitted_after_event_digest"] }
|
|
135
|
-
if kind == "service" && binding["no_effect_challenge"]
|
|
136
|
-
unavailable! unless descriptor["admitted_after_event_digest"] == binding.fetch("challenge_event_digest")
|
|
137
|
-
end
|
|
138
|
-
Time.iso8601(descriptor.fetch("admitted_at"))
|
|
139
|
-
bytes = yield(reference.fetch("ref"))
|
|
140
|
-
unavailable! unless bytes.is_a?(String) && bytes.bytesize <= MAX_ARTIFACT_BYTES && bytes.bytesize == descriptor["bytes"] &&
|
|
141
|
-
Digest::SHA256.hexdigest(bytes) == descriptor["sha256"]
|
|
142
|
-
bytes
|
|
143
|
-
rescue ArgumentError, KeyError, TypeError
|
|
144
|
-
unavailable!
|
|
145
|
-
end
|
|
146
|
-
|
|
147
|
-
def unavailable!
|
|
148
|
-
raise AttemptErrors::EvidenceUnavailable, "Canonical evidence import or binding is unverifiable"
|
|
149
|
-
end
|
|
150
|
-
end
|
|
151
|
-
end
|
|
152
|
-
end
|
|
153
|
-
end
|