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
@@ -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
- "id" => id,
46
- "title" => title,
47
- "kind" => effective_kind,
48
- "status" => "pending",
49
- "tags" => tags,
50
- "questions" => questions,
51
- "assignment" => assignment,
52
- "step" => step,
53
- "step_name" => step_name,
54
- "resume_instructions" => resume_instructions,
55
- "answered" => false,
56
- "created_at" => time
57
- }.merge(infer_requester_context(assignment: assignment, step: step)).compact
58
-
59
- body = build_body(title: title, questions: questions)
60
- content = Ace::Support::Items::Atoms::FrontmatterSerializer.rebuild(frontmatter, body)
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 build_body(title:, questions: [])
135
- question_lines = if questions.empty?
136
- "- (pending human input)"
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
- questions.map { |q| "- #{q}" }.join("\n")
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
- <<~BODY
142
- # #{title}
223
+ <<~BODY
224
+ # #{title}
143
225
 
144
- ## Questions
226
+ ## Report
145
227
 
146
- #{question_lines}
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
- ## Answer
239
+ <<~BODY
240
+ # #{title}
149
241
 
150
- BODY
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
- kind: frontmatter["kind"] || "clarification",
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
- attr_reader :root_dir, :last_list_total, :last_folder_counts
29
+ class NotAnswerableError < StandardError; end
30
+ class AnswerConflictError < StandardError; end
30
31
 
31
- def initialize(root_dir: nil, config: nil, scope_resolver: nil, resume_dispatcher: nil)
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)