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
|
@@ -19,6 +19,10 @@ module Ace
|
|
|
19
19
|
|
|
20
20
|
def create(title,
|
|
21
21
|
kind: nil,
|
|
22
|
+
type: nil,
|
|
23
|
+
result: nil,
|
|
24
|
+
task: nil,
|
|
25
|
+
body: [],
|
|
22
26
|
questions: [],
|
|
23
27
|
tags: [],
|
|
24
28
|
assignment: nil,
|
|
@@ -26,9 +30,15 @@ module Ace
|
|
|
26
30
|
step_name: nil,
|
|
27
31
|
resume_instructions: nil,
|
|
28
32
|
move_to: nil,
|
|
33
|
+
recipient: nil,
|
|
34
|
+
reply_to: nil,
|
|
29
35
|
time: Time.now.utc)
|
|
30
36
|
raise ArgumentError, "Title is required" if title.nil? || title.strip.empty?
|
|
31
37
|
|
|
38
|
+
effective_type = type || "question"
|
|
39
|
+
validate_type!(effective_type, result: result, body: body, questions: questions,
|
|
40
|
+
resume_instructions: resume_instructions)
|
|
41
|
+
|
|
32
42
|
effective_kind = kind || @config.dig("hitl", "default_kind") || "clarification"
|
|
33
43
|
id = generate_unique_id(time)
|
|
34
44
|
folder_slug = generate_folder_slug(title)
|
|
@@ -41,23 +51,44 @@ module Ace
|
|
|
41
51
|
item_dir = File.join(target_dir, folder_name)
|
|
42
52
|
FileUtils.mkdir_p(item_dir)
|
|
43
53
|
|
|
44
|
-
frontmatter =
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
54
|
+
frontmatter = if effective_type == "report"
|
|
55
|
+
{
|
|
56
|
+
"id" => id,
|
|
57
|
+
"title" => title,
|
|
58
|
+
"type" => "report",
|
|
59
|
+
"result" => result,
|
|
60
|
+
"status" => "recorded",
|
|
61
|
+
"tags" => tags,
|
|
62
|
+
"task" => task,
|
|
63
|
+
"assignment" => assignment,
|
|
64
|
+
"step" => step,
|
|
65
|
+
"step_name" => step_name,
|
|
66
|
+
"created_at" => time
|
|
67
|
+
}.merge(recipient_keys(recipient, id)).compact
|
|
68
|
+
else
|
|
69
|
+
{
|
|
70
|
+
"id" => id,
|
|
71
|
+
"title" => title,
|
|
72
|
+
"type" => "question",
|
|
73
|
+
"kind" => effective_kind,
|
|
74
|
+
"status" => "pending",
|
|
75
|
+
"tags" => tags,
|
|
76
|
+
"task" => task,
|
|
77
|
+
"questions" => questions,
|
|
78
|
+
"assignment" => assignment,
|
|
79
|
+
"step" => step,
|
|
80
|
+
"step_name" => step_name,
|
|
81
|
+
"resume_instructions" => resume_instructions,
|
|
82
|
+
"answered" => false,
|
|
83
|
+
"created_at" => time
|
|
84
|
+
}.merge(recipient_keys(recipient, id))
|
|
85
|
+
.merge(reply_to_keys(reply_to, id))
|
|
86
|
+
.merge(infer_requester_context(assignment: assignment, step: step)).compact
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
body_text = build_body(title: title, type: effective_type, result: result,
|
|
90
|
+
questions: questions, body: body)
|
|
91
|
+
content = Ace::Support::Items::Atoms::FrontmatterSerializer.rebuild(frontmatter, body_text)
|
|
61
92
|
|
|
62
93
|
item_file = File.join(item_dir, Atoms::HitlFilePattern.filename(id, file_slug))
|
|
63
94
|
File.write(item_file, content)
|
|
@@ -72,6 +103,33 @@ module Ace
|
|
|
72
103
|
|
|
73
104
|
private
|
|
74
105
|
|
|
106
|
+
# Flat recipient keys; the folder form stamps the new event's ID as
|
|
107
|
+
# the case id (panel form has none). Returns {} when no recipient.
|
|
108
|
+
def recipient_keys(recipient, id)
|
|
109
|
+
return {} unless recipient.is_a?(Hash)
|
|
110
|
+
|
|
111
|
+
keys = {
|
|
112
|
+
"recipient_machine" => recipient["machine"],
|
|
113
|
+
"recipient_folder" => recipient["folder"],
|
|
114
|
+
"recipient_panel" => recipient["panel"],
|
|
115
|
+
"recipient_id" => recipient["id"] || (recipient["folder"] ? id : nil)
|
|
116
|
+
}
|
|
117
|
+
keys.compact
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# Flat reply keys (questions only): original sender + exact case.
|
|
121
|
+
# When the reply address carries no id, the new event's own ID is
|
|
122
|
+
# the case id the answer binds to.
|
|
123
|
+
def reply_to_keys(reply_to, id)
|
|
124
|
+
return {} unless reply_to.is_a?(Hash)
|
|
125
|
+
|
|
126
|
+
{
|
|
127
|
+
"reply_to_machine" => reply_to["machine"],
|
|
128
|
+
"reply_to_folder" => reply_to["folder"],
|
|
129
|
+
"reply_to_id" => reply_to["id"] || id
|
|
130
|
+
}.compact
|
|
131
|
+
end
|
|
132
|
+
|
|
75
133
|
def generate_unique_id(base_time)
|
|
76
134
|
time = base_time
|
|
77
135
|
1000.times do
|
|
@@ -131,23 +189,64 @@ module Ace
|
|
|
131
189
|
candidate
|
|
132
190
|
end
|
|
133
191
|
|
|
134
|
-
def
|
|
135
|
-
|
|
136
|
-
|
|
192
|
+
def validate_type!(type, result:, body:, questions:, resume_instructions:)
|
|
193
|
+
allowed = %w[question report]
|
|
194
|
+
unless allowed.include?(type)
|
|
195
|
+
raise ArgumentError, "Invalid type '#{type}'. Allowed: #{allowed.join(", ")}"
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
if type == "report"
|
|
199
|
+
unless %w[success error].include?(result.to_s)
|
|
200
|
+
raise ArgumentError, "Report requires result 'success' or 'error'"
|
|
201
|
+
end
|
|
202
|
+
if questions.any?
|
|
203
|
+
raise ArgumentError, "Reports cannot include questions; use body lines instead"
|
|
204
|
+
end
|
|
205
|
+
if resume_instructions
|
|
206
|
+
raise ArgumentError, "Reports do not carry resume instructions; only questions resume"
|
|
207
|
+
end
|
|
137
208
|
else
|
|
138
|
-
|
|
209
|
+
if result
|
|
210
|
+
raise ArgumentError, "result is only valid for reports (--type report)"
|
|
211
|
+
end
|
|
212
|
+
if body.any?
|
|
213
|
+
raise ArgumentError, "body is only valid for reports (--type report)"
|
|
214
|
+
end
|
|
139
215
|
end
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
def build_body(title:, type: "question", result: nil, questions: [], body: [])
|
|
219
|
+
if type == "report"
|
|
220
|
+
content_lines = body.map(&:strip).reject(&:empty?)
|
|
221
|
+
content_lines = ["(no content)"] if content_lines.empty?
|
|
140
222
|
|
|
141
|
-
|
|
142
|
-
|
|
223
|
+
<<~BODY
|
|
224
|
+
# #{title}
|
|
143
225
|
|
|
144
|
-
|
|
226
|
+
## Report
|
|
145
227
|
|
|
146
|
-
|
|
228
|
+
Result: #{result}
|
|
229
|
+
|
|
230
|
+
#{content_lines.map { |line| "- #{line}" }.join("\n")}
|
|
231
|
+
BODY
|
|
232
|
+
else
|
|
233
|
+
question_lines = if questions.empty?
|
|
234
|
+
"- (pending human input)"
|
|
235
|
+
else
|
|
236
|
+
questions.map { |q| "- #{q}" }.join("\n")
|
|
237
|
+
end
|
|
147
238
|
|
|
148
|
-
|
|
239
|
+
<<~BODY
|
|
240
|
+
# #{title}
|
|
149
241
|
|
|
150
|
-
|
|
242
|
+
## Questions
|
|
243
|
+
|
|
244
|
+
#{question_lines}
|
|
245
|
+
|
|
246
|
+
## Answer
|
|
247
|
+
|
|
248
|
+
BODY
|
|
249
|
+
end
|
|
151
250
|
end
|
|
152
251
|
|
|
153
252
|
def infer_requester_context(assignment:, step:)
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ace
|
|
4
|
+
module Hitl
|
|
5
|
+
module Molecules
|
|
6
|
+
# Pure redelivery decisions over the persisted per-route delivery map
|
|
7
|
+
# (event frontmatter `delivery.<route>`). No IO.
|
|
8
|
+
#
|
|
9
|
+
# Statuses: delivered (confirmed; skipped on default retry), failed /
|
|
10
|
+
# unavailable (failure reason retained; retried), in_flight (marker
|
|
11
|
+
# written before a delivery attempt; a stale one means the sending
|
|
12
|
+
# process died — unknown result, retried). At-least-once: a retry after
|
|
13
|
+
# an unknown result may duplicate, so recipients dedup by case ID.
|
|
14
|
+
module HitlDeliveryState
|
|
15
|
+
module_function
|
|
16
|
+
|
|
17
|
+
# @param record [Hash, nil] persisted route record
|
|
18
|
+
# @return [Boolean] true when the route must be (re)delivered;
|
|
19
|
+
# only a confirmed delivered record is skipped
|
|
20
|
+
def retry?(record)
|
|
21
|
+
return true if record.nil? || !record.is_a?(Hash)
|
|
22
|
+
|
|
23
|
+
record["status"] != "delivered"
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# A stale in_flight marker is an unknown result.
|
|
27
|
+
def unknown?(record)
|
|
28
|
+
record.is_a?(Hash) && record["status"] == "in_flight"
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# @return [Boolean] some route has a delivery record and is not delivered
|
|
32
|
+
def undelivered?(delivery_map)
|
|
33
|
+
map = normalized(delivery_map)
|
|
34
|
+
return false if map.empty?
|
|
35
|
+
|
|
36
|
+
map.any? { |_route, record| record["status"] != "delivered" }
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# @return [Boolean] a delivery map exists and every recorded route delivered
|
|
40
|
+
def fully_delivered?(delivery_map)
|
|
41
|
+
map = normalized(delivery_map)
|
|
42
|
+
return false if map.empty?
|
|
43
|
+
|
|
44
|
+
map.all? { |_route, record| record["status"] == "delivered" }
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Malformed entries (nil/scalar records from hand-written YAML) are
|
|
48
|
+
# dropped, never raised through: a broken record is simply not a
|
|
49
|
+
# delivery fact.
|
|
50
|
+
def normalized(delivery_map)
|
|
51
|
+
return {} unless delivery_map.is_a?(Hash)
|
|
52
|
+
|
|
53
|
+
delivery_map.select { |_route, record| record.is_a?(Hash) }
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
@@ -9,10 +9,11 @@ module Ace
|
|
|
9
9
|
|
|
10
10
|
STATUS_SYMBOLS = {
|
|
11
11
|
"pending" => "○",
|
|
12
|
-
"answered" => "✓"
|
|
12
|
+
"answered" => "✓",
|
|
13
|
+
"recorded" => "▪"
|
|
13
14
|
}.freeze
|
|
14
15
|
|
|
15
|
-
STATUS_ORDER = %w[pending answered].freeze
|
|
16
|
+
STATUS_ORDER = %w[pending answered recorded].freeze
|
|
16
17
|
|
|
17
18
|
def self.format_list(events, total_count: nil, global_folder_stats: nil)
|
|
18
19
|
body = if events.empty?
|
|
@@ -29,7 +29,9 @@ module Ace
|
|
|
29
29
|
created_at = parse_created_at(frontmatter["created_at"])
|
|
30
30
|
|
|
31
31
|
known_keys = %w[
|
|
32
|
-
id status title kind tags questions assignment step step_name
|
|
32
|
+
id status title kind type result task delivery tags questions assignment step step_name
|
|
33
|
+
recipient_machine recipient_folder recipient_panel recipient_id
|
|
34
|
+
reply_to_machine reply_to_folder reply_to_id
|
|
33
35
|
resume_instructions created_at answered answered_at
|
|
34
36
|
requester_provider requester_model requester_session_id
|
|
35
37
|
waiter_state waiter_session_id waiter_provider waiter_last_seen_at
|
|
@@ -39,10 +41,16 @@ module Ace
|
|
|
39
41
|
]
|
|
40
42
|
extra_metadata = frontmatter.reject { |k, _| known_keys.include?(k) }
|
|
41
43
|
|
|
44
|
+
event_type = frontmatter["type"] || "question"
|
|
45
|
+
|
|
42
46
|
Models::HitlEvent.new(
|
|
43
47
|
id: id || frontmatter["id"],
|
|
44
48
|
status: frontmatter["status"] || "pending",
|
|
45
|
-
|
|
49
|
+
# Kinds apply to questions only; reports carry no kind so
|
|
50
|
+
# question-oriented --kind filters never mix them in.
|
|
51
|
+
kind: event_type == "report" ? nil : (frontmatter["kind"] || "clarification"),
|
|
52
|
+
type: event_type,
|
|
53
|
+
result: frontmatter["result"],
|
|
46
54
|
title: title,
|
|
47
55
|
tags: Array(frontmatter["tags"]),
|
|
48
56
|
questions: Array(frontmatter["questions"]),
|
|
@@ -53,9 +61,18 @@ module Ace
|
|
|
53
61
|
special_folder: special_folder,
|
|
54
62
|
created_at: created_at,
|
|
55
63
|
metadata: extra_metadata.merge(
|
|
64
|
+
"task" => frontmatter["task"],
|
|
65
|
+
"delivery" => frontmatter["delivery"],
|
|
56
66
|
"assignment" => frontmatter["assignment"],
|
|
57
67
|
"step" => frontmatter["step"],
|
|
58
68
|
"step_name" => frontmatter["step_name"],
|
|
69
|
+
"recipient_machine" => frontmatter["recipient_machine"],
|
|
70
|
+
"recipient_folder" => frontmatter["recipient_folder"],
|
|
71
|
+
"recipient_panel" => frontmatter["recipient_panel"],
|
|
72
|
+
"recipient_id" => frontmatter["recipient_id"],
|
|
73
|
+
"reply_to_machine" => frontmatter["reply_to_machine"],
|
|
74
|
+
"reply_to_folder" => frontmatter["reply_to_folder"],
|
|
75
|
+
"reply_to_id" => frontmatter["reply_to_id"],
|
|
59
76
|
"resume_instructions" => frontmatter["resume_instructions"],
|
|
60
77
|
"answered" => frontmatter["answered"],
|
|
61
78
|
"answered_at" => frontmatter["answered_at"],
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "hitl_address_resolver"
|
|
4
|
+
|
|
5
|
+
module Ace
|
|
6
|
+
module Hitl
|
|
7
|
+
module Molecules
|
|
8
|
+
# Loads and validates the configured adapter route set from the hitl
|
|
9
|
+
# cascade (`hitl.adapters.routes`). Fail-closed: no routes, malformed
|
|
10
|
+
# entries, and duplicate route names are actionable errors raised
|
|
11
|
+
# before any delivery is attempted.
|
|
12
|
+
class HitlRouteLoader
|
|
13
|
+
CONFIG_LOCATION = "hitl.adapters.routes (.ace/hitl/config.yml)"
|
|
14
|
+
|
|
15
|
+
class NoRoutesError < StandardError; end
|
|
16
|
+
class InvalidRouteError < StandardError; end
|
|
17
|
+
|
|
18
|
+
# Delivery retention persists `delivery.<route>` as nested dot-keys
|
|
19
|
+
# and reads back the literal route key, so route names must be a
|
|
20
|
+
# single path segment (no dots).
|
|
21
|
+
ROUTE_NAME_PATTERN = /\A[a-z0-9][a-z0-9_-]*\z/i.freeze
|
|
22
|
+
|
|
23
|
+
# @param config [Hash] merged hitl namespace config
|
|
24
|
+
# @return [Array<Hash>] validated string-keyed routes
|
|
25
|
+
# @raise [NoRoutesError, InvalidRouteError]
|
|
26
|
+
def self.load(config)
|
|
27
|
+
routes = config.is_a?(Hash) ? config.dig("hitl", "adapters", "routes") : nil
|
|
28
|
+
if routes.nil?
|
|
29
|
+
raise NoRoutesError,
|
|
30
|
+
"no adapter routes configured; set #{CONFIG_LOCATION}"
|
|
31
|
+
end
|
|
32
|
+
unless routes.is_a?(Array) && !routes.empty?
|
|
33
|
+
raise NoRoutesError,
|
|
34
|
+
"no adapter routes configured; set #{CONFIG_LOCATION}"
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
validated = routes.map { |entry| validate_entry(entry) }
|
|
38
|
+
names = validated.map { |route| route["route"] }
|
|
39
|
+
duplicates = names.tally.select { |_, count| count > 1 }.keys
|
|
40
|
+
unless duplicates.empty?
|
|
41
|
+
raise InvalidRouteError,
|
|
42
|
+
"duplicate route name(s) #{duplicates.sort.join(", ")} in #{CONFIG_LOCATION}"
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
validated
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def self.validate_entry(entry)
|
|
49
|
+
unless entry.is_a?(Hash)
|
|
50
|
+
raise InvalidRouteError, "route entry must be a mapping, got #{entry.class} in #{CONFIG_LOCATION}"
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
normalized = stringified(entry)
|
|
54
|
+
adapter = normalized["adapter"].to_s.strip
|
|
55
|
+
if adapter.empty?
|
|
56
|
+
raise InvalidRouteError, "route entry missing required 'adapter' in #{CONFIG_LOCATION}"
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
route = normalized["route"].to_s.strip
|
|
60
|
+
route = adapter if route.empty?
|
|
61
|
+
unless route.match?(ROUTE_NAME_PATTERN)
|
|
62
|
+
raise InvalidRouteError,
|
|
63
|
+
"route name '#{route}' is invalid in #{CONFIG_LOCATION}: must match #{ROUTE_NAME_PATTERN.source} " \
|
|
64
|
+
"(delivery retention keys route names literally; a dot would nest frontmatter keys and break retry/skip)"
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
normalized["route"] = route
|
|
68
|
+
normalized["adapter"] = adapter
|
|
69
|
+
if normalized.key?("recipients") &&
|
|
70
|
+
(!normalized["recipients"].is_a?(Array) || normalized["recipients"].empty? ||
|
|
71
|
+
!normalized["recipients"].all? { |name| name.is_a?(String) && !name.strip.empty? })
|
|
72
|
+
raise InvalidRouteError, "route '#{route}' recipients must be a non-empty list of configured recipient names"
|
|
73
|
+
end
|
|
74
|
+
normalized
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Selection happens before adapter construction or delivery-state writes.
|
|
78
|
+
# Names are configuration references, not labels copied onto an event:
|
|
79
|
+
# compare their resolved addresses to the event's immutable destination.
|
|
80
|
+
def self.for_recipient(routes, recipient:, config:)
|
|
81
|
+
return routes if recipient.nil?
|
|
82
|
+
|
|
83
|
+
resolver = HitlAddressResolver.new(config: config)
|
|
84
|
+
destination = recipient.reject { |key, _| key == "id" }
|
|
85
|
+
selected = routes.select do |route|
|
|
86
|
+
addresses = Array(route["recipients"]).map do |name|
|
|
87
|
+
resolver.resolve(name).reject { |key, _| key == "id" }
|
|
88
|
+
end
|
|
89
|
+
addresses.include?(destination)
|
|
90
|
+
end
|
|
91
|
+
if selected.empty?
|
|
92
|
+
raise NoRoutesError, "no adapter route matches recipient #{Atoms::HitlAddress.to_s(recipient)}; " \
|
|
93
|
+
"set route recipients to configured recipient names in #{CONFIG_LOCATION}"
|
|
94
|
+
end
|
|
95
|
+
selected
|
|
96
|
+
rescue HitlAddressResolver::UnknownRecipientError, HitlAddressResolver::InvalidAddressError => e
|
|
97
|
+
raise InvalidRouteError, e.message
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def self.stringified(entry)
|
|
101
|
+
entry.each_with_object({}) do |(key, value), memo|
|
|
102
|
+
memo[key.to_s] = value
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
@@ -26,20 +26,46 @@ module Ace
|
|
|
26
26
|
end
|
|
27
27
|
end
|
|
28
28
|
|
|
29
|
-
|
|
29
|
+
class NotAnswerableError < StandardError; end
|
|
30
|
+
class AnswerConflictError < StandardError; end
|
|
30
31
|
|
|
31
|
-
|
|
32
|
+
attr_reader :root_dir, :last_list_total, :last_folder_counts, :config
|
|
33
|
+
|
|
34
|
+
def initialize(root_dir: nil, config: nil, scope_resolver: nil, resume_dispatcher: nil,
|
|
35
|
+
address_resolver: nil)
|
|
32
36
|
@config = config || load_config
|
|
33
37
|
@configured_root_setting = @config.dig("hitl", "root_dir") || Molecules::HitlConfigLoader::DEFAULT_ROOT_DIR
|
|
34
38
|
@root_dir = root_dir || resolve_root_dir
|
|
35
39
|
@scope_resolver = scope_resolver || Molecules::WorktreeScopeResolver.new
|
|
36
40
|
@resume_dispatcher = resume_dispatcher || Molecules::ResumeDispatcher.new
|
|
41
|
+
@address_resolver = address_resolver || Molecules::HitlAddressResolver.new(config: @config)
|
|
37
42
|
end
|
|
38
43
|
|
|
39
|
-
def create(title, **options)
|
|
44
|
+
def create(title, to: nil, **options)
|
|
45
|
+
# Resolve the recipient address before any I/O: unknown names and
|
|
46
|
+
# malformed addresses fail closed with nothing written. The reply
|
|
47
|
+
# address (sender machine + events folder) applies to questions
|
|
48
|
+
# only — a report carries the recipient but never awaits a reply,
|
|
49
|
+
# so hitl.machine is not required for report+--to.
|
|
50
|
+
recipient = nil
|
|
51
|
+
reply_to = nil
|
|
52
|
+
effective_to = to || @address_resolver.default_recipient
|
|
53
|
+
event_type = options[:type] || "question"
|
|
54
|
+
if effective_to
|
|
55
|
+
recipient = @address_resolver.resolve(effective_to)
|
|
56
|
+
if event_type == "question"
|
|
57
|
+
# The reply address must identify THIS sender folder: use the
|
|
58
|
+
# resolved root (absolute), not the configured setting — a
|
|
59
|
+
# relative value like `.ace-local/hitl` is identical across
|
|
60
|
+
# worktrees and gives the reply no base to resolve against.
|
|
61
|
+
reply_to = @address_resolver.sender_address(folder: File.expand_path(@root_dir), id: "pending")
|
|
62
|
+
.reject { |key, _| key == "id" }
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
40
66
|
ensure_root_dir
|
|
41
67
|
creator = Molecules::HitlCreator.new(root_dir: @root_dir, config: @config)
|
|
42
|
-
creator.create(title, **options)
|
|
68
|
+
creator.create(title, **options, recipient: recipient, reply_to: reply_to)
|
|
43
69
|
end
|
|
44
70
|
|
|
45
71
|
def show(ref, scope: nil)
|
|
@@ -68,13 +94,15 @@ module Ace
|
|
|
68
94
|
}
|
|
69
95
|
end
|
|
70
96
|
|
|
71
|
-
def list(status: nil, kind: nil, in_folder: "next", tags: [], scope: nil)
|
|
97
|
+
def list(status: nil, kind: nil, type: nil, delivery: nil, in_folder: "next", tags: [], scope: nil)
|
|
72
98
|
effective_scope = @scope_resolver.effective_scope(scope)
|
|
73
99
|
scan_results = scan_results_for_scope(effective_scope, in_folder: in_folder)
|
|
74
100
|
events = load_events(scan_results)
|
|
75
101
|
|
|
76
102
|
events = events.select { |event| event.status == status } if status
|
|
77
103
|
events = events.select { |event| event.kind == kind } if kind
|
|
104
|
+
events = events.select { |event| event.type == type } if type
|
|
105
|
+
events = filter_by_delivery(events, delivery) if delivery
|
|
78
106
|
events = filter_by_tags(events, tags) if tags.any?
|
|
79
107
|
|
|
80
108
|
events
|
|
@@ -87,6 +115,11 @@ module Ace
|
|
|
87
115
|
event = resolved[:event]
|
|
88
116
|
hitl_root = resolved[:hitl_root] || @root_dir
|
|
89
117
|
|
|
118
|
+
if !answer.nil? && event.report?
|
|
119
|
+
raise NotAnswerableError,
|
|
120
|
+
"HITL event '#{event.id}' is a report; reports do not await an answer"
|
|
121
|
+
end
|
|
122
|
+
|
|
90
123
|
has_field_updates = [set, add, remove].any? { |h| h && !h.empty? }
|
|
91
124
|
if has_field_updates && !answer.nil?
|
|
92
125
|
apply_field_and_answer_updates(event.file_path, set: set, add: add, remove: remove, answer: answer)
|
|
@@ -121,6 +154,21 @@ module Ace
|
|
|
121
154
|
loader.load(current_path, id: event.id, special_folder: current_special)
|
|
122
155
|
end
|
|
123
156
|
|
|
157
|
+
# Transport imports are first-answer-wins. The case's own write lock
|
|
158
|
+
# checks the answer again, so replay cannot overwrite a human answer.
|
|
159
|
+
# Explicit `update --answer` remains an intentional editing operation.
|
|
160
|
+
def accept_answer(ref, answer:, scope: nil)
|
|
161
|
+
resolved = resolve_for_mutation(ref, scope: scope)
|
|
162
|
+
return nil unless resolved
|
|
163
|
+
event = resolved[:event]
|
|
164
|
+
raise NotAnswerableError, "reports do not await an answer" if event.report?
|
|
165
|
+
raise NotAnswerableError, "managed requests require their authenticated lifecycle" if event.metadata["lab_request_id"]
|
|
166
|
+
raise ArgumentError, "answer is empty" if answer.to_s.strip.empty?
|
|
167
|
+
apply_field_and_answer_updates(event.file_path, set: {}, add: {}, remove: {},
|
|
168
|
+
answer: answer, first_answer: true)
|
|
169
|
+
Molecules::HitlLoader.new.load(event.path, id: event.id, special_folder: event.special_folder)
|
|
170
|
+
end
|
|
171
|
+
|
|
124
172
|
def wait_for_answer(ref, scope: nil, poll_every: 600, timeout: 14_400, waiter: {}, now_proc: nil, sleeper: nil)
|
|
125
173
|
poll_every = normalize_poll_seconds(poll_every)
|
|
126
174
|
timeout = normalize_timeout_seconds(timeout)
|
|
@@ -137,6 +185,8 @@ module Ace
|
|
|
137
185
|
return {status: :not_found} unless current
|
|
138
186
|
|
|
139
187
|
event = current[:event]
|
|
188
|
+
return {status: :report, event: event} if event.report?
|
|
189
|
+
|
|
140
190
|
if event.metadata["lab_request_id"]
|
|
141
191
|
raise Lifecycle::StateError, "managed requests require authenticated scoped wait"
|
|
142
192
|
end
|
|
@@ -179,6 +229,7 @@ module Ace
|
|
|
179
229
|
return {status: :not_found} unless current
|
|
180
230
|
|
|
181
231
|
event = current[:event]
|
|
232
|
+
return {status: :report, event: event} if event.report?
|
|
182
233
|
return {status: :managed_request, event: event} if event.metadata["lab_request_id"]
|
|
183
234
|
|
|
184
235
|
answer = event.answer.to_s
|
|
@@ -344,6 +395,17 @@ module Ace
|
|
|
344
395
|
events.select { |event| tags.any? { |tag| event.tags.include?(tag) } }
|
|
345
396
|
end
|
|
346
397
|
|
|
398
|
+
def filter_by_delivery(events, delivery)
|
|
399
|
+
case delivery.to_s
|
|
400
|
+
when "undelivered"
|
|
401
|
+
events.select { |event| Molecules::HitlDeliveryState.undelivered?(event.metadata["delivery"]) }
|
|
402
|
+
when "delivered"
|
|
403
|
+
events.select { |event| Molecules::HitlDeliveryState.fully_delivered?(event.metadata["delivery"]) }
|
|
404
|
+
else
|
|
405
|
+
raise Error, "invalid delivery filter '#{delivery}' (undelivered, delivered)"
|
|
406
|
+
end
|
|
407
|
+
end
|
|
408
|
+
|
|
347
409
|
def apply_answer_update(file_path, answer)
|
|
348
410
|
content = File.read(file_path)
|
|
349
411
|
frontmatter, body = Ace::Support::Items::Atoms::FrontmatterParser.parse(content)
|
|
@@ -362,7 +424,7 @@ module Ace
|
|
|
362
424
|
File.unlink(tmp_path) if tmp_path && File.exist?(tmp_path)
|
|
363
425
|
end
|
|
364
426
|
|
|
365
|
-
def apply_field_and_answer_updates(file_path, set:, add:, remove:, answer:)
|
|
427
|
+
def apply_field_and_answer_updates(file_path, set:, add:, remove:, answer:, first_answer: false)
|
|
366
428
|
File.open(file_path, File::RDWR) do |file|
|
|
367
429
|
file.flock(File::LOCK_EX)
|
|
368
430
|
file.rewind
|
|
@@ -370,6 +432,11 @@ module Ace
|
|
|
370
432
|
content = file.read
|
|
371
433
|
frontmatter, body = Ace::Support::Items::Atoms::FrontmatterParser.parse(content)
|
|
372
434
|
body = body.sub(/\A\n/, "")
|
|
435
|
+
previous = body.match(/^## Answer[ \t]*(?:\n(.*))?\z/m)&.[](1).to_s.strip
|
|
436
|
+
if first_answer && (frontmatter["answered"] == true || !previous.empty?)
|
|
437
|
+
raise AnswerConflictError, "case already has a different answer" unless previous == answer.to_s.strip
|
|
438
|
+
return
|
|
439
|
+
end
|
|
373
440
|
|
|
374
441
|
Ace::Support::Items::Molecules::FieldUpdater.apply_set(frontmatter, set)
|
|
375
442
|
Ace::Support::Items::Molecules::FieldUpdater.apply_add(frontmatter, add)
|