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.
Files changed (58) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +162 -0
  3. data/README.md +60 -117
  4. data/docs/usage.md +247 -421
  5. data/handbook/workflow-instructions/hitl.wf.md +55 -6
  6. data/lib/ace/hitl/adapters/registry.rb +65 -0
  7. data/lib/ace/hitl/adapters/terminal.rb +75 -0
  8. data/lib/ace/hitl/adapters.rb +80 -0
  9. data/lib/ace/hitl/atoms/hitl_address.rb +89 -0
  10. data/lib/ace/hitl/atoms/hitl_event_text.rb +54 -0
  11. data/lib/ace/hitl/cli/commands/create.rb +81 -12
  12. data/lib/ace/hitl/cli/commands/lifecycle_command.rb +1 -7
  13. data/lib/ace/hitl/cli/commands/list.rb +22 -1
  14. data/lib/ace/hitl/cli/commands/send.rb +85 -0
  15. data/lib/ace/hitl/cli/commands/serve.rb +25 -44
  16. data/lib/ace/hitl/cli/commands/show.rb +23 -3
  17. data/lib/ace/hitl/cli/commands/update.rb +4 -0
  18. data/lib/ace/hitl/cli/commands/wait.rb +5 -0
  19. data/lib/ace/hitl/cli.rb +8 -18
  20. data/lib/ace/hitl/lifecycle/frame_serving.rb +0 -15
  21. data/lib/ace/hitl/lifecycle/kinds.rb +1 -1
  22. data/lib/ace/hitl/lifecycle/lifecycle_requests.rb +0 -36
  23. data/lib/ace/hitl/lifecycle/policy.rb +0 -44
  24. data/lib/ace/hitl/lifecycle/protocol.rb +1 -2
  25. data/lib/ace/hitl/lifecycle/service.rb +2 -3
  26. data/lib/ace/hitl/lifecycle/store.rb +11 -75
  27. data/lib/ace/hitl/lifecycle/tls_access_policy.rb +0 -14
  28. data/lib/ace/hitl/lifecycle/tls_project_scope.rb +0 -15
  29. data/lib/ace/hitl/lifecycle/tls_service.rb +2 -3
  30. data/lib/ace/hitl/lifecycle.rb +2 -4
  31. data/lib/ace/hitl/live_client.rb +11 -87
  32. data/lib/ace/hitl/models/hitl_event.rb +35 -0
  33. data/lib/ace/hitl/molecules/hitl_address_resolver.rb +80 -0
  34. data/lib/ace/hitl/molecules/hitl_config_loader.rb +22 -1
  35. data/lib/ace/hitl/molecules/hitl_creator.rb +126 -27
  36. data/lib/ace/hitl/molecules/hitl_delivery_state.rb +58 -0
  37. data/lib/ace/hitl/molecules/hitl_display_formatter.rb +3 -2
  38. data/lib/ace/hitl/molecules/hitl_loader.rb +19 -2
  39. data/lib/ace/hitl/molecules/hitl_route_loader.rb +108 -0
  40. data/lib/ace/hitl/organisms/hitl_manager.rb +73 -6
  41. data/lib/ace/hitl/organisms/hitl_sender.rb +148 -0
  42. data/lib/ace/hitl/providers/lab.rb +15 -129
  43. data/lib/ace/hitl/version.rb +1 -1
  44. data/lib/ace/hitl.rb +8 -1
  45. metadata +12 -43
  46. data/lib/ace/hitl/cli/commands/ask.rb +0 -136
  47. data/lib/ace/hitl/cli/commands/overseer_ack.rb +0 -30
  48. data/lib/ace/hitl/cli/commands/overseer_pending.rb +0 -28
  49. data/lib/ace/hitl/cli/commands/overseer_send.rb +0 -35
  50. data/lib/ace/hitl/cli/commands/proposal.rb +0 -75
  51. data/lib/ace/hitl/lifecycle/overseer.rb +0 -111
  52. data/lib/ace/hitl/lifecycle/proposals.rb +0 -297
  53. data/lib/ace/hitl/lifecycle/service_publication_binding.rb +0 -97
  54. data/lib/ace/hitl/proposals/evaluator.rb +0 -29
  55. data/lib/ace/hitl/proposals/policy.rb +0 -127
  56. data/lib/ace/hitl/providers/lab/assignment_binding.rb +0 -126
  57. data/lib/ace/hitl/providers/lab/protected_assignment_binding.rb +0 -75
  58. 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