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
|
@@ -3,8 +3,8 @@ doc-type: workflow
|
|
|
3
3
|
title: HITL Workflow
|
|
4
4
|
purpose: Standardize human-attention handling for blocked and completed work using ace-hitl events.
|
|
5
5
|
ace-docs:
|
|
6
|
-
last-updated: '2026-
|
|
7
|
-
last-checked: '2026-
|
|
6
|
+
last-updated: '2026-10-18'
|
|
7
|
+
last-checked: '2026-10-18'
|
|
8
8
|
---
|
|
9
9
|
|
|
10
10
|
# HITL Workflow
|
|
@@ -15,12 +15,27 @@ Use `ace-hitl` as the canonical contract whenever agent flow needs explicit huma
|
|
|
15
15
|
|
|
16
16
|
## When To Use HITL
|
|
17
17
|
|
|
18
|
-
Create a HITL event in exactly two
|
|
18
|
+
Create a HITL event in exactly two shapes:
|
|
19
19
|
|
|
20
|
-
1. **
|
|
21
|
-
|
|
20
|
+
1. **Question requiring a decision** (blocker needing human judgment: ambiguity,
|
|
21
|
+
product decision, policy/approval gate, or completed work that needs explicit
|
|
22
|
+
confirmation). Questions stay pending and can hold the assignment until the
|
|
23
|
+
Reply arrives; no answer is never consent or success.
|
|
24
|
+
2. **Report** (work done / error notification that needs no reply):
|
|
25
|
+
`--type report --result success|error` with optional `--body` lines and
|
|
26
|
+
task/assignment/step references. Reports are recorded, never pending, and
|
|
27
|
+
`wait`/`--answer`/`--resume` on them are explicit usage errors.
|
|
22
28
|
|
|
23
|
-
Do not create
|
|
29
|
+
Do not create question events for routine status updates that do not require
|
|
30
|
+
user action; use a report instead.
|
|
31
|
+
|
|
32
|
+
## Planned surface (L2)
|
|
33
|
+
|
|
34
|
+
The normal local path uses plain event files: `create`, `list`, `show`, `update`,
|
|
35
|
+
`wait` below -- for both questions and reports. A `serve` boundary service,
|
|
36
|
+
grants, OTP challenges and proposals are not required for this path. Reply
|
|
37
|
+
routing to the correct remote case and configurable adapters (single or in a
|
|
38
|
+
set) land in the remaining L2 leaves; local write is not delivery.
|
|
24
39
|
|
|
25
40
|
## Commands
|
|
26
41
|
|
|
@@ -54,6 +69,19 @@ ace-hitl create "Review completed assignment results" \
|
|
|
54
69
|
--resume "/as-assign-drive <assignment-id>"
|
|
55
70
|
```
|
|
56
71
|
|
|
72
|
+
### 2b) Create a work-done or error report (no reply expected)
|
|
73
|
+
|
|
74
|
+
```bash
|
|
75
|
+
ace-hitl create "Work done" \
|
|
76
|
+
--type report --result success \
|
|
77
|
+
--body "Implemented the change; tests green." \
|
|
78
|
+
--task <task-ref> --assignment <assignment-id> --step <step-number>
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
Reports land with status `recorded`; never wait on them and never answer them.
|
|
82
|
+
The overseer reads them and archives processed reports with
|
|
83
|
+
`ace-hitl update <hitl-id> --move-to archive`.
|
|
84
|
+
|
|
57
85
|
### 3) Discover HITL work
|
|
58
86
|
|
|
59
87
|
```bash
|
|
@@ -76,6 +104,27 @@ Polling is the default reliability path for the requesting agent:
|
|
|
76
104
|
ace-hitl wait <hitl-id>
|
|
77
105
|
```
|
|
78
106
|
|
|
107
|
+
### 4b) Remote delivery through configured adapter routes
|
|
108
|
+
|
|
109
|
+
When `hitl.adapters.routes` is configured, deliver the persisted event
|
|
110
|
+
before waiting. Every route reports its own result; a route failure never
|
|
111
|
+
silently reroutes to another recipient.
|
|
112
|
+
|
|
113
|
+
```bash
|
|
114
|
+
ace-hitl send <hitl-id>
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
Per-route outcomes are retained on the event file: a later `send` retries
|
|
118
|
+
only undelivered or unknown-result routes (a crashed send leaves an unknown
|
|
119
|
+
result that the next `send` retries — at-least-once, recipients dedup by
|
|
120
|
+
the case ID); `--force` redelivers every route; `--dry-run` previews the
|
|
121
|
+
decisions. Discover retained events with `ace-hitl list --delivery
|
|
122
|
+
undelivered` and inspect retained failure reasons with `ace-hitl show`.
|
|
123
|
+
Redelivery retries the delivery of the same case — never the assignment.
|
|
124
|
+
|
|
125
|
+
An unavailable or failed route is an escalation reason (retry or fix the
|
|
126
|
+
route configuration); the local event file remains the durable record.
|
|
127
|
+
|
|
79
128
|
If the waiter is no longer active, operator fallback can dispatch resume:
|
|
80
129
|
|
|
81
130
|
```bash
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ace
|
|
4
|
+
module Hitl
|
|
5
|
+
module Adapters
|
|
6
|
+
# Duck-typed adapter registry with lazy entrypoint loading.
|
|
7
|
+
# Semantics mirror Ace::Runtime::Registry (kept independent on
|
|
8
|
+
# purpose): fail-closed resolution carrying the available list;
|
|
9
|
+
# only a missing entrypoint counts as unknown — a LoadError raised
|
|
10
|
+
# inside an installed entrypoint propagates so a broken adapter is
|
|
11
|
+
# never misreported as unknown.
|
|
12
|
+
class Registry
|
|
13
|
+
NAME_PATTERN = /\A[a-z][a-z0-9_]*\z/
|
|
14
|
+
ADAPTER_ENTRYPOINT_FORMAT = "ace/hitl/adapters/%s"
|
|
15
|
+
|
|
16
|
+
def initialize
|
|
17
|
+
@factories = {}
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def register(name, factory = nil, &block)
|
|
21
|
+
key = name.to_s
|
|
22
|
+
callable = factory || block
|
|
23
|
+
unless callable.respond_to?(:call)
|
|
24
|
+
raise ArgumentError, "adapter factory for '#{key}' must respond to #call"
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
@factories[key] = callable
|
|
28
|
+
nil
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def registered?(name)
|
|
32
|
+
@factories.key?(name.to_s)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def available
|
|
36
|
+
@factories.keys.sort
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# @return [Object] the built adapter (factory receives the route
|
|
40
|
+
# configuration hash)
|
|
41
|
+
# @raise [UnknownAdapterError] when the name cannot be resolved
|
|
42
|
+
def resolve(name, route: {})
|
|
43
|
+
key = name.to_s
|
|
44
|
+
load_adapter_entrypoint(key) unless @factories.key?(key)
|
|
45
|
+
|
|
46
|
+
factory = @factories[key]
|
|
47
|
+
raise UnknownAdapterError.new(requested: key, available: available) unless factory
|
|
48
|
+
|
|
49
|
+
factory.call(route)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
private
|
|
53
|
+
|
|
54
|
+
def load_adapter_entrypoint(name)
|
|
55
|
+
return unless name.match?(NAME_PATTERN)
|
|
56
|
+
|
|
57
|
+
entrypoint = format(ADAPTER_ENTRYPOINT_FORMAT, name)
|
|
58
|
+
require entrypoint
|
|
59
|
+
rescue LoadError => e
|
|
60
|
+
raise unless e.path == entrypoint
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "ace/runtime"
|
|
4
|
+
require_relative "../adapters"
|
|
5
|
+
|
|
6
|
+
module Ace
|
|
7
|
+
module Hitl
|
|
8
|
+
module Adapters
|
|
9
|
+
# Built-in terminal adapter: pushes the shared event text to a pane
|
|
10
|
+
# through any Ace::Runtime terminal (tmux, herdr). The runtime is
|
|
11
|
+
# named by the route configuration, so switching transports is pure
|
|
12
|
+
# configuration — no sender or coworker change.
|
|
13
|
+
#
|
|
14
|
+
# The event's recipient steers the target: a panel-form address on
|
|
15
|
+
# the route's machine names the pane the text goes to (the route
|
|
16
|
+
# `pane` is only the fallback for legacy recipient-less events).
|
|
17
|
+
# Every other recipient — folder form, or a panel on a different
|
|
18
|
+
# machine — is an explicit per-route refusal, never a silent send
|
|
19
|
+
# to the route's own pane.
|
|
20
|
+
class TerminalAdapter
|
|
21
|
+
def initialize(route)
|
|
22
|
+
@route_name = route["route"].to_s.strip
|
|
23
|
+
@runtime_name = route["runtime"].to_s.strip
|
|
24
|
+
@pane = route["pane"].to_s.strip
|
|
25
|
+
@machine = route["machine"].to_s
|
|
26
|
+
if @runtime_name.empty?
|
|
27
|
+
raise Error, "terminal route '#{@route_name}' missing required 'runtime' (tmux|herdr)"
|
|
28
|
+
end
|
|
29
|
+
if @pane.empty?
|
|
30
|
+
raise Error, "terminal route '#{@route_name}' missing required 'pane' handle"
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def deliver(event:, text:, recipient:, reply_to:)
|
|
35
|
+
pane = target_pane(recipient)
|
|
36
|
+
unless pane
|
|
37
|
+
return RouteResult.new(route: @route_name, adapter: "terminal", status: :failed,
|
|
38
|
+
detail: "terminal route '#{@route_name}' on machine '#{@machine}' cannot serve " \
|
|
39
|
+
"recipient #{Atoms::HitlAddress.to_s(recipient)} (panel-form recipient " \
|
|
40
|
+
"on this machine required; route pane '#{@pane}' not used)")
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
runtime = Ace::Runtime.resolve(@runtime_name)
|
|
44
|
+
runtime.send_text(pane: pane, text: text)
|
|
45
|
+
RouteResult.new(route: @route_name, adapter: "terminal", status: :delivered,
|
|
46
|
+
detail: "text pushed to #{@runtime_name} pane #{pane}")
|
|
47
|
+
rescue Ace::Runtime::UnknownRuntimeError,
|
|
48
|
+
Ace::Runtime::RuntimeUnavailableError => e
|
|
49
|
+
RouteResult.new(route: @route_name, adapter: "terminal", status: :unavailable,
|
|
50
|
+
detail: e.message)
|
|
51
|
+
rescue Ace::Runtime::Error => e
|
|
52
|
+
RouteResult.new(route: @route_name, adapter: "terminal", status: :failed,
|
|
53
|
+
detail: e.message)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
private
|
|
57
|
+
|
|
58
|
+
# The recipient's panel names the target pane, but only when the
|
|
59
|
+
# recipient lives on this route's machine (route `machine`,
|
|
60
|
+
# defaulting to hitl.machine) — a same-named pane on another
|
|
61
|
+
# machine is not the recipient. Without a recipient (legacy
|
|
62
|
+
# local-only events) the route's configured pane is used.
|
|
63
|
+
def target_pane(recipient)
|
|
64
|
+
return @pane if recipient.nil?
|
|
65
|
+
return nil unless recipient["machine"] == @machine
|
|
66
|
+
|
|
67
|
+
panel = recipient["panel"].to_s
|
|
68
|
+
panel.empty? ? nil : panel
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
Ace::Hitl::Adapters.register(:terminal, ->(route) { Ace::Hitl::Adapters::TerminalAdapter.new(route) })
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "adapters/registry"
|
|
4
|
+
|
|
5
|
+
module Ace
|
|
6
|
+
module Hitl
|
|
7
|
+
# Duck-typed HITL delivery-adapter registry (mirrors Ace::Runtime).
|
|
8
|
+
#
|
|
9
|
+
# Adapter contract (transport translation only — never role rules):
|
|
10
|
+
#
|
|
11
|
+
# Ace::Hitl::Adapters.register(:name, ->(route) { adapter })
|
|
12
|
+
# adapter.deliver(event:, text:, recipient:, reply_to:)
|
|
13
|
+
# -> Adapters::RouteResult(route:, adapter:, status:, detail:)
|
|
14
|
+
# status: :delivered | :failed | :unavailable
|
|
15
|
+
#
|
|
16
|
+
# `text` is the shared rendering from Atoms::HitlEventText — identical
|
|
17
|
+
# for every adapter. `recipient`/`reply_to` are the address hashes from
|
|
18
|
+
# the event (nil for legacy local-only events). A route delivers only
|
|
19
|
+
# to its own configured destination and never reroutes on any status.
|
|
20
|
+
#
|
|
21
|
+
# Adapter packages ship a lib/ace/hitl/adapters/<name>.rb entrypoint
|
|
22
|
+
# that calls Adapters.register; it is loaded lazily on first resolve,
|
|
23
|
+
# so a future adapter (e.g. nervus) needs no ace-hitl change.
|
|
24
|
+
module Adapters
|
|
25
|
+
class Error < StandardError; end
|
|
26
|
+
|
|
27
|
+
# Unknown adapter name; carries the requested name and the sorted
|
|
28
|
+
# list of currently available adapters.
|
|
29
|
+
class UnknownAdapterError < Error
|
|
30
|
+
attr_reader :requested, :available
|
|
31
|
+
|
|
32
|
+
def initialize(requested:, available:)
|
|
33
|
+
@requested = requested
|
|
34
|
+
@available = available
|
|
35
|
+
super("unknown HITL adapter '#{requested}' (available: #{available.sort.join(", ")})")
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Per-route delivery outcome. `detail` is a human-readable,
|
|
40
|
+
# single-line explanation (may be nil for a plain success).
|
|
41
|
+
RouteResult = Struct.new(:route, :adapter, :status, :detail, keyword_init: true) do
|
|
42
|
+
def delivered?
|
|
43
|
+
status == :delivered
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
class << self
|
|
48
|
+
def register(name, factory = nil, &block)
|
|
49
|
+
registry.register(name, factory, &block)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def registry
|
|
53
|
+
@registry ||= Registry.new
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Resetting re-registers the built-in adapters: a plain Registry.new
|
|
57
|
+
# would permanently lose :terminal (the entrypoint require is a
|
|
58
|
+
# no-op once loaded), breaking registry-level resolution in tests
|
|
59
|
+
# and long-lived processes.
|
|
60
|
+
def reset_registry!
|
|
61
|
+
@registry = Registry.new
|
|
62
|
+
register(:terminal, ->(route) { TerminalAdapter.new(route) })
|
|
63
|
+
nil
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def available
|
|
67
|
+
registry.available
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def registered?(name)
|
|
71
|
+
registry.registered?(name)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def resolve(name, route: {})
|
|
75
|
+
registry.resolve(name, route: route)
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ace
|
|
4
|
+
module Hitl
|
|
5
|
+
module Atoms
|
|
6
|
+
# Pure recipient/reply address validation and formatting. No IO.
|
|
7
|
+
#
|
|
8
|
+
# Two forms (program contract 8xg.t.sbz.1.1):
|
|
9
|
+
# - folder: machine + events folder + case id (id stamped at create)
|
|
10
|
+
# - panel: machine + panel
|
|
11
|
+
# Display strings align with the hermes <machine>/<name>/<id> shape.
|
|
12
|
+
class HitlAddress
|
|
13
|
+
TOKEN_PATTERN = /\A[a-z0-9][a-z0-9_-]*\z/i.freeze
|
|
14
|
+
PANEL_PATTERN = /\A[a-z0-9][a-z0-9_:-]{0,127}\z/i.freeze
|
|
15
|
+
|
|
16
|
+
class InvalidAddress < StandardError; end
|
|
17
|
+
|
|
18
|
+
# @param hash [Hash] symbol or string keys: machine, folder/panel, id
|
|
19
|
+
# @return [Hash] validated string-keyed address
|
|
20
|
+
# @raise [InvalidAddress] with an actionable message
|
|
21
|
+
def self.validate!(hash)
|
|
22
|
+
raise InvalidAddress, "address must be a mapping" unless hash.is_a?(Hash)
|
|
23
|
+
|
|
24
|
+
machine = hash[:machine] || hash["machine"]
|
|
25
|
+
folder = hash[:folder] || hash["folder"]
|
|
26
|
+
panel = hash[:panel] || hash["panel"]
|
|
27
|
+
id = hash[:id] || hash["id"]
|
|
28
|
+
|
|
29
|
+
unless machine.to_s.match?(TOKEN_PATTERN)
|
|
30
|
+
raise InvalidAddress, "address machine '#{machine}' is not a valid token"
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
if folder.to_s.strip.empty? && panel.to_s.strip.empty?
|
|
34
|
+
raise InvalidAddress, "address needs exactly one of folder or panel"
|
|
35
|
+
end
|
|
36
|
+
unless folder.to_s.strip.empty? || panel.to_s.strip.empty?
|
|
37
|
+
raise InvalidAddress, "address cannot have both folder and panel"
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
address = {"machine" => machine.to_s}
|
|
41
|
+
unless folder.to_s.strip.empty?
|
|
42
|
+
folder = folder.to_s
|
|
43
|
+
if folder.split(File::SEPARATOR).include?("..")
|
|
44
|
+
raise InvalidAddress, "address folder '#{folder}' must not traverse outside its root"
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
address["folder"] = folder
|
|
48
|
+
if id && !id.to_s.empty?
|
|
49
|
+
address["id"] = id.to_s
|
|
50
|
+
end
|
|
51
|
+
else
|
|
52
|
+
unless panel.to_s.match?(PANEL_PATTERN)
|
|
53
|
+
raise InvalidAddress, "address panel '#{panel}' is not a valid terminal handle"
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
address["panel"] = panel.to_s
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
address
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# @return [Boolean] true when the address carries an events folder
|
|
63
|
+
def self.folder_form?(address)
|
|
64
|
+
address.is_a?(Hash) && !address["folder"].to_s.empty?
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# @return [Boolean] true when the address carries a panel
|
|
68
|
+
def self.panel_form?(address)
|
|
69
|
+
address.is_a?(Hash) && !address["panel"].to_s.empty?
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# @return [String] canonical display form
|
|
73
|
+
def self.to_s(address)
|
|
74
|
+
return "" unless address.is_a?(Hash)
|
|
75
|
+
|
|
76
|
+
if folder_form?(address)
|
|
77
|
+
parts = [address["machine"], address["folder"]]
|
|
78
|
+
parts << address["id"] unless address["id"].to_s.empty?
|
|
79
|
+
parts.join("/")
|
|
80
|
+
elsif panel_form?(address)
|
|
81
|
+
"#{address["machine"]}/panel/#{address["panel"]}"
|
|
82
|
+
else
|
|
83
|
+
address["machine"].to_s
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "hitl_address"
|
|
4
|
+
|
|
5
|
+
module Ace
|
|
6
|
+
module Hitl
|
|
7
|
+
module Atoms
|
|
8
|
+
# Shared, adapter-independent rendering of a HITL event for delivery.
|
|
9
|
+
# Every route receives the identical text — the adapter translates
|
|
10
|
+
# transport, not role rules. Pure; no IO.
|
|
11
|
+
class HitlEventText
|
|
12
|
+
# @param event [Models::HitlEvent]
|
|
13
|
+
# @return [String] single rendered message body
|
|
14
|
+
def self.call(event)
|
|
15
|
+
lines = []
|
|
16
|
+
if event.report?
|
|
17
|
+
lines << "[HITL report #{event.result}] #{event.title} (case #{event.id})"
|
|
18
|
+
body_lines(event).each { |line| lines << line }
|
|
19
|
+
else
|
|
20
|
+
lines << "[HITL question] #{event.title} (case #{event.id})"
|
|
21
|
+
questions = event.questions.empty? ? ["(pending human input)"] : event.questions
|
|
22
|
+
questions.each { |q| lines << "Q: #{q}" }
|
|
23
|
+
if event.reply_to
|
|
24
|
+
lines << "Reply-To: #{HitlAddress.to_s(event.reply_to)}"
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
context_lines(event).each { |line| lines << line }
|
|
29
|
+
lines.join("\n")
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Report body = the `## Report` section the creator writes. The
|
|
33
|
+
# section boundary is the next `## ` heading; reports are created by
|
|
34
|
+
# HitlCreator with plain `- ` content lines only, so nothing is lost
|
|
35
|
+
# for creator-written reports (documented boundary).
|
|
36
|
+
def self.body_lines(event)
|
|
37
|
+
body = event.content.to_s
|
|
38
|
+
report_section = body.split(/^## Report\s*$/m, 2)[1].to_s
|
|
39
|
+
report_section = report_section.sub(/\A\n+/, "").split(/^## /m, 2)[0].to_s
|
|
40
|
+
lines = report_section.lines.map(&:strip).reject(&:empty?)
|
|
41
|
+
lines.empty? ? ["(no content)"] : lines
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def self.context_lines(event)
|
|
45
|
+
lines = []
|
|
46
|
+
lines << "Task: #{event.metadata["task"]}" if event.metadata["task"].to_s.strip != ""
|
|
47
|
+
lines << "Assignment: #{event.metadata["assignment"]}" if event.metadata["assignment"].to_s.strip != ""
|
|
48
|
+
lines << "Step: #{event.metadata["step"]}" if event.metadata["step"].to_s.strip != ""
|
|
49
|
+
lines
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
@@ -13,7 +13,12 @@ module Ace
|
|
|
13
13
|
|
|
14
14
|
argument :title, required: false, desc: "HITL event title"
|
|
15
15
|
|
|
16
|
-
option :kind, type: :string, aliases: %w[-k], desc: "Kind: clarification, decision, approval"
|
|
16
|
+
option :kind, type: :string, aliases: %w[-k], desc: "Kind: clarification, decision, approval (questions only)"
|
|
17
|
+
option :type, type: :string, aliases: %w[-t], desc: "Type: question (default), report"
|
|
18
|
+
option :result, type: :string, desc: "Report result: success, error (reports only)"
|
|
19
|
+
option :to, type: :string, desc: "Configured recipient name (machine+folder+ID or machine+panel)"
|
|
20
|
+
option :body, type: :string, repeat: true, aliases: %w[-b], desc: "Report content line (repeatable, reports only)"
|
|
21
|
+
option :task, type: :string, desc: "Task reference"
|
|
17
22
|
option :question, type: :string, repeat: true, aliases: %w[-Q], desc: "Question line (repeatable)"
|
|
18
23
|
option :tags, type: :string, aliases: %w[-T], desc: "Comma-separated tags"
|
|
19
24
|
option :assignment, type: :string, desc: "Assignment reference"
|
|
@@ -34,25 +39,50 @@ module Ace
|
|
|
34
39
|
kind = options[:kind]
|
|
35
40
|
validate_kind!(kind) if kind
|
|
36
41
|
|
|
42
|
+
type = options[:type]
|
|
43
|
+
validate_type!(type) if type
|
|
44
|
+
|
|
37
45
|
questions = Array(options[:question]).map(&:strip).reject(&:empty?)
|
|
46
|
+
body = Array(options[:body]).map(&:strip).reject(&:empty?)
|
|
47
|
+
result = options[:result]
|
|
48
|
+
validate_combinations!(type, result: result, body: body, questions: questions,
|
|
49
|
+
resume: options[:resume], kind: kind)
|
|
50
|
+
validate_result!(result) if result
|
|
51
|
+
|
|
38
52
|
tags = parse_tags(options[:tags])
|
|
39
53
|
|
|
40
54
|
manager = Ace::Hitl::Organisms::HitlManager.new
|
|
41
|
-
event =
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
55
|
+
event = begin
|
|
56
|
+
manager.create(
|
|
57
|
+
title,
|
|
58
|
+
to: options[:to],
|
|
59
|
+
kind: kind,
|
|
60
|
+
type: type,
|
|
61
|
+
result: result,
|
|
62
|
+
task: options[:task],
|
|
63
|
+
body: body,
|
|
64
|
+
questions: questions,
|
|
65
|
+
tags: tags,
|
|
66
|
+
assignment: options[:assignment],
|
|
67
|
+
step: options[:step],
|
|
68
|
+
step_name: options[:"step-name"],
|
|
69
|
+
resume_instructions: options[:resume],
|
|
70
|
+
move_to: options[:"move-to"]
|
|
71
|
+
)
|
|
72
|
+
rescue Ace::Hitl::Molecules::HitlAddressResolver::UnknownRecipientError,
|
|
73
|
+
Ace::Hitl::Molecules::HitlAddressResolver::InvalidAddressError => e
|
|
74
|
+
raise Ace::Support::Cli::Error.new(e.message)
|
|
75
|
+
end
|
|
52
76
|
|
|
53
77
|
folder_info = event.special_folder ? " (#{event.special_folder})" : ""
|
|
54
78
|
puts "HITL event created: #{event.id} #{event.title}#{folder_info}"
|
|
55
79
|
puts " Path: #{event.file_path}"
|
|
80
|
+
if event.recipient
|
|
81
|
+
puts " Recipient: #{Atoms::HitlAddress.to_s(event.recipient)}"
|
|
82
|
+
end
|
|
83
|
+
if event.reply_to
|
|
84
|
+
puts " Reply-To: #{Atoms::HitlAddress.to_s(event.reply_to)}"
|
|
85
|
+
end
|
|
56
86
|
end
|
|
57
87
|
|
|
58
88
|
private
|
|
@@ -64,6 +94,45 @@ module Ace
|
|
|
64
94
|
raise Ace::Support::Cli::Error.new("Invalid kind '#{kind}'. Allowed: #{allowed.join(", ")}")
|
|
65
95
|
end
|
|
66
96
|
|
|
97
|
+
def validate_type!(type)
|
|
98
|
+
allowed = %w[question report]
|
|
99
|
+
return if allowed.include?(type)
|
|
100
|
+
|
|
101
|
+
raise Ace::Support::Cli::Error.new("Invalid type '#{type}'. Allowed: #{allowed.join(", ")}")
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def validate_result!(result)
|
|
105
|
+
allowed = %w[success error]
|
|
106
|
+
return if allowed.include?(result)
|
|
107
|
+
|
|
108
|
+
raise Ace::Support::Cli::Error.new("Invalid result '#{result}'. Allowed: #{allowed.join(", ")}")
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def validate_combinations!(type, result:, body:, questions:, resume:, kind: nil)
|
|
112
|
+
report = type == "report"
|
|
113
|
+
if report
|
|
114
|
+
unless result
|
|
115
|
+
raise Ace::Support::Cli::Error.new("--type report requires --result success|error")
|
|
116
|
+
end
|
|
117
|
+
if questions.any?
|
|
118
|
+
raise Ace::Support::Cli::Error.new("--question cannot be combined with --type report; use --body lines")
|
|
119
|
+
end
|
|
120
|
+
if resume
|
|
121
|
+
raise Ace::Support::Cli::Error.new("--resume cannot be combined with --type report; reports do not resume")
|
|
122
|
+
end
|
|
123
|
+
if kind
|
|
124
|
+
raise Ace::Support::Cli::Error.new("--kind cannot be combined with --type report; kinds apply to questions only")
|
|
125
|
+
end
|
|
126
|
+
else
|
|
127
|
+
if result
|
|
128
|
+
raise Ace::Support::Cli::Error.new("--result is only valid with --type report")
|
|
129
|
+
end
|
|
130
|
+
if body.any?
|
|
131
|
+
raise Ace::Support::Cli::Error.new("--body is only valid with --type report")
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
|
|
67
136
|
def parse_tags(raw)
|
|
68
137
|
return [] unless raw
|
|
69
138
|
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "json"
|
|
4
|
+
require_relative "../../providers/lab"
|
|
4
5
|
|
|
5
6
|
module Ace
|
|
6
7
|
module Hitl
|
|
@@ -13,17 +14,10 @@ module Ace
|
|
|
13
14
|
# are one JSON line, byte-compatible with the migrated CLI
|
|
14
15
|
# contract.
|
|
15
16
|
module LifecycleCommand
|
|
16
|
-
STDIN_READER = ->(limit) { $stdin.read(limit) }.freeze
|
|
17
|
-
|
|
18
17
|
def lifecycle_client
|
|
19
18
|
Providers::Lab.boundary_client
|
|
20
19
|
end
|
|
21
20
|
|
|
22
|
-
def overseer
|
|
23
|
-
channel_root = ENV.fetch("ACE_HITL_OVERSEER_CHANNEL_ROOT", "/lab/state/overseer-channel")
|
|
24
|
-
Lifecycle::Overseer.new(outbox_dir: File.join(channel_root, "outbox"))
|
|
25
|
-
end
|
|
26
|
-
|
|
27
21
|
def emit(result)
|
|
28
22
|
puts JSON.generate(result)
|
|
29
23
|
end
|
|
@@ -14,6 +14,8 @@ module Ace
|
|
|
14
14
|
|
|
15
15
|
option :status, type: :string, aliases: %w[-s], desc: "Filter by status"
|
|
16
16
|
option :kind, type: :string, aliases: %w[-k], desc: "Filter by kind"
|
|
17
|
+
option :type, type: :string, desc: "Filter by type (question, report)"
|
|
18
|
+
option :delivery, type: :string, desc: "Filter by delivery state (undelivered, delivered)"
|
|
17
19
|
option :tags, type: :string, aliases: %w[-T], desc: "Filter by tags (comma-separated)"
|
|
18
20
|
option :in, type: :string, aliases: %w[-i], desc: "Folder filter (next, all, archive)"
|
|
19
21
|
option :scope, type: :string, desc: "Scope (current, all)"
|
|
@@ -26,11 +28,16 @@ module Ace
|
|
|
26
28
|
scope = validate_scope(options[:scope])
|
|
27
29
|
status = options[:status]
|
|
28
30
|
kind = options[:kind]
|
|
31
|
+
type = options[:type]
|
|
32
|
+
validate_type!(type) if type
|
|
33
|
+
delivery = options[:delivery]
|
|
34
|
+
validate_delivery!(delivery) if delivery
|
|
29
35
|
tags = parse_tags(options[:tags])
|
|
30
36
|
in_folder = options[:in] || "next"
|
|
31
37
|
|
|
32
38
|
manager = Ace::Hitl::Organisms::HitlManager.new
|
|
33
|
-
events = manager.list(status: status, kind: kind,
|
|
39
|
+
events = manager.list(status: status, kind: kind, type: type, delivery: delivery,
|
|
40
|
+
tags: tags, in_folder: in_folder, scope: scope)
|
|
34
41
|
|
|
35
42
|
puts Ace::Hitl::Molecules::HitlDisplayFormatter.format_list(
|
|
36
43
|
events,
|
|
@@ -47,6 +54,20 @@ module Ace
|
|
|
47
54
|
raw.split(",").map(&:strip).reject(&:empty?)
|
|
48
55
|
end
|
|
49
56
|
|
|
57
|
+
def validate_type!(type)
|
|
58
|
+
allowed = %w[question report]
|
|
59
|
+
return if allowed.include?(type)
|
|
60
|
+
|
|
61
|
+
raise Ace::Support::Cli::Error.new("Invalid type '#{type}'. Allowed: #{allowed.join(", ")}")
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def validate_delivery!(delivery)
|
|
65
|
+
allowed = %w[undelivered delivered]
|
|
66
|
+
return if allowed.include?(delivery)
|
|
67
|
+
|
|
68
|
+
raise Ace::Support::Cli::Error.new("Invalid delivery filter '#{delivery}'. Allowed: #{allowed.join(", ")}")
|
|
69
|
+
end
|
|
70
|
+
|
|
50
71
|
def validate_scope(raw_scope)
|
|
51
72
|
return nil if raw_scope.nil?
|
|
52
73
|
|