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
@@ -15,7 +15,6 @@ require_relative "operator_questions"
15
15
  require_relative "effects"
16
16
  require_relative "otp_vault"
17
17
  require_relative "protocol"
18
- require_relative "proposals"
19
18
 
20
19
  module Ace
21
20
  module Hitl
@@ -39,7 +38,6 @@ module Ace
39
38
  # or replaced attempts cannot acquire new authority.
40
39
  class Store
41
40
  include OperatorQuestions
42
- include Proposals
43
41
  MAX_ANSWER = 4096
44
42
  # IO#read(limit) is byte-oriented, so the character limit is
45
43
  # enforced on a decoded string with this separate byte bound
@@ -82,7 +80,7 @@ module Ace
82
80
 
83
81
  def initialize(root:, binding:, policy: AccessPolicy.new,
84
82
  identity: Identity, ownership: AtomicJson::DEFAULT_OWNERSHIP,
85
- poll_seconds: CONSUME_POLL_SECONDS, vault: :file, proposal_clock: -> { Time.now.utc },
83
+ poll_seconds: CONSUME_POLL_SECONDS, vault: :file,
86
84
  stopping: -> { false }, group: DEFAULT_GROUP)
87
85
  raise ArgumentError, "a binding policy is required (fail closed without one)" unless binding
88
86
  raise ArgumentError, "an access policy is required (fail closed without one)" unless policy
@@ -94,7 +92,6 @@ module Ace
94
92
  @ownership = ownership
95
93
  @poll_seconds = poll_seconds
96
94
  @vault = vault == :file ? OtpVault::FileVault : vault
97
- @proposal_clock = proposal_clock
98
95
  @stopping = stopping
99
96
  @group = group
100
97
  end
@@ -113,39 +110,15 @@ module Ace
113
110
  # validates its binding — unknown identity/authority is an
114
111
  # error, never permission.
115
112
  def create(id:, attempt:, plan:, question:, ace_hitl_id:, project: "ace", harness: "lab-admin",
116
- assignment:, kind: "text", options: [], effect: nil, otp: nil, publication_binding: nil)
117
- raise StateError, "proposal kind must use proposal_create" if kind == "proposal"
113
+ assignment:, kind: "text", options: [], effect: nil, otp: nil)
118
114
  value = prepare_request(id: id, attempt: attempt, plan: plan, question: question,
119
115
  ace_hitl_id: ace_hitl_id, project: project, harness: harness, assignment: assignment,
120
- kind: kind, options: options, effect: effect, otp: otp, publication_binding: publication_binding)
121
- return persist_request(value) unless value["publication_binding"]
122
- with_request_lock(value.fetch("id")) do
123
- with_live_authority!(value, requester: @identity.username) do
124
- if value["publication_binding"]
125
- if load_terminal(value.fetch("id"))
126
- raise StateError, "publication HITL id is already terminal"
127
- end
128
- duplicate = request_path(value.fetch("id")).exist? && load_request(value.fetch("id"))
129
- if duplicate
130
- unless duplicate.slice("publication_binding", "publication_requester_peer", "otp") == value.slice("publication_binding", "publication_requester_peer", "otp")
131
- raise StateError, "publication HITL id has different binding"
132
- end
133
- return {"id" => duplicate.fetch("id"), "requested" => true, "replay" => true, "envelope" => duplicate.fetch("envelope")}
134
- end
135
- requests_dir.glob("*.json").each do |path|
136
- existing = AtomicJson.read(path)
137
- if existing["publication_binding"] == value["publication_binding"]
138
- raise StateError, "publication challenge already has an active HITL request"
139
- end
140
- end
141
- end
142
- persist_request_locked!(value)
143
- end
144
- end
116
+ kind: kind, options: options, effect: effect, otp: otp)
117
+ persist_request(value)
145
118
  end
146
119
 
147
120
  def prepare_request(id:, attempt:, plan:, question:, ace_hitl_id:, project: "ace", harness: "lab-admin",
148
- assignment:, kind: "text", options: [], effect: nil, otp: nil, publication_binding: nil)
121
+ assignment:, kind: "text", options: [], effect: nil, otp: nil)
149
122
  requester = @identity.username
150
123
  request_id = safe_id(id)
151
124
  validate_binding_ids!(assignment, attempt)
@@ -154,8 +127,7 @@ module Ace
154
127
  end
155
128
  plan = plan.to_s.strip
156
129
  question = question.to_s.strip
157
- question_too_long = kind == "proposal" ? question.bytesize > 4096 : question.length > MAX_PLAN_QUESTION
158
- if plan.empty? || plan.length > MAX_PLAN_QUESTION || question.empty? || question_too_long
130
+ if plan.empty? || plan.length > MAX_PLAN_QUESTION || question.empty? || question.length > MAX_PLAN_QUESTION
159
131
  raise StateError, "plan and question must contain 1-#{MAX_PLAN_QUESTION} characters"
160
132
  end
161
133
  options = Array(options).map(&:strip)
@@ -168,8 +140,6 @@ module Ace
168
140
  unless Kinds.valid?(kind)
169
141
  raise StateError, "unknown HITL kind: #{kind}"
170
142
  end
171
- raise StateError, "proposal effects belong to ace-assign" if kind == "proposal" && effect
172
- require_proposer!(project) if kind == "proposal"
173
143
  if Kinds.secret?(kind)
174
144
  unless options.empty?
175
145
  raise StateError, "OTP requests must not offer choices"
@@ -178,13 +148,7 @@ module Ace
178
148
  challenge = normalize_otp_challenge!(otp)
179
149
  end
180
150
 
181
- if publication_binding
182
- raise StateError, "publication HITL must be OTP" unless kind == "otp"
183
- publication_binding = JSON.parse(JSON.generate(publication_binding))
184
- reverse = nil
185
- else
186
- reverse = validate_request_binding(assignment, attempt, project, requester)
187
- end
151
+ reverse = validate_request_binding(assignment, attempt, project, requester)
188
152
 
189
153
  value = {
190
154
  "id" => request_id,
@@ -202,7 +166,6 @@ module Ace
202
166
  "created_at" => Time.now.to_i
203
167
  }
204
168
  value["otp"] = challenge if challenge
205
- value["publication_binding"] = publication_binding if publication_binding
206
169
  Effects.validate_declaration!(effect) if effect
207
170
  value["effect"] = Effects.normalized_declaration(effect) if effect
208
171
  value["incarnation"] = SecureRandom.hex(8)
@@ -272,7 +235,7 @@ module Ace
272
235
  terminal = load_terminal(request_id)
273
236
  if terminal
274
237
  gate_read_access!(terminal)
275
- return terminal.slice("id", "assignment", "attempt", "project", "requester", "requester_uid", "sensitive", "envelope", "state", "native_delivery", "effect_receipt_ref", "publication_binding", "publication_requester_peer", "otp")
238
+ return terminal.slice("id", "assignment", "attempt", "project", "requester", "requester_uid", "sensitive", "envelope", "state", "native_delivery", "effect_receipt_ref", "otp")
276
239
  .merge("kind" => terminal.dig("envelope", "kind"))
277
240
  end
278
241
  end
@@ -290,8 +253,6 @@ module Ace
290
253
  "options" => value["options"],
291
254
  "plan" => value["plan"],
292
255
  "otp" => value["otp"],
293
- "publication_binding" => value["publication_binding"],
294
- "publication_requester_peer" => value["publication_requester_peer"],
295
256
  "requester" => value["requester"],
296
257
  "requester_uid" => value["requester_uid"],
297
258
  "envelope" => value["envelope"],
@@ -299,7 +260,6 @@ module Ace
299
260
  "effect_receipt_ref" => effect_receipt_ref(value),
300
261
  "effect" => value["effect_state"]
301
262
  }
302
- facts["proposal"] = proposal_for_request!(request_id, project: value.fetch("project")) if value["kind"] == "proposal"
303
263
  facts
304
264
  end
305
265
 
@@ -328,7 +288,6 @@ module Ace
328
288
 
329
289
  value = load_request(request_id)
330
290
  requester_gate!(value)
331
- raise StateError, "proposal authorization is consumed by the scoped Assign service claim" if value["kind"] == "proposal"
332
291
  verify_native_consumer!(value) if native_delivery
333
292
  verify_operation!(value, operation)
334
293
  deadline = timeout.positive? ? Time.now.to_i + timeout : nil
@@ -409,7 +368,6 @@ module Ace
409
368
  locked_value = nil
410
369
  with_request_lock(request_id) do
411
370
  value = load_request(request_id)
412
- raise StateError, "proposal decisions use revise or authenticated Captain veto" if value["kind"] == "proposal"
413
371
  # Ownership is re-checked against the LOCKED record: an
414
372
  # unlocked check would race a concurrent recreate of the id
415
373
  # (review 8wq2zttu on PR#336).
@@ -424,11 +382,7 @@ module Ace
424
382
  remove_request(value, keep_public: true)
425
383
  locked_value = value
426
384
  end
427
- if value["publication_binding"]
428
- with_live_authority!(value, requester: @identity.username, &cancel)
429
- else
430
- cancel.call
431
- end
385
+ cancel.call
432
386
  end
433
387
  {
434
388
  "id" => request_id,
@@ -459,7 +413,6 @@ module Ace
459
413
  end
460
414
  value = load_request(request_id)
461
415
  require_transport!("deliver", value)
462
- raise StateError, "proposal reply requires authenticated ingress evidence" if value["kind"] == "proposal"
463
416
  if value["sensitive"] == true && !Kinds.secret?(value["kind"].to_s)
464
417
  raise StateError, "secret HITL answers are forbidden"
465
418
  end
@@ -525,7 +478,6 @@ module Ace
525
478
  def each_pending(project: nil, after: nil)
526
479
  return enum_for(__method__, project: project, after: after) unless block_given?
527
480
  require_transport!("pending")
528
- recover_proposal_projections(project: project)
529
481
  ids = (requests_dir.glob("*.json") + public_dir.glob("*.json")).map { |path| path.basename(".json").to_s }.uniq.sort
530
482
  ids.each do |id|
531
483
  next if after && id <= after
@@ -879,9 +831,6 @@ module Ace
879
831
  unless @identity.username == value["requester"].to_s
880
832
  raise PermissionError, "only the requesting role can consume this answer"
881
833
  end
882
- if value["publication_binding"]
883
- @binding.verify_publication_requester!(identity: @identity, original_peer: value.fetch("publication_requester_peer"))
884
- end
885
834
  end
886
835
 
887
836
  def gate_read_access!(value)
@@ -935,7 +884,7 @@ module Ace
935
884
  raise PermissionError, "only the requesting role can #{operation == "cancelled" ? "cancel" : "consume"} this request"
936
885
  end
937
886
 
938
- requester_gate!(terminal) if terminal["publication_binding"] || operator_request?(terminal)
887
+ requester_gate!(terminal) if operator_request?(terminal)
939
888
  case terminal["state"]
940
889
  when "cancelled"
941
890
  return replayed_cancel(request_id, terminal) if operation == "cancelled"
@@ -1000,9 +949,7 @@ module Ace
1000
949
  record["requester_uid"] = value.fetch("requester_uid")
1001
950
  record["kind"] = "question"
1002
951
  end
1003
- %w[publication_binding publication_requester_peer otp].each do |key|
1004
- record[key] = value[key] if value.key?(key)
1005
- end
952
+ record["otp"] = value["otp"] if value.key?("otp")
1006
953
  record["answer"] = extra[:answer] if extra[:answer]
1007
954
  record["native_delivery"] = true if extra[:native_delivery]
1008
955
  record["audit"] = extra["audit"] if extra["audit"]
@@ -1027,17 +974,6 @@ module Ace
1027
974
  require_transport!("operator transition", value)
1028
975
  end
1029
976
  yield
1030
- elsif value["publication_binding"]
1031
- unless @binding.respond_to?(:with_publication!)
1032
- raise BindingError, "publication HITL source binding is unavailable"
1033
- end
1034
- @binding.with_publication!(binding: value.fetch("publication_binding"), identity: @identity,
1035
- assignment: value.fetch("assignment"), attempt: value.fetch("attempt"), project: value.fetch("project"),
1036
- otp: value.fetch("otp"), original_peer: value["publication_requester_peer"],
1037
- requester_required: @identity.username == value.fetch("requester")) do |selection|
1038
- value["publication_requester_peer"] ||= JSON.parse(JSON.generate(selection.fetch("executor_process_binding")))
1039
- yield
1040
- end
1041
977
  else
1042
978
  @binding.with_active(
1043
979
  assignment: value["assignment"], attempt: value["attempt"],
@@ -23,20 +23,6 @@ module Ace
23
23
  project == @project && @decision_policy.operator_question?(uid: uid, project: project)
24
24
  end
25
25
 
26
- def proposal?(peer, project:)
27
- transport?(peer, project: project)
28
- end
29
-
30
- def proposal_silence?(caller_uid:, requester:, project:, operation:)
31
- return false unless project == @project
32
- # The transport peer is authenticated separately. A local proposal
33
- # retains its original kernel principal when Hermes reconciles it.
34
- return @decision_policy.proposal_silence?(caller_uid: caller_uid, requester: requester,
35
- project: project, operation: operation) unless caller_uid.nil?
36
- requester == "urn:ace:hitl:#{@project}:hermes-client" &&
37
- @decision_policy.silence_operation?(project: project, operation: operation)
38
- end
39
-
40
26
  # A TLS authority has no Unix service uid; client endpoint
41
27
  # verification is certificate-based.
42
28
  def service_uid
@@ -39,21 +39,6 @@ module Ace
39
39
  super
40
40
  end
41
41
 
42
- def proposal_wake(project:, after: nil)
43
- require_bound_project!(project)
44
- super(project: @project, after: after)
45
- end
46
-
47
- def proposal_due(project:, after: nil)
48
- require_bound_project!(project)
49
- super(project: @project, after: after)
50
- end
51
-
52
- def proposal_history(project:, query: "", after: nil)
53
- require_bound_project!(project)
54
- super(project: @project, query: query, after: after)
55
- end
56
-
57
42
  private
58
43
 
59
44
  def require_bound_project!(project)
@@ -35,7 +35,7 @@ module Ace
35
35
 
36
36
  def initialize(root:, binding:, project:, credential:, ssl_context:,
37
37
  listen: "127.0.0.1", port:, group: Store::DEFAULT_GROUP, vault: nil, logger: nil,
38
- idle_seconds: DEFAULT_IDLE_SECONDS, proposal_clock: -> { Time.now.utc }, decision_policy: AccessPolicy.new)
38
+ idle_seconds: DEFAULT_IDLE_SECONDS, decision_policy: AccessPolicy.new)
39
39
  @root = root
40
40
  @binding = binding
41
41
  @project = TlsCredentials.project_id(project)
@@ -47,7 +47,6 @@ module Ace
47
47
  @vault = vault || OtpVault::MemoryVault.new
48
48
  @logger = logger
49
49
  @idle_seconds = idle_seconds
50
- @proposal_clock = proposal_clock
51
50
  @policy = TlsAccessPolicy.new(project: @project, decision_policy: decision_policy)
52
51
  @max_connections = MAX_CONNECTIONS
53
52
  @connections = 0
@@ -227,7 +226,7 @@ module Ace
227
226
  policy: @policy,
228
227
  identity: peer,
229
228
  vault: @vault,
230
- proposal_clock: @proposal_clock, stopping: -> { @stopping }, group: @group
229
+ stopping: -> { @stopping }, group: @group
231
230
  ),
232
231
  project: @project
233
232
  )
@@ -4,9 +4,8 @@
4
4
  # 8wq.t.34i): provider-agnostic request store behind an authenticated
5
5
  # scoped boundary (peer-credential service, bounded protocol, transport
6
6
  # policy), kinds/secrets gates with transient OTP, public projection,
7
- # effect layer, duty projection, and the Overseer reverse-address
8
- # surface. Lab coupling lives only in the provider=lab seams (binding
9
- # policy, store factory, grants deployment).
7
+ # effect layer, and duty projection. Lab coupling lives only in the
8
+ # provider=lab seams (binding policy, store factory, grants deployment).
10
9
  require_relative "lifecycle/errors"
11
10
  require_relative "lifecycle/identity"
12
11
  require_relative "lifecycle/peer"
@@ -32,4 +31,3 @@ require_relative "lifecycle/service"
32
31
  require_relative "lifecycle/tls_service"
33
32
  require_relative "lifecycle/tls_authority"
34
33
  require_relative "lifecycle/duty"
35
- require_relative "lifecycle/overseer"
@@ -1,103 +1,39 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "digest"
4
- require "ace/assign"
5
- require "ace/herdr"
3
+ require "ace/hitl/contract"
6
4
  require_relative "providers/lab"
7
5
 
8
6
  module Ace
9
7
  module Hitl
10
- # Explicit in-process client. The scoped service owns answer availability
11
- # and business callbacks; Herdr owns delivery to the selected live terminal.
12
- # A watcher lives only as long as its requesting agent. Delivery status is
13
- # read from that original Inbox owner, never a duplicate recovery projection.
8
+ # Client of the authenticated lifecycle boundary. The installed
9
+ # attempt-bound native delivery path (Herdr inbox reconciliation and its
10
+ # ace-assign attempt authority) was removed with the old authority model;
11
+ # requester-side boundary operations remain.
14
12
  class LiveClient
15
- def initialize(boundary: nil, coordinator: nil, inbox: nil, root: Dir.pwd)
13
+ def initialize(boundary: nil)
16
14
  @boundary = boundary
17
- @coordinator = coordinator
18
- @inbox = inbox
19
- @root = root
20
15
  end
21
16
 
22
17
  # Pane-less waiting consumes the existing authorized request through
23
- # protected IPC. It neither queues an answer nor attests native/effect
24
- # completion. OTP bytes remain solely in this authenticated return value.
18
+ # the authenticated boundary. It neither queues an answer nor attests
19
+ # native/effect completion. OTP bytes remain solely in this
20
+ # authenticated return value.
25
21
  def wait(request:, timeout: 0, operation: nil)
26
22
  boundary.consume(request, timeout: timeout, operation: operation)
27
23
  end
28
24
 
29
- def watch(request:, timeout: 0, &observer)
30
- Thread.new do
31
- result = deliver(request: request, timeout: timeout)
32
- observer.call(result) if observer
33
- result
34
- end
35
- end
36
-
37
- def deliver(request:, timeout: 0)
38
- facts = boundary.read(request)
39
- envelope = envelope_for(facts, request)
40
- raise Lifecycle::StateError, "OTP answers require protected consume; native/folder delivery is prohibited" if envelope["kind"] == "otp"
41
- reverse = envelope["reverse"]
42
- raise Lifecycle::BindingError, "request has no verified native reverse address" unless reverse
43
- owner = coordinator.runtime_binding(attempt_id: envelope["attempt_id"], caller_pid: Process.pid)
44
- unless owner.values_at("session", "pane") == reverse.values_at("session", "pane")
45
- raise Lifecycle::BindingError, "request reverse differs from its exact active native owner"
46
- end
47
- target = inbox.target_from_owner(owner)
48
- result = boundary.consume(request, timeout: timeout, native_delivery: true)
49
- raise Lifecycle::StateError, "native delivery claim was not committed" unless result["native_delivery"] == true
50
- consumed = envelope_for(result, request)
51
- unless consumed == envelope
52
- raise Lifecycle::BindingError, "request binding changed during answer consumption"
53
- end
54
- answer = result["answer"]
55
- unless answer.is_a?(String) && !answer.empty?
56
- raise Lifecycle::StateError, "ordinary answer is unavailable for native delivery"
57
- end
58
- delivery = envelope.merge("payload_sha256" => Digest::SHA256.hexdigest(answer))
59
- if result["effect_receipt_ref"] && delivery["effect"]
60
- delivery["effect"] = delivery["effect"].merge("receipt_ref" => result["effect_receipt_ref"])
61
- end
62
- delivery = Contract::ManagedEnvelope.load(delivery)
63
- raise Lifecycle::AnswerError, "secret-bearing native answers are prohibited" if Contract::SecretGate::PATTERN.match?(answer)
64
- event = event_id(envelope)
65
- queued = inbox.enqueue(event: event, attempt: envelope["attempt_id"], ref: reverse, payload: answer,
66
- managed_envelope: delivery, expected_target: target)
67
- coordinator.bind_inbox(attempt_id: envelope["attempt_id"], event_id: event, inbox: inbox)
68
- inbox.deliver(event: event)
69
- ensure
70
- answer&.clear unless answer&.frozen?
71
- end
72
-
73
25
  def status(request:)
74
26
  facts = boundary.read(request)
75
27
  envelope = envelope_for(facts, request)
76
- {"request_id" => request, "request_state" => facts["state"], "envelope" => envelope,
77
- "delivery" => envelope["kind"] == "otp" ? {"state" => "not-applicable"} : delivery_status(envelope)}
28
+ {"request_id" => request, "request_state" => facts["state"], "envelope" => envelope}
78
29
  end
79
30
 
80
31
  def pending(project: nil)
81
- boundary.pending(project: project).filter_map do |facts|
82
- next facts unless facts["native_delivery"] == true
83
- envelope = envelope_for(facts, facts["id"])
84
- delivery = delivery_status(envelope)
85
- next if delivery["state"] == "delivered"
86
- facts.merge("delivery" => delivery)
87
- end
32
+ boundary.pending(project: project)
88
33
  end
89
34
 
90
35
  private
91
36
 
92
- def delivery_status(envelope)
93
- event = event_id(envelope)
94
- # This is transport history from its original owner, not proof that
95
- # an agent read an answer or a cached view of the current terminal.
96
- inbox.status(event: event)
97
- rescue Ace::Herdr::Organisms::Inbox::MissingEventError
98
- {"event_id" => event, "state" => "not-submitted"}
99
- end
100
-
101
37
  def envelope_for(result, request)
102
38
  Contract::ManagedEnvelope.load(result.fetch("envelope"), expected: {
103
39
  request_id: request, correlation_id: request, assignment_id: result["assignment"], attempt_id: result["attempt"]
@@ -106,21 +42,9 @@ module Ace
106
42
  raise Lifecycle::BindingError, "managed request binding is invalid (#{e.message})"
107
43
  end
108
44
 
109
- def event_id(envelope)
110
- Contract::ManagedEnvelope.inbox_event_id(envelope)
111
- end
112
-
113
45
  def boundary
114
46
  @boundary ||= Providers::Lab.boundary_client
115
47
  end
116
-
117
- def coordinator
118
- @coordinator ||= Ace::Assign::Organisms::AttemptCoordinator.new(repo_root: @root)
119
- end
120
-
121
- def inbox
122
- @inbox ||= Ace::Herdr::Organisms::Inbox.from_config(root: @root)
123
- end
124
48
  end
125
49
  end
126
50
  end
@@ -7,6 +7,8 @@ module Ace
7
7
  :id,
8
8
  :status,
9
9
  :kind,
10
+ :type,
11
+ :result,
10
12
  :title,
11
13
  :tags,
12
14
  :questions,
@@ -26,6 +28,39 @@ module Ace
26
28
  def answered?
27
29
  status.to_s == "answered" || metadata["answered"] == true
28
30
  end
31
+
32
+ def report?
33
+ type.to_s == "report"
34
+ end
35
+
36
+ def question?
37
+ !report?
38
+ end
39
+
40
+ # Recipient address hash (machine + folder [+ id] or machine + panel)
41
+ # for consumers/adapters; nil for legacy events without keys.
42
+ def recipient
43
+ address_from_metadata("recipient")
44
+ end
45
+
46
+ # Reply address (original sender + exact case); questions only.
47
+ def reply_to
48
+ address_from_metadata("reply_to")
49
+ end
50
+
51
+ private
52
+
53
+ def address_from_metadata(prefix)
54
+ base = {
55
+ "machine" => metadata["#{prefix}_machine"],
56
+ "folder" => metadata["#{prefix}_folder"],
57
+ "panel" => metadata["#{prefix}_panel"],
58
+ "id" => metadata["#{prefix}_id"]
59
+ }
60
+ return nil if base["machine"].to_s.empty?
61
+
62
+ base.compact
63
+ end
29
64
  end
30
65
  end
31
66
  end
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../atoms/hitl_address"
4
+
5
+ module Ace
6
+ module Hitl
7
+ module Molecules
8
+ # Config-backed recipient resolution and sender/reply address
9
+ # construction. The single place that reads address configuration;
10
+ # consumers pass a configured recipient name without own routing.
11
+ class HitlAddressResolver
12
+ class UnknownRecipientError < StandardError; end
13
+ class InvalidAddressError < StandardError; end
14
+
15
+ # @param config [Hash] merged hitl namespace config
16
+ def initialize(config: {})
17
+ @config = config || {}
18
+ end
19
+
20
+ # @return [Array<String>] configured recipient names
21
+ def recipient_names
22
+ recipients.keys.sort
23
+ end
24
+
25
+ # @return [String, nil] name applied when --to is absent
26
+ def default_recipient
27
+ value = @config.dig("hitl", "default_recipient").to_s.strip
28
+ value.empty? ? nil : value
29
+ end
30
+
31
+ # @param name [String] configured recipient name
32
+ # @return [Hash] validated base address (folder or panel form)
33
+ # @raise [UnknownRecipientError] listing configured names
34
+ # @raise [InvalidAddressError] when the configured address is malformed
35
+ def resolve(name)
36
+ entry = recipients[name.to_s]
37
+ unless entry
38
+ listed = recipient_names.empty? ? "(none configured)" : recipient_names.join(", ")
39
+ raise UnknownRecipientError, "Unknown HITL recipient '#{name}'. Configured recipients: #{listed}"
40
+ end
41
+
42
+ begin
43
+ Atoms::HitlAddress.validate!(entry)
44
+ rescue Atoms::HitlAddress::InvalidAddress => e
45
+ raise InvalidAddressError, "recipient '#{name}' has an invalid address: #{e.message}"
46
+ end
47
+ end
48
+
49
+ # Sender (reply) address: this machine + this events folder + case id.
50
+ #
51
+ # @param folder [String] configured events root (sender's folder)
52
+ # @param id [String] the event's own case ID
53
+ # @return [Hash] validated folder-form address
54
+ # @raise [InvalidAddressError] when hitl.machine is unset or malformed
55
+ def sender_address(folder:, id:)
56
+ machine = @config.dig("hitl", "machine").to_s.strip
57
+ if machine.empty?
58
+ raise InvalidAddressError,
59
+ "cannot build reply address: hitl.machine is not configured"
60
+ end
61
+
62
+ begin
63
+ Atoms::HitlAddress.validate!(machine: machine, folder: folder, id: id)
64
+ rescue Atoms::HitlAddress::InvalidAddress => e
65
+ raise InvalidAddressError, "invalid sender address: #{e.message}"
66
+ end
67
+ end
68
+
69
+ private
70
+
71
+ def recipients
72
+ @recipients ||= begin
73
+ raw = @config.dig("hitl", "recipients")
74
+ raw.is_a?(Hash) ? raw : {}
75
+ end
76
+ end
77
+ end
78
+ end
79
+ end
80
+ end
@@ -18,12 +18,33 @@ module Ace
18
18
  defaults_dir: ".ace-defaults",
19
19
  gem_path: gem_root
20
20
  )
21
- {"hitl" => resolver.resolve_namespace("hitl").data}
21
+ data = resolver.resolve_namespace("hitl").data
22
+ {"hitl" => normalize(data)}
22
23
  rescue StandardError => e
23
24
  warn "ace-hitl: Could not load config: #{e.class} - #{e.message}" if Ace::Hitl.respond_to?(:debug?) && Ace::Hitl.debug?
24
25
  load_defaults_fallback(gem_root: gem_root)
25
26
  end
26
27
 
28
+ # A hitl config document is either shaped (settings under the
29
+ # `hitl:` key, like the shipped defaults) or flat (settings at the
30
+ # top level of `.ace/hitl/config.yml`). The cascade merge combines
31
+ # them into one document, so normalization folds the top-level
32
+ # keys over the shaped subtree — flat project overrides apply and
33
+ # are never silently dropped by the defaults supplying `hitl:`.
34
+ #
35
+ # Caveat: the folding happens after the tier merge, so mixing
36
+ # shapes across tiers can invert cascade precedence (a flat user
37
+ # document would override a shaped project document). Use the
38
+ # same shape at every tier.
39
+ def self.normalize(data)
40
+ return {} unless data.is_a?(Hash)
41
+
42
+ shaped = data["hitl"]
43
+ base = shaped.is_a?(Hash) ? shaped : {}
44
+ flat = data.reject { |key, _| key == "hitl" }
45
+ flat.empty? ? base : Ace::Support::Config::Atoms::DeepMerger.merge(base, flat)
46
+ end
47
+
27
48
  def self.root_dir(config = nil)
28
49
  config ||= load
29
50
  dir = config.dig("hitl", "root_dir") || DEFAULT_ROOT_DIR