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
|
@@ -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,
|
|
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
|
|
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
|
|
121
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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", "
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
229
|
+
stopping: -> { @stopping }, group: @group
|
|
231
230
|
),
|
|
232
231
|
project: @project
|
|
233
232
|
)
|
data/lib/ace/hitl/lifecycle.rb
CHANGED
|
@@ -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
|
|
8
|
-
#
|
|
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"
|
data/lib/ace/hitl/live_client.rb
CHANGED
|
@@ -1,103 +1,39 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require "
|
|
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
|
-
#
|
|
11
|
-
#
|
|
12
|
-
#
|
|
13
|
-
#
|
|
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
|
|
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
|
-
#
|
|
24
|
-
# completion. OTP bytes remain solely in this
|
|
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)
|
|
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
|
-
|
|
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
|