ace-assign 0.63.1 → 0.64.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.ace-defaults/assign/catalog/steps/create-pr.step.yml +2 -2
- data/.ace-defaults/assign/catalog/steps/mark-pr-ready.step.yml +1 -1
- data/.ace-defaults/assign/catalog/steps/release-minor.step.yml +4 -2
- data/.ace-defaults/assign/catalog/steps/release.step.yml +4 -2
- data/.ace-defaults/assign/catalog/steps/update-pr-desc.step.yml +1 -1
- data/.ace-defaults/assign/presets/fix-bug.yml +15 -3
- data/.ace-defaults/assign/presets/work-on-task-ace-development.yml +16 -4
- data/.ace-defaults/assign/presets/work-on-task-auto-merge.yml +15 -3
- data/.ace-defaults/assign/presets/work-on-task.yml +16 -4
- data/CHANGELOG.md +43 -0
- data/docs/demo/canonical-skill-source.tape.yml +4 -4
- data/docs/protected-authority.md +266 -0
- data/docs/usage.md +91 -2
- data/handbook/workflow-instructions/assign/drive.wf.md +2 -2
- data/handbook/workflow-instructions/assign/prepare.wf.md +2 -2
- data/lib/ace/assign/atoms/delivery_parameters.rb +54 -0
- data/lib/ace/assign/authority/candidate_transfer.rb +160 -0
- data/lib/ace/assign/authority/client.rb +107 -0
- data/lib/ace/assign/authority/deployment.rb +465 -0
- data/lib/ace/assign/authority/endcap.rb +342 -0
- data/lib/ace/assign/authority/endcap_inboxes.rb +314 -0
- data/lib/ace/assign/authority/endcap_results.rb +380 -0
- data/lib/ace/assign/authority/endcap_services.rb +395 -0
- data/lib/ace/assign/authority/evidence_transfer.rb +67 -0
- data/lib/ace/assign/authority/execution_scope_observation.rb +476 -0
- data/lib/ace/assign/authority/launch_driver.rb +114 -0
- data/lib/ace/assign/authority/launch_lifecycle.rb +822 -0
- data/lib/ace/assign/authority/launch_scope_abort.rb +88 -0
- data/lib/ace/assign/authority/launch_scope_admission.rb +84 -0
- data/lib/ace/assign/authority/launch_scope_close.rb +115 -0
- data/lib/ace/assign/authority/launch_scope_parent.rb +44 -0
- data/lib/ace/assign/authority/launch_scope_release.rb +180 -0
- data/lib/ace/assign/authority/posix_acl.rb +56 -0
- data/lib/ace/assign/authority/private_directory.rb +33 -0
- data/lib/ace/assign/authority/receipt_transfer.rb +48 -0
- data/lib/ace/assign/authority/router.rb +54 -0
- data/lib/ace/assign/authority/server.rb +230 -0
- data/lib/ace/assign/authority/service_evidence.rb +55 -0
- data/lib/ace/assign/authority/transfer_codec.rb +155 -0
- data/lib/ace/assign/cli/commands/authority/launch.rb +45 -0
- data/lib/ace/assign/cli/commands/authority/serve.rb +30 -0
- data/lib/ace/assign/cli/commands/authority/status.rb +26 -0
- data/lib/ace/assign/cli/commands/authority/terminate.rb +29 -0
- data/lib/ace/assign/cli/commands/create.rb +9 -1
- data/lib/ace/assign/cli/commands/delivery.rb +57 -0
- data/lib/ace/assign/cli/commands/inbox_reconcile.rb +36 -0
- data/lib/ace/assign/cli/commands/resume.rb +26 -0
- data/lib/ace/assign/cli.rb +22 -1
- data/lib/ace/assign/models/evidence_event.rb +3 -1
- data/lib/ace/assign/molecules/attempt_reconciler.rb +31 -12
- data/lib/ace/assign/molecules/canonical_evidence.rb +150 -0
- data/lib/ace/assign/molecules/evidence_journal.rb +218 -63
- data/lib/ace/assign/molecules/execution_identity_resolver.rb +49 -9
- data/lib/ace/assign/molecules/execution_scope_lineage.rb +327 -0
- data/lib/ace/assign/molecules/journal_mutation.rb +202 -0
- data/lib/ace/assign/molecules/lifecycle_exclusion.rb +4 -0
- data/lib/ace/assign/molecules/proposal_journal.rb +151 -0
- data/lib/ace/assign/molecules/receipt_verifier.rb +49 -19
- data/lib/ace/assign/organisms/assignment_executor.rb +6 -0
- data/lib/ace/assign/organisms/attempt_coordinator.rb +312 -13
- data/lib/ace/assign/organisms/delivery_coordinator.rb +342 -0
- data/lib/ace/assign/organisms/task_assignment_creator.rb +8 -3
- data/lib/ace/assign/version.rb +1 -1
- data/lib/ace/assign.rb +13 -0
- metadata +107 -8
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
require "json"
|
|
3
|
+
require_relative "../../../authority/launch_driver"
|
|
4
|
+
module Ace
|
|
5
|
+
module Assign
|
|
6
|
+
module CLI
|
|
7
|
+
module Commands
|
|
8
|
+
module Authority
|
|
9
|
+
class Terminate < Ace::Support::Cli::Command
|
|
10
|
+
desc "Close the original gated native child and request proven abort"
|
|
11
|
+
option :mapping, required: true, desc: "Installed launch mapping ID"
|
|
12
|
+
option :assignment, required: true, desc: "Assignment ID"
|
|
13
|
+
option :attempt, required: true, desc: "Attempt ID"
|
|
14
|
+
def call(**options)
|
|
15
|
+
client = Ace::Assign::Authority::Client.new(mapping_id: options.fetch(:mapping))
|
|
16
|
+
state = client.call("inspect_launch", {"assignment_id" => options.fetch(:assignment), "attempt_id" => options.fetch(:attempt)}).data
|
|
17
|
+
binding = state.fetch("process_binding") { raise ArgumentError, "no positively recorded original child; supervisor inspection required" }
|
|
18
|
+
result = Ace::Assign::Authority::LaunchDriver.new(mapping_id: options.fetch(:mapping)).terminate(
|
|
19
|
+
state: state, binding: binding, evidence: "protected exact-child termination requested")
|
|
20
|
+
puts JSON.generate(result)
|
|
21
|
+
rescue ArgumentError, Ace::Runtime::Error, Ace::Assign::Error => error
|
|
22
|
+
raise Ace::Support::Cli::Error, error.message
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
|
+
require "json"
|
|
2
3
|
|
|
3
4
|
module Ace
|
|
4
5
|
module Assign
|
|
@@ -10,6 +11,7 @@ module Ace
|
|
|
10
11
|
|
|
11
12
|
desc "Create a new workflow assignment"
|
|
12
13
|
|
|
14
|
+
option :delivery_parameters, desc: "JSON file with forge selection and explicit PR provenance (task mode)"
|
|
13
15
|
option :yaml, desc: "Path to job.yaml config file"
|
|
14
16
|
option :task, aliases: ["-t"], type: :array,
|
|
15
17
|
desc: "Task reference(s), repeatable and comma-separated"
|
|
@@ -19,13 +21,15 @@ module Ace
|
|
|
19
21
|
|
|
20
22
|
def call(yaml: nil, task: nil, preset: nil, **options)
|
|
21
23
|
validate_modes!(yaml, task, preset)
|
|
24
|
+
raise Ace::Support::Cli::Error, "--delivery-parameters requires --task" if yaml && options[:delivery_parameters]
|
|
22
25
|
|
|
23
26
|
result = if yaml
|
|
24
27
|
Organisms::AssignmentExecutor.new.start(yaml)
|
|
25
28
|
else
|
|
26
29
|
Organisms::TaskAssignmentCreator.new.call(
|
|
27
30
|
task_refs: task,
|
|
28
|
-
preset_name: preset || Organisms::TaskAssignmentCreator::DEFAULT_PRESET
|
|
31
|
+
preset_name: preset || Organisms::TaskAssignmentCreator::DEFAULT_PRESET,
|
|
32
|
+
delivery_parameters: options[:delivery_parameters] && JSON.parse(File.read(options[:delivery_parameters]))
|
|
29
33
|
)
|
|
30
34
|
end
|
|
31
35
|
|
|
@@ -34,6 +38,10 @@ module Ace
|
|
|
34
38
|
print_assignment_header(result[:assignment])
|
|
35
39
|
print_step_instructions(result[:current] || result[:state]&.next_workable)
|
|
36
40
|
end
|
|
41
|
+
rescue JSON::ParserError, Errno::ENOENT
|
|
42
|
+
raise Ace::Support::Cli::Error, "Delivery parameters file unavailable or invalid JSON"
|
|
43
|
+
rescue ArgumentError => e
|
|
44
|
+
raise Ace::Support::Cli::Error, e.message
|
|
37
45
|
end
|
|
38
46
|
|
|
39
47
|
private
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
require "json"
|
|
3
|
+
|
|
4
|
+
module Ace
|
|
5
|
+
module Assign
|
|
6
|
+
module CLI
|
|
7
|
+
module Commands
|
|
8
|
+
class Delivery < Ace::Support::Cli::Command
|
|
9
|
+
desc "Execute or reconcile attempt-bound forge delivery"
|
|
10
|
+
option :assignment, required: true, desc: "Managed assignment ID"
|
|
11
|
+
option :attempt, required: true, desc: "Active delivery attempt ID"
|
|
12
|
+
option :operation, required: true, desc: "create, update, ready, review, merge, status"
|
|
13
|
+
option :parameters, desc: "JSON delivery parameters (otherwise assignment source delivery mapping)"
|
|
14
|
+
option :title, desc: "PR title"
|
|
15
|
+
option :body_file, desc: "PR description file"
|
|
16
|
+
option :tests, desc: "JSON reference to coordinator-accepted test attempt and receipt digest"
|
|
17
|
+
option :review, desc: "JSON reference to coordinator-accepted independent review attempt and receipt digest"
|
|
18
|
+
option :service_request, desc: "Completed authorized qjx merge request ID (merge consumption only)"
|
|
19
|
+
|
|
20
|
+
def call(**options)
|
|
21
|
+
result = build_delivery.perform(assignment_id: options[:assignment], attempt_id: options[:attempt],
|
|
22
|
+
operation: options[:operation], parameters: read_json(options[:parameters]),
|
|
23
|
+
title: options[:title], body: options[:body_file] && File.read(options[:body_file]),
|
|
24
|
+
tests: read_json(options[:tests]), review: read_json(options[:review]),
|
|
25
|
+
service_request_id: options[:service_request])
|
|
26
|
+
puts JSON.generate(json_value(result))
|
|
27
|
+
rescue ArgumentError, Ace::Git::Error, Ace::Assign::Error, Errno::ENOENT => e
|
|
28
|
+
raise Ace::Support::Cli::Error, e.message
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
private
|
|
32
|
+
|
|
33
|
+
def build_delivery
|
|
34
|
+
Organisms::DeliveryCoordinator.new(repo_root: Ace::Support::Fs::Molecules::ProjectRootFinder.find_or_current)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def read_json(path)
|
|
38
|
+
return nil unless path
|
|
39
|
+
data = JSON.parse(File.read(path))
|
|
40
|
+
raise ArgumentError, "Delivery input must be a JSON object" unless data.is_a?(Hash)
|
|
41
|
+
data
|
|
42
|
+
rescue JSON::ParserError
|
|
43
|
+
raise ArgumentError, "Delivery input must be valid JSON"
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def json_value(value)
|
|
47
|
+
case value
|
|
48
|
+
when Hash then value.transform_values { |item| json_value(item) }
|
|
49
|
+
when Array then value.map { |item| json_value(item) }
|
|
50
|
+
else value.respond_to?(:to_h) ? json_value(value.to_h) : value
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ace
|
|
4
|
+
module Assign
|
|
5
|
+
module CLI
|
|
6
|
+
module Commands
|
|
7
|
+
class InboxReconcile < Ace::Support::Cli::Command
|
|
8
|
+
include Ace::Support::Cli::Base
|
|
9
|
+
desc "Verify a Herdr inbox proof and record its references in the assignment journal"
|
|
10
|
+
option :attempt, desc: "Exact attempt ID"
|
|
11
|
+
option :event, desc: "Herdr inbox event ID"
|
|
12
|
+
option :receipt, desc: "Existing native receipt JSON file with detached FILE.sig"
|
|
13
|
+
|
|
14
|
+
def initialize(coordinator: nil, inbox: nil)
|
|
15
|
+
super()
|
|
16
|
+
@coordinator = coordinator
|
|
17
|
+
@inbox = inbox
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def call(**options)
|
|
21
|
+
%i[attempt event receipt].each do |key|
|
|
22
|
+
raise Ace::Support::Cli::Error, "--#{key} is required" if options[key].to_s.strip.empty?
|
|
23
|
+
end
|
|
24
|
+
result = (@coordinator || Organisms::AttemptCoordinator.new).reconcile_inbox(
|
|
25
|
+
attempt_id: options[:attempt], event_id: options[:event], receipt_path: options[:receipt],
|
|
26
|
+
inbox: @inbox || Ace::Herdr::Organisms::Inbox.from_config)
|
|
27
|
+
# Keep the public projection bounded; raw native output and the
|
|
28
|
+
# human observation text belong to the existing receipt file.
|
|
29
|
+
puts JSON.generate(result.slice("event_id", "attempt_id", "state", "claim_generation",
|
|
30
|
+
"receipt_key_sha256", "reconciliation_refusal"))
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Ace
|
|
6
|
+
module Assign
|
|
7
|
+
module CLI
|
|
8
|
+
module Commands
|
|
9
|
+
class Resume < Ace::Support::Cli::Command
|
|
10
|
+
include Ace::Support::Cli::Base
|
|
11
|
+
desc "Recover accepted assignment state without launching another writer"
|
|
12
|
+
option :assignment, desc: "Assignment ID"
|
|
13
|
+
option :dry_run, type: :boolean, default: false, desc: "Inspect recovery without writing observations"
|
|
14
|
+
|
|
15
|
+
def call(**options)
|
|
16
|
+
id = options[:assignment].to_s.strip
|
|
17
|
+
raise Ace::Support::Cli::Error, "--assignment ID is required" if id.empty?
|
|
18
|
+
|
|
19
|
+
puts JSON.generate(Organisms::AttemptCoordinator.new.resume(
|
|
20
|
+
assignment_id: id, dry_run: options[:dry_run]))
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
data/lib/ace/assign/cli.rb
CHANGED
|
@@ -44,6 +44,7 @@ require_relative "molecules/runtime_control_surface_runner"
|
|
|
44
44
|
require_relative "molecules/preset_inferrer"
|
|
45
45
|
require_relative "molecules/evidence_calculator"
|
|
46
46
|
require_relative "molecules/evidence_journal"
|
|
47
|
+
require_relative "molecules/canonical_evidence"
|
|
47
48
|
require_relative "molecules/execution_identity_resolver"
|
|
48
49
|
require_relative "molecules/receipt_verifier"
|
|
49
50
|
require_relative "molecules/attempt_reconciler"
|
|
@@ -53,9 +54,12 @@ require_relative "organisms/assignment_executor"
|
|
|
53
54
|
require_relative "organisms/attempt_coordinator"
|
|
54
55
|
|
|
55
56
|
# Commands
|
|
57
|
+
require_relative "cli/commands/delivery"
|
|
56
58
|
require_relative "cli/commands/create"
|
|
57
59
|
require_relative "cli/commands/assignment_target"
|
|
58
60
|
require_relative "cli/commands/status"
|
|
61
|
+
require_relative "cli/commands/resume"
|
|
62
|
+
require_relative "cli/commands/inbox_reconcile"
|
|
59
63
|
require_relative "cli/commands/step"
|
|
60
64
|
require_relative "cli/commands/start"
|
|
61
65
|
require_relative "cli/commands/finish"
|
|
@@ -66,6 +70,10 @@ require_relative "cli/commands/list"
|
|
|
66
70
|
require_relative "cli/commands/select"
|
|
67
71
|
require_relative "cli/commands/fork_run"
|
|
68
72
|
require_relative "cli/commands/fork_session"
|
|
73
|
+
require_relative "cli/commands/authority/serve"
|
|
74
|
+
require_relative "cli/commands/authority/launch"
|
|
75
|
+
require_relative "cli/commands/authority/status"
|
|
76
|
+
require_relative "cli/commands/authority/terminate"
|
|
69
77
|
require_relative "cli/commands/attempt/base"
|
|
70
78
|
require_relative "cli/commands/attempt/start"
|
|
71
79
|
require_relative "cli/commands/attempt/status"
|
|
@@ -84,6 +92,11 @@ module Ace
|
|
|
84
92
|
# Application commands with descriptions (for help output)
|
|
85
93
|
REGISTERED_COMMANDS = [
|
|
86
94
|
["create", "Create assignment from preset or YAML"],
|
|
95
|
+
["authority serve", "Serve the installed protected assignment owner"],
|
|
96
|
+
["authority launch", "Launch one mapped native gated worker"],
|
|
97
|
+
["authority status", "Inspect protected launch ownership"],
|
|
98
|
+
["authority terminate", "Terminate the original native gated child"],
|
|
99
|
+
["delivery", "Execute or reconcile attempt-bound forge delivery"],
|
|
87
100
|
["status", "Show assignment status"],
|
|
88
101
|
["step", "Show step instructions"],
|
|
89
102
|
["start", "Start next workable step"],
|
|
@@ -110,7 +123,8 @@ module Ace
|
|
|
110
123
|
"cat report.md | ace-assign finish # Complete step via stdin",
|
|
111
124
|
"ace-assign fork-run 010.01 # Run subtree in subprocess",
|
|
112
125
|
"ace-assign attempt start --assignment ID --step 010 --project ID",
|
|
113
|
-
"ace-assign attempt finish --attempt ID --receipt receipt.json"
|
|
126
|
+
"ace-assign attempt finish --attempt ID --receipt receipt.json",
|
|
127
|
+
"ace-assign resume --assignment ID --dry-run"
|
|
114
128
|
].freeze
|
|
115
129
|
|
|
116
130
|
# Captured command exit code from last run
|
|
@@ -146,7 +160,12 @@ module Ace
|
|
|
146
160
|
end
|
|
147
161
|
|
|
148
162
|
# Register commands (wrapped to capture exit codes)
|
|
163
|
+
register "authority serve", wrap_command(Commands::Authority::Serve)
|
|
164
|
+
register "authority launch", wrap_command(Commands::Authority::Launch)
|
|
165
|
+
register "authority status", wrap_command(Commands::Authority::Status)
|
|
166
|
+
register "authority terminate", wrap_command(Commands::Authority::Terminate)
|
|
149
167
|
register "create", wrap_command(Commands::Create)
|
|
168
|
+
register "delivery", wrap_command(Commands::Delivery)
|
|
150
169
|
register "status", wrap_command(Commands::Status)
|
|
151
170
|
register "step", wrap_command(Commands::Step)
|
|
152
171
|
register "start", wrap_command(Commands::Start)
|
|
@@ -163,6 +182,8 @@ module Ace
|
|
|
163
182
|
register "attempt evidence", wrap_command(Commands::Attempt::Evidence)
|
|
164
183
|
register "attempt finish", wrap_command(Commands::Attempt::Finish)
|
|
165
184
|
register "attempt reconcile", wrap_command(Commands::Attempt::Reconcile)
|
|
185
|
+
register "resume", wrap_command(Commands::Resume)
|
|
186
|
+
register "inbox-reconcile", wrap_command(Commands::InboxReconcile)
|
|
166
187
|
|
|
167
188
|
# Register version command
|
|
168
189
|
version_cmd = Ace::Support::Cli::VersionCommand.build(
|
|
@@ -17,7 +17,9 @@ module Ace
|
|
|
17
17
|
TYPES = %w[
|
|
18
18
|
intent process_start receipt_accepted transition
|
|
19
19
|
candidate_invalidated reconciliation
|
|
20
|
-
service_claim service_transition
|
|
20
|
+
service_claim service_transition recovery_observation inbox_binding inbox_reconciliation delivery
|
|
21
|
+
authority_mutation evidence_import proposal_state result_submitted
|
|
22
|
+
scope_provisioning scope_bound scope_native_bound scope_child_bound scope_sealed scope_closed_no_writers
|
|
21
23
|
].freeze
|
|
22
24
|
|
|
23
25
|
# Build an event chained to a previous digest.
|
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
3
5
|
module Ace
|
|
4
6
|
module Assign
|
|
5
7
|
module Molecules
|
|
6
8
|
# Conservative classification and resolution of interrupted attempts.
|
|
7
9
|
#
|
|
8
10
|
# Classification of a `running` attempt found after a lost session:
|
|
9
|
-
# - no recorded process start -> `
|
|
11
|
+
# - no recorded process start -> `uncertain` (launch may be unbound)
|
|
10
12
|
# - recorded process still verifiably live -> keep `running`
|
|
11
13
|
# - otherwise -> `uncertain` (the operation may have completed without
|
|
12
14
|
# a durable receipt)
|
|
@@ -19,9 +21,11 @@ module Ace
|
|
|
19
21
|
class AttemptReconciler
|
|
20
22
|
# @param journal [EvidenceJournal, nil] Managed evidence journal
|
|
21
23
|
# @param verifier [ReceiptVerifier, nil] Receipt verifier (coordinator supplies on use)
|
|
22
|
-
def initialize(journal: nil, verifier: nil)
|
|
24
|
+
def initialize(journal: nil, verifier: nil, observer: nil, runtime_resolver: Ace::Runtime)
|
|
23
25
|
@journal = journal
|
|
24
26
|
@verifier = verifier
|
|
27
|
+
@observer = observer || Ace::Runtime::Molecules::ProcessIdentity.new
|
|
28
|
+
@runtime_resolver = runtime_resolver
|
|
25
29
|
end
|
|
26
30
|
|
|
27
31
|
# Classify a running attempt after an interruption.
|
|
@@ -30,8 +34,6 @@ module Ace
|
|
|
30
34
|
# @return [Symbol] :stopped, :live, or :uncertain
|
|
31
35
|
def classify(attempt)
|
|
32
36
|
return :live if process_live?(attempt)
|
|
33
|
-
return :stopped unless process_started?(attempt)
|
|
34
|
-
|
|
35
37
|
:uncertain
|
|
36
38
|
end
|
|
37
39
|
|
|
@@ -44,15 +46,27 @@ module Ace
|
|
|
44
46
|
# @param attempt [Models::Attempt] Attempt to inspect
|
|
45
47
|
# @return [Boolean] True when the recorded process is verifiably alive
|
|
46
48
|
def process_live?(attempt)
|
|
47
|
-
|
|
48
|
-
|
|
49
|
+
observation(attempt)["liveness"] == "live"
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def observation(attempt)
|
|
53
|
+
payload = start_event(attempt)&.dig("payload")
|
|
54
|
+
observation = @observer.observe(payload&.dig("process_identity"))
|
|
55
|
+
binding = payload&.dig("runtime_binding")
|
|
56
|
+
return observation unless binding && observation["liveness"] == "live"
|
|
57
|
+
|
|
58
|
+
live = @runtime_resolver.resolve(binding["runtime"]).process_binding(
|
|
59
|
+
pane: binding["pane"], caller_pid: binding.dig("process_identity", "pid"))
|
|
60
|
+
return observation if binding == live
|
|
49
61
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
62
|
+
observation.merge("liveness" => "unknown", "reason" => "native process/session binding changed")
|
|
63
|
+
rescue Ace::Runtime::Error
|
|
64
|
+
{"liveness" => "unknown", "observed_at" => Time.now.utc.iso8601,
|
|
65
|
+
"reason" => "native process/session observation unavailable"}
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def events(attempt)
|
|
69
|
+
events_for(attempt)
|
|
56
70
|
end
|
|
57
71
|
|
|
58
72
|
# Runtime recorded at the attempt's process start — the boundary any
|
|
@@ -64,6 +78,11 @@ module Ace
|
|
|
64
78
|
start_event(attempt)&.dig("payload", "runtime")
|
|
65
79
|
end
|
|
66
80
|
|
|
81
|
+
def recorded_binding(attempt)
|
|
82
|
+
binding = start_event(attempt)&.dig("payload", "runtime_binding")
|
|
83
|
+
JSON.parse(JSON.generate(binding)) if binding.is_a?(Hash)
|
|
84
|
+
end
|
|
85
|
+
|
|
67
86
|
private
|
|
68
87
|
|
|
69
88
|
def start_event(attempt)
|
|
@@ -0,0 +1,150 @@
|
|
|
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", "inbox" => "signer",
|
|
18
|
+
"observation" => "observer", "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
|
+
Time.iso8601(descriptor.fetch("admitted_at"))
|
|
136
|
+
bytes = yield(reference.fetch("ref"))
|
|
137
|
+
unavailable! unless bytes.is_a?(String) && bytes.bytesize <= MAX_ARTIFACT_BYTES && bytes.bytesize == descriptor["bytes"] &&
|
|
138
|
+
Digest::SHA256.hexdigest(bytes) == descriptor["sha256"]
|
|
139
|
+
bytes
|
|
140
|
+
rescue ArgumentError, KeyError, TypeError
|
|
141
|
+
unavailable!
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def unavailable!
|
|
145
|
+
raise AttemptErrors::EvidenceUnavailable, "Canonical evidence import or binding is unverifiable"
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
end
|