ace-hitl 0.17.0 → 0.23.2
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/CHANGELOG.md +162 -0
- data/README.md +60 -117
- data/docs/usage.md +247 -421
- data/handbook/workflow-instructions/hitl.wf.md +55 -6
- data/lib/ace/hitl/adapters/registry.rb +65 -0
- data/lib/ace/hitl/adapters/terminal.rb +75 -0
- data/lib/ace/hitl/adapters.rb +80 -0
- data/lib/ace/hitl/atoms/hitl_address.rb +89 -0
- data/lib/ace/hitl/atoms/hitl_event_text.rb +54 -0
- data/lib/ace/hitl/cli/commands/create.rb +81 -12
- data/lib/ace/hitl/cli/commands/lifecycle_command.rb +1 -7
- data/lib/ace/hitl/cli/commands/list.rb +22 -1
- data/lib/ace/hitl/cli/commands/send.rb +85 -0
- data/lib/ace/hitl/cli/commands/serve.rb +25 -44
- data/lib/ace/hitl/cli/commands/show.rb +23 -3
- data/lib/ace/hitl/cli/commands/update.rb +4 -0
- data/lib/ace/hitl/cli/commands/wait.rb +5 -0
- data/lib/ace/hitl/cli.rb +8 -18
- data/lib/ace/hitl/lifecycle/frame_serving.rb +0 -15
- data/lib/ace/hitl/lifecycle/kinds.rb +1 -1
- data/lib/ace/hitl/lifecycle/lifecycle_requests.rb +0 -36
- data/lib/ace/hitl/lifecycle/policy.rb +0 -44
- data/lib/ace/hitl/lifecycle/protocol.rb +1 -2
- data/lib/ace/hitl/lifecycle/service.rb +2 -3
- data/lib/ace/hitl/lifecycle/store.rb +11 -75
- data/lib/ace/hitl/lifecycle/tls_access_policy.rb +0 -14
- data/lib/ace/hitl/lifecycle/tls_project_scope.rb +0 -15
- data/lib/ace/hitl/lifecycle/tls_service.rb +2 -3
- data/lib/ace/hitl/lifecycle.rb +2 -4
- data/lib/ace/hitl/live_client.rb +11 -87
- data/lib/ace/hitl/models/hitl_event.rb +35 -0
- data/lib/ace/hitl/molecules/hitl_address_resolver.rb +80 -0
- data/lib/ace/hitl/molecules/hitl_config_loader.rb +22 -1
- data/lib/ace/hitl/molecules/hitl_creator.rb +126 -27
- data/lib/ace/hitl/molecules/hitl_delivery_state.rb +58 -0
- data/lib/ace/hitl/molecules/hitl_display_formatter.rb +3 -2
- data/lib/ace/hitl/molecules/hitl_loader.rb +19 -2
- data/lib/ace/hitl/molecules/hitl_route_loader.rb +108 -0
- data/lib/ace/hitl/organisms/hitl_manager.rb +73 -6
- data/lib/ace/hitl/organisms/hitl_sender.rb +148 -0
- data/lib/ace/hitl/providers/lab.rb +15 -129
- data/lib/ace/hitl/version.rb +1 -1
- data/lib/ace/hitl.rb +8 -1
- metadata +12 -43
- data/lib/ace/hitl/cli/commands/ask.rb +0 -136
- data/lib/ace/hitl/cli/commands/overseer_ack.rb +0 -30
- data/lib/ace/hitl/cli/commands/overseer_pending.rb +0 -28
- data/lib/ace/hitl/cli/commands/overseer_send.rb +0 -35
- data/lib/ace/hitl/cli/commands/proposal.rb +0 -75
- data/lib/ace/hitl/lifecycle/overseer.rb +0 -111
- data/lib/ace/hitl/lifecycle/proposals.rb +0 -297
- data/lib/ace/hitl/lifecycle/service_publication_binding.rb +0 -97
- data/lib/ace/hitl/proposals/evaluator.rb +0 -29
- data/lib/ace/hitl/proposals/policy.rb +0 -127
- data/lib/ace/hitl/providers/lab/assignment_binding.rb +0 -126
- data/lib/ace/hitl/providers/lab/protected_assignment_binding.rb +0 -75
- data/lib/ace/hitl/providers/providers.rb +0 -33
|
@@ -1,28 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require "ace/support/cli"
|
|
4
|
-
require_relative "lifecycle_command"
|
|
5
|
-
|
|
6
|
-
module Ace
|
|
7
|
-
module Hitl
|
|
8
|
-
module CLI
|
|
9
|
-
module Commands
|
|
10
|
-
# Host-broker drain view of the Overseer response outbox.
|
|
11
|
-
class OverseerPending < Ace::Support::Cli::Command
|
|
12
|
-
include Ace::Support::Cli::Base
|
|
13
|
-
include LifecycleCommand
|
|
14
|
-
|
|
15
|
-
desc "List queued Overseer responses (host-broker operation)"
|
|
16
|
-
|
|
17
|
-
option :quiet, type: :boolean, aliases: %w[-q], desc: "Suppress non-essential output"
|
|
18
|
-
|
|
19
|
-
def call(**options)
|
|
20
|
-
emit(overseer.pending)
|
|
21
|
-
rescue Lifecycle::Error => e
|
|
22
|
-
raise_lifecycle_error(e.message)
|
|
23
|
-
end
|
|
24
|
-
end
|
|
25
|
-
end
|
|
26
|
-
end
|
|
27
|
-
end
|
|
28
|
-
end
|
|
@@ -1,35 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require "ace/support/cli"
|
|
4
|
-
require_relative "lifecycle_command"
|
|
5
|
-
|
|
6
|
-
module Ace
|
|
7
|
-
module Hitl
|
|
8
|
-
module CLI
|
|
9
|
-
module Commands
|
|
10
|
-
# The Root Overseer's bounded, type-tagged response through the
|
|
11
|
-
# reverse address (spec 8wm.t.y21 §6). The response is read from
|
|
12
|
-
# stdin.
|
|
13
|
-
class OverseerSend < Ace::Support::Cli::Command
|
|
14
|
-
include Ace::Support::Cli::Base
|
|
15
|
-
include LifecycleCommand
|
|
16
|
-
|
|
17
|
-
desc "Queue a bounded, type-tagged Overseer response (stdin)"
|
|
18
|
-
|
|
19
|
-
option :"reply-to", type: :string, desc: "Source message id this response answers"
|
|
20
|
-
|
|
21
|
-
option :quiet, type: :boolean, aliases: %w[-q], desc: "Suppress non-essential output"
|
|
22
|
-
|
|
23
|
-
def call(**options)
|
|
24
|
-
emit(overseer.send_response(
|
|
25
|
-
reply_to: options[:"reply-to"] || "",
|
|
26
|
-
reader: LifecycleCommand::STDIN_READER
|
|
27
|
-
))
|
|
28
|
-
rescue Lifecycle::Error => e
|
|
29
|
-
raise_lifecycle_error(e.message)
|
|
30
|
-
end
|
|
31
|
-
end
|
|
32
|
-
end
|
|
33
|
-
end
|
|
34
|
-
end
|
|
35
|
-
end
|
|
@@ -1,75 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require "ace/support/cli"
|
|
4
|
-
require_relative "lifecycle_command"
|
|
5
|
-
require_relative "../../proposals/evaluator"
|
|
6
|
-
|
|
7
|
-
module Ace
|
|
8
|
-
module Hitl
|
|
9
|
-
module CLI
|
|
10
|
-
module Commands
|
|
11
|
-
class Proposal < Ace::Support::Cli::Command
|
|
12
|
-
include Ace::Support::Cli::Base
|
|
13
|
-
include LifecycleCommand
|
|
14
|
-
argument :operation, required: true, desc: "create, show, revise, history or resolve-due"
|
|
15
|
-
argument :id, required: false, desc: "Exact proposal ID"
|
|
16
|
-
option :assignment, type: :string
|
|
17
|
-
option :attempt, type: :string
|
|
18
|
-
option :project, type: :string
|
|
19
|
-
option :file, type: :string
|
|
20
|
-
option :"expected-revision", type: :integer, desc: "Exact source revision for revise"
|
|
21
|
-
option :"operation-id", type: :string, desc: "Caller-persisted revision- plus 24 lowercase hex digits"
|
|
22
|
-
option :format, type: :string, default: "json"
|
|
23
|
-
option :now, type: :string, desc: "Unavailable in production; inject a fixture clock in tests"
|
|
24
|
-
option :"history-after", type: :integer, desc: "Read the next bounded history page from history_next"
|
|
25
|
-
option :query, type: :string, desc: "Relevant prior decisions containing this text"
|
|
26
|
-
option :after, type: :string, desc: "History-list cursor from next"
|
|
27
|
-
|
|
28
|
-
def call(operation:, id: nil, **options)
|
|
29
|
-
raise_lifecycle_error("only --format json is supported") unless options[:format] == "json"
|
|
30
|
-
raise_lifecycle_error("--now is restricted to injected test fixtures; production uses trusted UTC clock") if options[:now]
|
|
31
|
-
result = case operation
|
|
32
|
-
when "create"
|
|
33
|
-
%i[assignment attempt project].each { |key| raise_lifecycle_error("--#{key} required") if options[key].to_s.empty? }
|
|
34
|
-
raise_lifecycle_error("stable proposal ID required") if id.to_s.empty?
|
|
35
|
-
lifecycle_client.proposal_create(id: id, assignment: options[:assignment], attempt: options[:attempt],
|
|
36
|
-
project: options[:project], document: load_document(options[:file]))
|
|
37
|
-
when "show"
|
|
38
|
-
raise_lifecycle_error("proposal ID required") if id.to_s.empty?
|
|
39
|
-
raise_lifecycle_error("--project required") if options[:project].to_s.empty?
|
|
40
|
-
lifecycle_client.proposal_show(id, project: options[:project], history_after: options[:"history-after"] || 0)
|
|
41
|
-
when "revise"
|
|
42
|
-
raise_lifecycle_error("proposal ID required") if id.to_s.empty?
|
|
43
|
-
raise_lifecycle_error("--expected-revision required") unless options[:"expected-revision"]
|
|
44
|
-
raise_lifecycle_error("--operation-id required") if options[:"operation-id"].to_s.empty?
|
|
45
|
-
raise_lifecycle_error("--project required") if options[:project].to_s.empty?
|
|
46
|
-
lifecycle_client.proposal_revise(id, project: options[:project], expected_revision: options[:"expected-revision"],
|
|
47
|
-
operation_id: options[:"operation-id"], document: load_document(options[:file]))
|
|
48
|
-
when "resolve-due"
|
|
49
|
-
raise_lifecycle_error("--project required") if options[:project].to_s.empty?
|
|
50
|
-
Proposals::Evaluator.new(boundary: lifecycle_client, project: options[:project]).call
|
|
51
|
-
when "history"
|
|
52
|
-
raise_lifecycle_error("--project required") if options[:project].to_s.empty?
|
|
53
|
-
lifecycle_client.proposal_history(project: options[:project], query: options[:query] || "", after: options[:after])
|
|
54
|
-
else raise_lifecycle_error("choose create, show, revise, history or resolve-due")
|
|
55
|
-
end
|
|
56
|
-
emit(result)
|
|
57
|
-
rescue Lifecycle::Error, Providers::ProviderUnavailableError => e
|
|
58
|
-
raise_lifecycle_error(e.message)
|
|
59
|
-
end
|
|
60
|
-
|
|
61
|
-
private
|
|
62
|
-
|
|
63
|
-
def load_document(path)
|
|
64
|
-
raise_lifecycle_error("--file required") if path.to_s.empty?
|
|
65
|
-
content = File.open(path, "rb") { |file| file.read(4097) }
|
|
66
|
-
raise_lifecycle_error("proposal file exceeds 4096 bytes") if content.bytesize > 4096
|
|
67
|
-
JSON.parse(content)
|
|
68
|
-
rescue JSON::ParserError, SystemCallError
|
|
69
|
-
raise_lifecycle_error("proposal file must be readable JSON")
|
|
70
|
-
end
|
|
71
|
-
end
|
|
72
|
-
end
|
|
73
|
-
end
|
|
74
|
-
end
|
|
75
|
-
end
|
|
@@ -1,111 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require "securerandom"
|
|
4
|
-
require_relative "errors"
|
|
5
|
-
require_relative "identity"
|
|
6
|
-
require_relative "atomic_json"
|
|
7
|
-
require_relative "kinds"
|
|
8
|
-
|
|
9
|
-
module Ace
|
|
10
|
-
module Hitl
|
|
11
|
-
module Lifecycle
|
|
12
|
-
# The Root Overseer's bounded response channel — the reverse
|
|
13
|
-
# address of the HITL conversation (spec 8wm.t.y21 §6; ports of
|
|
14
|
-
# overseer-send/overseer-pending/overseer-ack). Responses are
|
|
15
|
-
# bounded, type-tagged, and free of internal identifiers; the
|
|
16
|
-
# transport that relays them is NOT part of this surface.
|
|
17
|
-
class Overseer
|
|
18
|
-
MAX_RESPONSE = 1200
|
|
19
|
-
TYPE_TAGS = %w[[decyzja] [pytanie] [info]].freeze
|
|
20
|
-
TYPE_TAG = /\A\[(decyzja|pytanie|info)\]/i
|
|
21
|
-
|
|
22
|
-
FULL_SHA = /\b[0-9a-fA-F]{40,64}\b/
|
|
23
|
-
ATTEMPT_REFERENCE = /\bA-[0-9a-fA-F]{24}\b/
|
|
24
|
-
WORK_REFERENCE = /\bW[0-9]{3,}\b/
|
|
25
|
-
TASK_REFERENCE = /\b[0-9a-z]{3,}\.t\.[0-9a-z.]+\b/i
|
|
26
|
-
SHA_WORD = /\bSHA(?:-?256)?\b/i
|
|
27
|
-
|
|
28
|
-
attr_reader :outbox_dir
|
|
29
|
-
|
|
30
|
-
def initialize(outbox_dir:, overseer_user: "mo", ownership: AtomicJson::DEFAULT_OWNERSHIP,
|
|
31
|
-
identity: Identity)
|
|
32
|
-
@outbox_dir = Pathname.new(outbox_dir)
|
|
33
|
-
@overseer_user = overseer_user
|
|
34
|
-
@ownership = ownership
|
|
35
|
-
@identity = identity
|
|
36
|
-
end
|
|
37
|
-
|
|
38
|
-
# Queue one bounded, type-tagged response (read from the reader,
|
|
39
|
-
# e.g. stdin). Only the overseer user may respond.
|
|
40
|
-
def send_response(reader:, reply_to: "")
|
|
41
|
-
unless @identity.username == @overseer_user
|
|
42
|
-
raise PermissionError, "only the Root Overseer can send channel responses"
|
|
43
|
-
end
|
|
44
|
-
reply_to = reply_to.to_s
|
|
45
|
-
if !reply_to.empty? && (!/\A\d+\z/.match?(reply_to) || reply_to.length > 32)
|
|
46
|
-
raise StateError, "invalid source message id"
|
|
47
|
-
end
|
|
48
|
-
|
|
49
|
-
response = reader.call(MAX_RESPONSE + 1).to_s.strip
|
|
50
|
-
if response.empty? || response.length > MAX_RESPONSE || response.include?("\u0000")
|
|
51
|
-
raise StateError, "response must contain 1-#{MAX_RESPONSE} characters"
|
|
52
|
-
end
|
|
53
|
-
unless TYPE_TAG.match?(response)
|
|
54
|
-
raise StateError,
|
|
55
|
-
"response must open with a type tag: #{TYPE_TAGS.join(", ")}"
|
|
56
|
-
end
|
|
57
|
-
if FULL_SHA.match?(response) || ATTEMPT_REFERENCE.match?(response) ||
|
|
58
|
-
WORK_REFERENCE.match?(response) || TASK_REFERENCE.match?(response) ||
|
|
59
|
-
SHA_WORD.match?(response)
|
|
60
|
-
raise StateError,
|
|
61
|
-
"rewrite for Captain without SHA, Work/Attempt/task IDs, hashes, or internal references"
|
|
62
|
-
end
|
|
63
|
-
|
|
64
|
-
message_id = "msg-#{SecureRandom.hex(8)}"
|
|
65
|
-
value = {
|
|
66
|
-
"id" => message_id,
|
|
67
|
-
"reply_to_message_id" => reply_to,
|
|
68
|
-
"response" => response,
|
|
69
|
-
"created_at" => Time.now.to_i,
|
|
70
|
-
"requester" => @overseer_user
|
|
71
|
-
}
|
|
72
|
-
AtomicJson.call(@outbox_dir.join("#{message_id}.json"), value, mode: 0o600, ownership_strategy: @ownership)
|
|
73
|
-
response.clear
|
|
74
|
-
{
|
|
75
|
-
"id" => message_id,
|
|
76
|
-
"queued" => true,
|
|
77
|
-
"reply_to_message_id" => reply_to
|
|
78
|
-
}
|
|
79
|
-
end
|
|
80
|
-
|
|
81
|
-
# Root-only drain view of the outbox.
|
|
82
|
-
def pending
|
|
83
|
-
require_root!("overseer-pending")
|
|
84
|
-
@outbox_dir.glob("*.json").sort.filter_map { |path| AtomicJson.read(path) }
|
|
85
|
-
end
|
|
86
|
-
|
|
87
|
-
# Root-only acknowledgement: the transport removes one response
|
|
88
|
-
# exactly once it has been relayed.
|
|
89
|
-
def ack(message_id)
|
|
90
|
-
require_root!("overseer-ack")
|
|
91
|
-
id = message_id.to_s
|
|
92
|
-
raise StateError, "invalid message id" unless Kinds::REQUEST_ID.match?(id)
|
|
93
|
-
|
|
94
|
-
path = @outbox_dir.join("#{id}.json")
|
|
95
|
-
raise StateError, "unknown Overseer response" unless path.exist?
|
|
96
|
-
|
|
97
|
-
path.unlink
|
|
98
|
-
{"id" => id, "acknowledged" => true}
|
|
99
|
-
end
|
|
100
|
-
|
|
101
|
-
private
|
|
102
|
-
|
|
103
|
-
def require_root!(operation)
|
|
104
|
-
unless @identity.root?
|
|
105
|
-
raise Lifecycle::PermissionError, "#{operation} is a host-broker operation"
|
|
106
|
-
end
|
|
107
|
-
end
|
|
108
|
-
end
|
|
109
|
-
end
|
|
110
|
-
end
|
|
111
|
-
end
|
|
@@ -1,297 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require_relative "../proposals/policy"
|
|
4
|
-
|
|
5
|
-
module Ace
|
|
6
|
-
module Hitl
|
|
7
|
-
module Lifecycle
|
|
8
|
-
module Proposals
|
|
9
|
-
def proposal_create(id:, assignment:, attempt:, project:, document:)
|
|
10
|
-
require_proposer!(project)
|
|
11
|
-
content = Ace::Hitl::Proposals::Policy.document(document)
|
|
12
|
-
unless id.is_a?(String) && id.match?(/\Aproposal-[0-9a-f]{24}\z/)
|
|
13
|
-
raise StateError, "create requires a stable proposal ID"
|
|
14
|
-
end
|
|
15
|
-
existing = proposal_journal(project).proposal_record(id)
|
|
16
|
-
if existing && existing.values_at("assignment_id", "attempt_id", "project_id", "requester", "caller_uid", "content_digest") !=
|
|
17
|
-
[assignment, attempt, project, @identity.username, @identity.uid, Digest::SHA256.hexdigest(JSON.generate(content))]
|
|
18
|
-
raise StateError, "proposal retry changed immutable content or caller"
|
|
19
|
-
end
|
|
20
|
-
proposal_revision(id, 1, assignment: assignment, attempt: attempt, project: project, content: content)
|
|
21
|
-
end
|
|
22
|
-
|
|
23
|
-
def proposal_revise(id, project:, expected_revision:, operation_id:, document:)
|
|
24
|
-
old = proposal_access!(id, project: project, requester: true)
|
|
25
|
-
require_proposer!(old["project_id"])
|
|
26
|
-
unless expected_revision.is_a?(Integer) && expected_revision.positive? &&
|
|
27
|
-
operation_id.is_a?(String) && operation_id.match?(/\Arevision-[0-9a-f]{24}\z/)
|
|
28
|
-
raise StateError, "revise requires an expected source revision and stable revision operation ID"
|
|
29
|
-
end
|
|
30
|
-
content = Ace::Hitl::Proposals::Policy.document(document)
|
|
31
|
-
replay = proposal_revision_replay(id, expected_revision, operation_id, content, project: project)
|
|
32
|
-
if replay
|
|
33
|
-
project_proposal_request(replay)
|
|
34
|
-
return replay
|
|
35
|
-
end
|
|
36
|
-
proposal_revision(id, expected_revision + 1, assignment: old["assignment_id"],
|
|
37
|
-
attempt: old["attempt_id"], project: old["project_id"], content: content,
|
|
38
|
-
expected_revision: expected_revision, operation_id: operation_id)
|
|
39
|
-
rescue Ace::Assign::AttemptErrors::ReceiptRejected => e
|
|
40
|
-
raise StateError, "Proposal revision refused (#{e.message})"
|
|
41
|
-
end
|
|
42
|
-
|
|
43
|
-
def proposal_show(id, project:, history_after: 0)
|
|
44
|
-
record = proposal_access!(id, project: project)
|
|
45
|
-
unless history_after.is_a?(Integer) && history_after >= 0
|
|
46
|
-
raise StateError, "history cursor must be a non-negative offset"
|
|
47
|
-
end
|
|
48
|
-
history = proposal_journal(project).proposal_history(id)
|
|
49
|
-
entries = (history.slice(history_after, 3) || []).map do |entry|
|
|
50
|
-
outcome = proposal_journal(project).proposal_claims(entry["revision_id"]).last
|
|
51
|
-
entry.merge("actual_outcome" => outcome && outcome["state"],
|
|
52
|
-
"effect_request_id" => outcome && outcome["request_id"])
|
|
53
|
-
end
|
|
54
|
-
claims = proposal_journal(project).proposal_claims(record["revision_id"])
|
|
55
|
-
claim = claims.last
|
|
56
|
-
outcome = case claim && claim["state"]
|
|
57
|
-
when "pending" then "queued"
|
|
58
|
-
when "accepted" then "executing"
|
|
59
|
-
when "succeeded" then "succeeded"
|
|
60
|
-
when "failed", "failed-settled", "rejected" then "failed"
|
|
61
|
-
when "uncertain" then "uncertain"
|
|
62
|
-
end
|
|
63
|
-
record.merge("state" => outcome || record["state"], "decision_state" => record["state"],
|
|
64
|
-
"effect_claim" => claim, "history" => entries, "history_count" => history.length,
|
|
65
|
-
"history_next" => history_after + entries.length < history.length ? history_after + entries.length : nil)
|
|
66
|
-
end
|
|
67
|
-
|
|
68
|
-
def proposal_acknowledge(request, project:, submitted_at:)
|
|
69
|
-
record = proposal_for_request!(request, project: project)
|
|
70
|
-
require_transport!("proposal delivery", {"project" => record["project_id"]})
|
|
71
|
-
proposal_transition(record) { |current| Ace::Hitl::Proposals::Policy.acknowledge(current, submitted_at) }
|
|
72
|
-
end
|
|
73
|
-
|
|
74
|
-
def proposal_reply(request, project:, answer:, received_at:, sequence:)
|
|
75
|
-
record = proposal_for_request!(request, project: project)
|
|
76
|
-
require_transport!("proposal reply", {"project" => record["project_id"]})
|
|
77
|
-
proposal_transition(record) do |current|
|
|
78
|
-
# Claims and this transition share one canonical ref lock/CAS.
|
|
79
|
-
claimed = proposal_journal(project).proposal_claims(current["revision_id"]).any?
|
|
80
|
-
applied = proposal_journal(project).proposal_history(current["proposal_id"]).find do |entry|
|
|
81
|
-
entry["revision_id"] == current["revision_id"] && entry["reply_sequence"] == sequence
|
|
82
|
-
end
|
|
83
|
-
Ace::Hitl::Proposals::Policy.reply(current, applied: applied, answer: answer, received_at: received_at,
|
|
84
|
-
sequence: sequence, claimed: claimed)
|
|
85
|
-
end
|
|
86
|
-
end
|
|
87
|
-
|
|
88
|
-
def proposal_reconcile(request, project:, checkpoint:)
|
|
89
|
-
record = proposal_for_request!(request, project: project)
|
|
90
|
-
require_transport!("proposal reconciliation", {"project" => record["project_id"]})
|
|
91
|
-
proposal_transition(record) do |current|
|
|
92
|
-
next current unless silence_delegated?(current)
|
|
93
|
-
Ace::Hitl::Proposals::Policy.resolve(current, checkpoint: checkpoint, now: proposal_now)
|
|
94
|
-
end
|
|
95
|
-
end
|
|
96
|
-
|
|
97
|
-
def proposal_wake(project:, after: nil)
|
|
98
|
-
raise StateError, "invalid proposal wake project" unless project.is_a?(String) && Kinds::SAFE_LABEL.match?(project)
|
|
99
|
-
require_proposer!(project)
|
|
100
|
-
records = proposal_journal(project).proposals.sort_by { |record| record["proposal_id"] }.select do |record|
|
|
101
|
-
record["project_id"] == project && (!after || record["proposal_id"] > after) &&
|
|
102
|
-
record["state"] == "awaiting-decision" && record["deadline"] <= proposal_now
|
|
103
|
-
end
|
|
104
|
-
selected = records.first(8)
|
|
105
|
-
items = selected.map do |record|
|
|
106
|
-
proposal_transition(record) { |current| current["reconcile_requested_at"] ? current : current.merge("reconcile_requested_at" => proposal_now) }
|
|
107
|
-
{"proposal_id" => record["proposal_id"], "status" => "queued-for-transport"}
|
|
108
|
-
end
|
|
109
|
-
{"items" => items, "next" => records.length > 8 ? selected.last["proposal_id"] : nil}
|
|
110
|
-
end
|
|
111
|
-
|
|
112
|
-
def proposal_due(project:, after: nil)
|
|
113
|
-
require_transport!("proposal deadlines", {"project" => project})
|
|
114
|
-
records = proposal_journal(project).proposals.sort_by { |record| record["proposal_id"] }.select do |record|
|
|
115
|
-
record["project_id"] == project && (!after || record["proposal_id"] > after) &&
|
|
116
|
-
@policy.transport?(@identity, project: record["project_id"]) &&
|
|
117
|
-
record["state"] == "awaiting-decision" && record["deadline"] <= proposal_now && record["reconcile_requested_at"]
|
|
118
|
-
end
|
|
119
|
-
selected = records.first(8)
|
|
120
|
-
{"items" => selected.map { |record| record.slice("proposal_id", "request_id", "deadline") },
|
|
121
|
-
"next" => records.length > 8 ? selected.last["proposal_id"] : nil}
|
|
122
|
-
end
|
|
123
|
-
|
|
124
|
-
def proposal_history(project:, query: "", after: nil)
|
|
125
|
-
unless query.is_a?(String) && query.bytesize <= 240 && project.is_a?(String)
|
|
126
|
-
raise StateError, "history requires a project and bounded query"
|
|
127
|
-
end
|
|
128
|
-
records = proposal_journal(project).proposals.sort_by { |record| record["proposal_id"] }.select do |record|
|
|
129
|
-
next false unless record["project_id"] == project && (!after || record["proposal_id"] > after)
|
|
130
|
-
visible = (record["requester"] == @identity.username && record["caller_uid"] == @identity.uid &&
|
|
131
|
-
@policy.respond_to?(:proposal?) && @policy.proposal?(@identity, project: project)) ||
|
|
132
|
-
@policy.transport?(@identity, project: project)
|
|
133
|
-
visible && (query.empty? || JSON.generate(record.slice("operation", "target", "context", "recommendation")).downcase.include?(query.downcase))
|
|
134
|
-
end
|
|
135
|
-
selected = records.first(3)
|
|
136
|
-
{"items" => selected, "next" => records.length > 3 ? selected.last["proposal_id"] : nil}
|
|
137
|
-
end
|
|
138
|
-
|
|
139
|
-
private
|
|
140
|
-
|
|
141
|
-
def silence_delegated?(record)
|
|
142
|
-
@policy.proposal_silence?(caller_uid: record["caller_uid"], requester: record["requester"],
|
|
143
|
-
project: record["project_id"], operation: record["operation"])
|
|
144
|
-
end
|
|
145
|
-
|
|
146
|
-
def require_proposer!(project)
|
|
147
|
-
unless @policy.respond_to?(:proposal?) && @policy.proposal?(@identity, project: project)
|
|
148
|
-
raise PermissionError, "proposal requires the installed second-commander decision principal"
|
|
149
|
-
end
|
|
150
|
-
end
|
|
151
|
-
|
|
152
|
-
def proposal_now
|
|
153
|
-
@proposal_clock.call.utc.iso8601
|
|
154
|
-
end
|
|
155
|
-
|
|
156
|
-
def proposal_journal(project)
|
|
157
|
-
unless project.is_a?(String) && Kinds::SAFE_LABEL.match?(project)
|
|
158
|
-
raise StateError, "proposal requires an explicit project"
|
|
159
|
-
end
|
|
160
|
-
@binding.proposal_journal(project: project)
|
|
161
|
-
rescue NoMethodError
|
|
162
|
-
raise BindingError, "Managed proposal authority is unavailable"
|
|
163
|
-
end
|
|
164
|
-
|
|
165
|
-
def proposal_access!(id, project:, requester: false)
|
|
166
|
-
unless id.is_a?(String) && id.match?(/\Aproposal-[0-9a-f]{24}\z/)
|
|
167
|
-
raise StateError, "invalid proposal ID"
|
|
168
|
-
end
|
|
169
|
-
record = proposal_journal(project).proposal_record(id)
|
|
170
|
-
raise StateError, "unknown proposal" unless record && record["project_id"] == project
|
|
171
|
-
if record["requester"] == @identity.username && record["caller_uid"] == @identity.uid &&
|
|
172
|
-
@policy.respond_to?(:proposal?) && @policy.proposal?(@identity, project: record["project_id"])
|
|
173
|
-
return record
|
|
174
|
-
end
|
|
175
|
-
if record["requester"] != @identity.username || record["caller_uid"] != @identity.uid
|
|
176
|
-
raise PermissionError, "only proposal requester may revise" if requester
|
|
177
|
-
require_transport!("proposal read", {"project" => record["project_id"]})
|
|
178
|
-
else
|
|
179
|
-
raise PermissionError, "proposal read requires current project authority" if requester
|
|
180
|
-
require_transport!("proposal read", {"project" => record["project_id"]})
|
|
181
|
-
end
|
|
182
|
-
record
|
|
183
|
-
end
|
|
184
|
-
|
|
185
|
-
def proposal_for_request!(request, project:)
|
|
186
|
-
unless request.is_a?(String) && request.match?(/\Aproposal-[0-9a-f]{24}-r[1-9][0-9]*\z/)
|
|
187
|
-
raise StateError, "invalid proposal revision request"
|
|
188
|
-
end
|
|
189
|
-
id = request.sub(/-r[1-9][0-9]*\z/, "")
|
|
190
|
-
proposal_access!(id, project: project)
|
|
191
|
-
record = proposal_journal(project).proposal_history(id).reverse.find { |entry| entry["request_id"] == request }
|
|
192
|
-
raise StateError, "unknown proposal revision" unless record
|
|
193
|
-
record
|
|
194
|
-
end
|
|
195
|
-
|
|
196
|
-
def proposal_transition(record, &policy)
|
|
197
|
-
proposal_journal(record.fetch("project_id")).change_proposal(record["proposal_id"], assignment_id: record["assignment_id"],
|
|
198
|
-
attempt_id: record["attempt_id"]) do |current|
|
|
199
|
-
# Selection may change while waiting for the canonical ref lock.
|
|
200
|
-
proposal_journal(record.fetch("project_id"))
|
|
201
|
-
# A reply to an older revision cannot modify the new decision.
|
|
202
|
-
next current unless current["request_id"] == record["request_id"]
|
|
203
|
-
policy.call(current)
|
|
204
|
-
end
|
|
205
|
-
rescue Ace::Assign::Error => e
|
|
206
|
-
raise StateError, "Proposal transition refused (#{e.message})"
|
|
207
|
-
end
|
|
208
|
-
|
|
209
|
-
def proposal_revision(id, revision, assignment:, attempt:, project:, content:, expected_revision: nil, operation_id: nil)
|
|
210
|
-
request = "#{id}-r#{revision}"
|
|
211
|
-
question = "Proposal #{request}: #{JSON.generate(content)}"
|
|
212
|
-
replay = nil
|
|
213
|
-
record = @binding.with_active(assignment: assignment, attempt: attempt, project: project, requester: @identity.username) do
|
|
214
|
-
proposal_journal(project).change_proposal(id, assignment_id: assignment, attempt_id: attempt) do |previous|
|
|
215
|
-
proposal_journal(project)
|
|
216
|
-
if operation_id
|
|
217
|
-
replay = proposal_revision_replay(id, expected_revision, operation_id, content, project: project)
|
|
218
|
-
next previous if replay
|
|
219
|
-
unless previous && previous["revision"] == expected_revision
|
|
220
|
-
raise StateError, "proposal source revision changed concurrently"
|
|
221
|
-
end
|
|
222
|
-
end
|
|
223
|
-
if previous && previous["revision"] == revision
|
|
224
|
-
unless previous.values_at("assignment_id", "attempt_id", "project_id", "requester", "caller_uid", "content_digest") ==
|
|
225
|
-
[assignment, attempt, project, @identity.username, @identity.uid, Digest::SHA256.hexdigest(JSON.generate(content))]
|
|
226
|
-
raise StateError, "proposal retry changed immutable content or caller"
|
|
227
|
-
end
|
|
228
|
-
next previous
|
|
229
|
-
end
|
|
230
|
-
unless (revision == 1 && previous.nil?) ||
|
|
231
|
-
(operation_id && previous && previous["revision"] == expected_revision)
|
|
232
|
-
raise StateError, "proposal revision changed concurrently"
|
|
233
|
-
end
|
|
234
|
-
projection = prepare_request(id: request, assignment: assignment, attempt: attempt, project: project,
|
|
235
|
-
kind: "proposal", plan: "second commander exact proposal", question: question, ace_hitl_id: id)
|
|
236
|
-
content.merge("schema" => "ace.hitl.proposal/v1", "proposal_id" => id, "revision" => revision,
|
|
237
|
-
"revision_id" => request, "request_id" => request, "assignment_id" => assignment,
|
|
238
|
-
"attempt_id" => attempt, "project_id" => project, "requester" => @identity.username,
|
|
239
|
-
"caller_uid" => @identity.uid, "content_digest" => Digest::SHA256.hexdigest(JSON.generate(content)),
|
|
240
|
-
"state" => "awaiting-delivery", "created_at" => proposal_now,
|
|
241
|
-
"silence_delegated" => @policy.proposal_silence?(caller_uid: @identity.uid,
|
|
242
|
-
requester: @identity.username, project: project, operation: content.fetch("operation")),
|
|
243
|
-
"authorization" => request, "lifecycle_request" => projection).tap do |value|
|
|
244
|
-
if operation_id
|
|
245
|
-
value["revision_operation_id"] = operation_id
|
|
246
|
-
value["expected_source_revision"] = expected_revision
|
|
247
|
-
end
|
|
248
|
-
end
|
|
249
|
-
end
|
|
250
|
-
end
|
|
251
|
-
record = replay || record
|
|
252
|
-
project_proposal_request(record)
|
|
253
|
-
record
|
|
254
|
-
end
|
|
255
|
-
|
|
256
|
-
def proposal_revision_replay(id, expected_revision, operation_id, content, project:)
|
|
257
|
-
replay = proposal_journal(project).proposal_operation_record(operation_id)
|
|
258
|
-
return unless replay
|
|
259
|
-
unless replay.values_at("proposal_id", "project_id", "expected_source_revision", "requester", "caller_uid", "content_digest") ==
|
|
260
|
-
[id, project, expected_revision, @identity.username, @identity.uid, Digest::SHA256.hexdigest(JSON.generate(content))]
|
|
261
|
-
raise StateError, "revision retry changed immutable source, content or caller"
|
|
262
|
-
end
|
|
263
|
-
replay
|
|
264
|
-
end
|
|
265
|
-
|
|
266
|
-
def project_proposal_request(record)
|
|
267
|
-
value = record.fetch("lifecycle_request")
|
|
268
|
-
if request_path(value.fetch("id")).exist?
|
|
269
|
-
unless load_request(value.fetch("id")) == value
|
|
270
|
-
raise StateError, "canonical proposal projection differs from lifecycle request"
|
|
271
|
-
end
|
|
272
|
-
initialize_projection!(value)
|
|
273
|
-
else
|
|
274
|
-
begin
|
|
275
|
-
persist_request(value)
|
|
276
|
-
rescue StateError => e
|
|
277
|
-
raise unless e.message == "HITL request already exists" && load_request(value.fetch("id")) == value
|
|
278
|
-
initialize_projection!(value)
|
|
279
|
-
end
|
|
280
|
-
end
|
|
281
|
-
end
|
|
282
|
-
|
|
283
|
-
def recover_proposal_projections(project:)
|
|
284
|
-
return unless @binding.respond_to?(:proposal_journal)
|
|
285
|
-
selected_projects = project ? [project] : @binding.proposal_projects
|
|
286
|
-
selected_projects.each do |selected|
|
|
287
|
-
next unless @policy.transport?(@identity, project: selected)
|
|
288
|
-
proposal_journal(selected).proposals.each do |record|
|
|
289
|
-
next unless record["project_id"] == selected
|
|
290
|
-
project_proposal_request(record)
|
|
291
|
-
end
|
|
292
|
-
end
|
|
293
|
-
end
|
|
294
|
-
end
|
|
295
|
-
end
|
|
296
|
-
end
|
|
297
|
-
end
|
|
@@ -1,97 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
require_relative "binding"
|
|
3
|
-
require "json"
|
|
4
|
-
|
|
5
|
-
module Ace
|
|
6
|
-
module Hitl
|
|
7
|
-
module Lifecycle
|
|
8
|
-
# Constructed beside the same-process authority, never from wire data.
|
|
9
|
-
# The selector is routing data; only the authority's held callback can
|
|
10
|
-
# admit a store transition for the original receiver process.
|
|
11
|
-
class ServicePublicationBinding < Binding
|
|
12
|
-
FIELDS = %w[schema mapping_id assignment_id attempt_id request_id claim_binding input_digest
|
|
13
|
-
candidate_generation head challenge_digest].freeze
|
|
14
|
-
|
|
15
|
-
def initialize(ordinary:, authority:, kernel:)
|
|
16
|
-
@ordinary, @authority, @kernel = ordinary, authority, kernel
|
|
17
|
-
end
|
|
18
|
-
|
|
19
|
-
def validate_request(**params)
|
|
20
|
-
deny_executor_ordinary!(params.fetch(:project), params[:caller_pid])
|
|
21
|
-
@ordinary.validate_request(**params)
|
|
22
|
-
end
|
|
23
|
-
|
|
24
|
-
def reverse_address(**params)
|
|
25
|
-
@ordinary.reverse_address(**params)
|
|
26
|
-
end
|
|
27
|
-
|
|
28
|
-
def with_active(**params, &block)
|
|
29
|
-
@ordinary.with_active(**params, &block)
|
|
30
|
-
end
|
|
31
|
-
|
|
32
|
-
def proposal_journal(project:)
|
|
33
|
-
@ordinary.proposal_journal(project: project)
|
|
34
|
-
end
|
|
35
|
-
|
|
36
|
-
def proposal_projects
|
|
37
|
-
@ordinary.proposal_projects
|
|
38
|
-
end
|
|
39
|
-
|
|
40
|
-
def with_publication!(binding:, identity:, assignment:, attempt:, project:, otp:, original_peer: nil, requester_required: true)
|
|
41
|
-
unless binding.is_a?(Hash) && binding.keys.sort == FIELDS.sort &&
|
|
42
|
-
binding["schema"] == "ace.hitl-publication-binding/v1" &&
|
|
43
|
-
binding["assignment_id"] == assignment && binding["attempt_id"] == attempt &&
|
|
44
|
-
%w[mapping_id assignment_id attempt_id request_id].all? { |key| binding[key].is_a?(String) && binding[key].match?(/\A[a-zA-Z0-9][a-zA-Z0-9._:-]{0,127}\z/) } &&
|
|
45
|
-
%w[claim_binding input_digest challenge_digest].all? { |key| binding[key].is_a?(String) && binding[key].match?(/\A[0-9a-f]{64}\z/) } &&
|
|
46
|
-
binding["head"].is_a?(String) && binding["head"].match?(/\A[0-9a-f]{40}\z/) &&
|
|
47
|
-
binding["candidate_generation"].is_a?(Integer) && binding["candidate_generation"].positive? &&
|
|
48
|
-
otp.is_a?(Hash) && otp["operation"] == "publish" && otp["input_digest"] == binding["input_digest"]
|
|
49
|
-
raise BindingError, "publication HITL selector differs"
|
|
50
|
-
end
|
|
51
|
-
caller = requester_required ? actual_peer!(identity) : original_peer
|
|
52
|
-
raise BindingError, "publication original receiver differs" if original_peer && caller != original_peer
|
|
53
|
-
@authority.with_publication_hitl!(binding: binding, project: project, peer: caller) do |selection|
|
|
54
|
-
unless selection.fetch("challenge_ref") == otp["result_ref"] && selection.fetch("executor_process_binding") == caller
|
|
55
|
-
raise BindingError, "publication challenge or receiver differs"
|
|
56
|
-
end
|
|
57
|
-
yield selection
|
|
58
|
-
end
|
|
59
|
-
rescue Ace::Assign::Error, Ace::Runtime::RuntimeUnavailableError => e
|
|
60
|
-
raise BindingError, "publication authority unavailable (#{e.class.name})"
|
|
61
|
-
end
|
|
62
|
-
|
|
63
|
-
def verify_publication_requester!(identity:, original_peer:)
|
|
64
|
-
raise BindingError, "publication original receiver differs" unless actual_peer!(identity) == original_peer
|
|
65
|
-
true
|
|
66
|
-
end
|
|
67
|
-
|
|
68
|
-
private
|
|
69
|
-
|
|
70
|
-
def actual_peer!(identity)
|
|
71
|
-
unless identity.respond_to?(:pid) && identity.pid.is_a?(Integer) && identity.pid.positive?
|
|
72
|
-
raise BindingError, "publication kernel peer is unavailable"
|
|
73
|
-
end
|
|
74
|
-
peer = @kernel.capture(identity.pid)
|
|
75
|
-
unless peer["uid"] == identity.uid && peer["gid"] == identity.gid
|
|
76
|
-
raise BindingError, "publication kernel credentials differ"
|
|
77
|
-
end
|
|
78
|
-
@kernel.live!(peer)
|
|
79
|
-
peer
|
|
80
|
-
end
|
|
81
|
-
|
|
82
|
-
def deny_executor_ordinary!(project, pid)
|
|
83
|
-
unless pid.is_a?(Integer) && pid.positive?
|
|
84
|
-
raise BindingError, "HITL kernel requester is unavailable"
|
|
85
|
-
end
|
|
86
|
-
peer = @kernel.capture(pid)
|
|
87
|
-
@kernel.live!(peer)
|
|
88
|
-
if @authority.publication_executor_identity?(project: project, uid: peer.fetch("uid"))
|
|
89
|
-
raise BindingError, "executor cannot select an ordinary worker HITL request"
|
|
90
|
-
end
|
|
91
|
-
rescue Ace::Assign::Error, Ace::Runtime::RuntimeUnavailableError => error
|
|
92
|
-
raise BindingError, "HITL kernel requester unavailable (#{error.class.name})"
|
|
93
|
-
end
|
|
94
|
-
end
|
|
95
|
-
end
|
|
96
|
-
end
|
|
97
|
-
end
|