ace-hitl 0.8.10 → 0.10.0
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 +23 -0
- data/README.md +34 -0
- data/docs/usage.md +119 -0
- data/lib/ace/hitl/atoms/hitl_effect_validator.rb +117 -0
- data/lib/ace/hitl/cli/commands/ask.rb +115 -0
- data/lib/ace/hitl/cli/commands/cancel.rb +34 -0
- data/lib/ace/hitl/cli/commands/consume.rb +34 -0
- data/lib/ace/hitl/cli/commands/deliver.rb +32 -0
- data/lib/ace/hitl/cli/commands/duty.rb +29 -0
- data/lib/ace/hitl/cli/commands/lifecycle_command.rb +36 -0
- data/lib/ace/hitl/cli/commands/overseer_ack.rb +30 -0
- data/lib/ace/hitl/cli/commands/overseer_pending.rb +28 -0
- data/lib/ace/hitl/cli/commands/overseer_send.rb +35 -0
- data/lib/ace/hitl/cli/commands/pending.rb +29 -0
- data/lib/ace/hitl/cli/commands/states.rb +28 -0
- data/lib/ace/hitl/cli/commands/wait.rb +12 -0
- data/lib/ace/hitl/cli.rb +32 -1
- data/lib/ace/hitl/lifecycle/atomic_json.rb +79 -0
- data/lib/ace/hitl/lifecycle/binding.rb +27 -0
- data/lib/ace/hitl/lifecycle/duty.rb +43 -0
- data/lib/ace/hitl/lifecycle/effects.rb +254 -0
- data/lib/ace/hitl/lifecycle/errors.rb +27 -0
- data/lib/ace/hitl/lifecycle/identity.rb +66 -0
- data/lib/ace/hitl/lifecycle/kinds.rb +57 -0
- data/lib/ace/hitl/lifecycle/overseer.rb +111 -0
- data/lib/ace/hitl/lifecycle/store.rb +544 -0
- data/lib/ace/hitl/lifecycle.rb +15 -0
- data/lib/ace/hitl/molecules/lab_projection_observer.rb +97 -0
- data/lib/ace/hitl/organisms/hitl_manager.rb +51 -4
- data/lib/ace/hitl/providers/errors.rb +24 -0
- data/lib/ace/hitl/providers/lab/daemon_binding.rb +206 -0
- data/lib/ace/hitl/providers/lab.rb +124 -0
- data/lib/ace/hitl/providers/providers.rb +41 -0
- data/lib/ace/hitl/providers/ref.rb +62 -0
- data/lib/ace/hitl/version.rb +1 -1
- data/lib/ace/hitl.rb +3 -0
- metadata +30 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 765d5a3ec0942bb25b92c9e16bf325bc60f5f9c58e72da56e8b62f8c716817e2
|
|
4
|
+
data.tar.gz: b3c1015a2037ac173be8cae4ce7527fc0b099bbdf5acd419572c30992a874424
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: a54fd6f1fc8ac167b6ccd87bb724cd469c733df2a37006e2066a5123f875365ee65ef7d91132cf284167ea359bb57df6147ecc6e75f3f9cab264a9377c31809d
|
|
7
|
+
data.tar.gz: 11bd3b6c7a553d5372b9cb2ee10ac87b1586e82ca90ab470e6216f041410db987842605ce7dca8595eed31e07c17c05b0453374e0cf14b2131bd65d9982aa516
|
data/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,29 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.10.0] - 2026-09-27
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
- **Generic HITL request lifecycle (spec 8wm.t.y21, M1 migration from lab-config lab-hitl)**: the generic core now lives natively in the gem under `Ace::Hitl::Lifecycle` — request store (`create`/`pending`/`states`/`deliver`/`consume`/`cancel`), kinds + OTP/secret-shape answer gates, 0400 requester-owned answer relay, 0440 merge-on-write public projection under a per-request `flock`, no time-based expiry (W651: only an answer, its consumption, or an explicit audited `cancel` ends a request), requester-declared effect callbacks executed AS THE REQUESTER (exec-argv with `{answer}`, fullmatch regex gate, bounded timeout, redacted root-only effects log, deduped escalation with `callback-ok`/`callback-escalated` projection states and the pending+escalated duty projection), and the Overseer reverse-address surface (`overseer-send`/`overseer-pending`/`overseer-ack`: bounded, type-tagged `[decyzja]`/`[pytanie]`/`[info]`, no SHA/Work/Attempt/task IDs). New CLI commands: `deliver`, `consume`, `cancel`, `pending`, `states`, `duty`, `overseer-send`, `overseer-pending`, `overseer-ack` (machine output: one JSON line). Store root: `ACE_HITL_STORE_ROOT` (default `/run/lab/hitl`); Overseer channel root: `ACE_HITL_OVERSEER_CHANNEL_ROOT` (default `/lab/state/overseer-channel`).
|
|
14
|
+
- **Binding policy seam**: the generic store requires a fail-closed `Lifecycle::Binding` policy; provider=lab supplies `Providers::Lab::DaemonBinding`, the minimal client of the lab daemon's read-only `hitl_binding` socket op (Work/Attempt binding authority stays lab-side, per audit 8wl.t.gad.6). Escalation spooling stays behind the store's `escalation_sink` seam (the wake/`lab_control` glue stays lab-config).
|
|
15
|
+
- **Provider adapter interface + provider=lab contract (spec 8wm.t.vrz)**: `ace-hitl ask` dispatches through the `Ace::Hitl::Providers` registry (selection: `--provider` flag → `ACE_HITL_PROVIDER` env → `lab`). The ask performs the local-event + transport send in ONE operation and captures the asker's reverse address fail-closed from the herdr environment (`HERDR_SESSION` / `HERDR_PANE`; versioned schema `ace.hitl.ref/v1`), persisting `provider`, `ref_schema`, `ref_session`, `ref_pane` alongside the existing `lab_request_*` fields. Pinned error model: `UnknownProviderError`, `InvalidRefError` (fail closed before any event or transport state), `ProviderUnavailableError` (transport failure; orphan event id message preserved), `UnsupportedOperationError` (`deliver(ref, answer)` lands with ace-herdr push delivery 8wm.t.vs0 + provider=lab integration 8wm.t.vs2; `wait` remains the pane-less CLI path outside the adapter).
|
|
16
|
+
|
|
17
|
+
### Changed
|
|
18
|
+
- **provider=lab `ask` creates the relay request through the native lifecycle store** (binding + effect declared in-process). The external-binary transport `Providers::Lab::Transport` is DELETED (pre-1.0; supersedes the 8wm.t.vrz §7 re-homing); the orphan-event `ProviderUnavailableError` contract is preserved. File and record formats stay byte-compatible with the deployed lab consumers.
|
|
19
|
+
- **Zero-lab-hitl guard**: the legacy `Molecules::LabRequestSubmitter` was deleted and re-homed (same behavior, provider error model) as `Providers::Lab::Transport`, the sole owner of the lab transport binary reference. A fast guard test keeps every agent-facing ace-hitl path free of direct lab transport references, and agent-facing ask/wait output no longer names the relay binary.
|
|
20
|
+
|
|
21
|
+
### Removed
|
|
22
|
+
- `Providers::Lab::Transport` and the `ACE_HITL_LAB_BIN` selection: the relay request path no longer shells out to an external binary.
|
|
23
|
+
|
|
24
|
+
### Fixed
|
|
25
|
+
- **PR#336 review hardening (codex astra high)**: unique atomic-writer temporary files so competing writers can no longer delete each other's in-flight temp (store answer relay included); delivery and cancel re-validate the request incarnation and ownership under the per-request lock (a cancel+recreate of the same id can no longer redirect an answer into the new incarnation); the first projection is initialized inside the lifecycle lock behind a per-incarnation token so a broker delivery can never be regressed to `created`; effect callbacks spawn with forced exec/argv semantics (a single-element declaration can no longer reach a shell) as process group leaders whose whole group is terminated on timeout; `{answer}` substitution is literal (block-form `gsub`, no replacement-string backreferences); answer bounds are enforced on decoded UTF-8 characters with a separate byte bound, so valid multibyte answers through real IO are accepted.
|
|
26
|
+
|
|
27
|
+
## [0.9.0] - 2026-09-23
|
|
28
|
+
|
|
29
|
+
### Added
|
|
30
|
+
- **`ace-hitl ask`**: requester-side effect-callback API. Creates the local HITL event, binds it to a Lab HITL request via `--ace-hitl-id`, and passes effect declarations (`--effect-match`, `--effect-arg`, `--effect-cwd`, `--effect-timeout-s`) through verbatim after client-side bounds mirroring (match <= 200 chars and compilable; argv 1..16 x 1..512 chars using the lab's strip-then-bounds check so whitespace-only elements fail fast, valid values pass through verbatim; at least one element when any effect flag is present; cwd absolute and existing; timeout 1..600). Prints both the event id and the Lab request id, records `lab_request_effect: declared|none` on the event, and — when the Lab submit fails after the event was created — surfaces the orphan event id in the error.
|
|
31
|
+
- **`ace-hitl wait` Lab awareness**: while waiting on the event answer, the waiter also observes the Lab request public projection (`/run/lab/hitl/public/<id>.json`, overridable via `ACE_HITL_LAB_PUBLIC_DIR`) across BOTH schema fields — the lifecycle `state` (created / answer-delivered / consumed / cancelled) and the separate `effect_state` (callback-pending-with-answer / callback-ok / callback-escalated). Terminal semantics are effect-aware: requests without a declared effect terminate on lifecycle states; effect-declaring requests keep waiting until the callback verdict appears instead of ending at answer delivery, and `callback-escalated` output points at `lab-hitl duty`. The event's `lab_request_state` records the effective state and never claims plain `answer-delivered` while an effect outcome exists. Relay consumption stays the agent's choice (`lab-hitl consume`).
|
|
32
|
+
|
|
10
33
|
|
|
11
34
|
## [0.8.10] - 2026-09-02
|
|
12
35
|
|
data/README.md
CHANGED
|
@@ -10,10 +10,16 @@ Canonical workflow and skill for agents:
|
|
|
10
10
|
## Commands
|
|
11
11
|
|
|
12
12
|
- `ace-hitl create` creates a HITL event
|
|
13
|
+
- `ace-hitl ask` asks a human via HITL and forwards the request through a provider adapter (`--provider`, default `lab`); ONE operation: local event + relay request through the native lifecycle store (`--work`, effect callback flags)
|
|
13
14
|
- `ace-hitl list` lists HITL events with filters (`--scope current|all`, all statuses by default)
|
|
14
15
|
- `ace-hitl show` renders event details, path, or raw content (`--scope current|all`)
|
|
15
16
|
- `ace-hitl update` updates frontmatter, answer content, and folder location
|
|
16
17
|
- `ace-hitl wait` polls a specific HITL event until answered (`--poll-every`, `--timeout`)
|
|
18
|
+
- `ace-hitl deliver` answers a pending relay request from stdin (host-broker operation); executes the declared effect callback as the requester
|
|
19
|
+
- `ace-hitl consume` consumes one own relay request's answer (indefinite by default; `--timeout` bounds only the local wait)
|
|
20
|
+
- `ace-hitl cancel` cancels with an audited reason (the only way to abandon a request)
|
|
21
|
+
- `ace-hitl pending` / `ace-hitl states` / `ace-hitl duty` host-broker projections (pending, public lifecycle records, pending + escalated)
|
|
22
|
+
- `ace-hitl overseer-send` / `ace-hitl overseer-pending` / `ace-hitl overseer-ack` the Overseer reverse-address response channel
|
|
17
23
|
|
|
18
24
|
`ace-hitl` is a blocker-resolution tool, not a global dashboard:
|
|
19
25
|
|
|
@@ -22,12 +28,40 @@ Canonical workflow and skill for agents:
|
|
|
22
28
|
|
|
23
29
|
Use `ace-overseer status` for a global worktree dashboard.
|
|
24
30
|
|
|
31
|
+
## Provider adapters
|
|
32
|
+
|
|
33
|
+
`ace-hitl ask` dispatches through the `Ace::Hitl::Providers` registry
|
|
34
|
+
(selection: `--provider` flag → `ACE_HITL_PROVIDER` env → `lab`).
|
|
35
|
+
|
|
36
|
+
- `ask` is ONE operation: it creates the local HITL event and the relay
|
|
37
|
+
request through the NATIVE generic lifecycle store
|
|
38
|
+
(`Ace::Hitl::Lifecycle`; migration spec 8wm.t.y21), then persists
|
|
39
|
+
`provider`, `ref_schema`, `ref_session`, `ref_pane` plus the existing
|
|
40
|
+
`lab_request_*` fields.
|
|
41
|
+
- The asker's reverse address (`ref`, versioned schema
|
|
42
|
+
`ace.hitl.ref/v1`: herdr session + pane) is captured fail-closed from
|
|
43
|
+
`HERDR_SESSION` / `HERDR_PANE`; absent or invalid values abort the ask
|
|
44
|
+
before any event is created or store state changes.
|
|
45
|
+
- Error model: `UnknownProviderError`, `InvalidRefError`,
|
|
46
|
+
`ProviderUnavailableError` (store-create failure; surfaces the orphan
|
|
47
|
+
event id when one was already created), `UnsupportedOperationError`.
|
|
48
|
+
- `deliver(ref, answer)` (push the answer back to the asker's pane) is
|
|
49
|
+
declared by the interface; provider `lab` raises
|
|
50
|
+
`UnsupportedOperationError` until the ace-herdr push-delivery
|
|
51
|
+
integration lands. `ace-hitl wait` stays the pane-less script path and
|
|
52
|
+
does not go through a provider.
|
|
53
|
+
- The generic lifecycle is provider-agnostic; all lab coupling lives in
|
|
54
|
+
the provider=lab seams (the `Providers::Lab::DaemonBinding` labd
|
|
55
|
+
binding client and the store factory), enforced by guard tests.
|
|
56
|
+
|
|
25
57
|
## Examples
|
|
26
58
|
|
|
27
59
|
```bash
|
|
28
60
|
ace-hitl list
|
|
29
61
|
ace-hitl list --scope all
|
|
30
62
|
ace-hitl create "Which auth strategy?" --kind decision --question "JWT or sessions?"
|
|
63
|
+
ace-hitl ask "Proceed with deploy?" --work W685 --effect-arg /bin/false --effect-cwd /tmp
|
|
64
|
+
ace-hitl ask "Proceed with deploy?" --provider lab --work W685
|
|
31
65
|
ace-hitl show abc123 --content
|
|
32
66
|
ace-hitl show abc123 --scope current
|
|
33
67
|
ace-hitl update abc123 --answer "Use JWT with server-side refresh tokens."
|
data/docs/usage.md
CHANGED
|
@@ -102,6 +102,105 @@ ace-hitl update abc123 --move-to next
|
|
|
102
102
|
ace-hitl update abc123 --answer "close the assignment" --resume
|
|
103
103
|
```
|
|
104
104
|
|
|
105
|
+
## Ask (Provider adapter with effect callback)
|
|
106
|
+
|
|
107
|
+
`ace-hitl ask` dispatches through the provider adapter registry
|
|
108
|
+
(`--provider`, default: `ACE_HITL_PROVIDER` env, then `lab`). ONE
|
|
109
|
+
operation: it creates the local HITL event, forwards the question through
|
|
110
|
+
the provider transport bound to the event via `--ace-hitl-id`, and prints
|
|
111
|
+
both ids. Effect declarations are validated client-side (exact bounds)
|
|
112
|
+
and passed through verbatim into the native relay request store.
|
|
113
|
+
|
|
114
|
+
```bash
|
|
115
|
+
ace-hitl ask "Proceed with deploy?" \
|
|
116
|
+
--work W685 \
|
|
117
|
+
--effect-arg /usr/bin/notify-send "{answer}" \
|
|
118
|
+
--effect-cwd /tmp
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
- `--attempt` defaults to `LAB_ATTEMPT_ID`; `--project` to `ace`;
|
|
122
|
+
`--harness` to `lab-admin`; `--plan` to `ace-hitl ask`.
|
|
123
|
+
- Reverse address (fail closed): the asker's herdr session + pane are
|
|
124
|
+
read from `HERDR_SESSION` / `HERDR_PANE` and persisted on the event as
|
|
125
|
+
`ref_session` / `ref_pane` with `ref_schema: ace.hitl.ref/v1` and
|
|
126
|
+
`provider: lab`. Absent or invalid values abort the ask before any
|
|
127
|
+
event is created or transport is called — an ask must always know
|
|
128
|
+
where its answer can be delivered.
|
|
129
|
+
- Effect flags: `--effect-match` (regex, <= 200 chars, must compile),
|
|
130
|
+
`--effect-arg` (repeatable, 1..16 x 1..512 chars after the lab's
|
|
131
|
+
strip-then-bounds check; whitespace-only elements fail fast, valid
|
|
132
|
+
values pass through verbatim; `{answer}` substituted lab-side),
|
|
133
|
+
`--effect-cwd` (absolute, must exist), `--effect-timeout-s` (1..600).
|
|
134
|
+
- Whether an effect was declared is recorded on the event as
|
|
135
|
+
`lab_request_effect: declared|none` so `wait` can apply the right
|
|
136
|
+
terminal semantics.
|
|
137
|
+
- The answer is always relayed unchanged; consumption stays on the
|
|
138
|
+
operator side via `ace-hitl consume`.
|
|
139
|
+
- If the transport send fails after the local event was created, the
|
|
140
|
+
error surfaces the event id as an orphan (created but never bound to a
|
|
141
|
+
relay request); inspect it with `ace-hitl show <id>` and delete or
|
|
142
|
+
re-ask as needed.
|
|
143
|
+
- `deliver(ref, answer)` — pushing the answer back to the asker's pane —
|
|
144
|
+
is declared by the adapter interface; provider `lab` reports it as
|
|
145
|
+
unsupported until the ace-herdr push-delivery integration lands.
|
|
146
|
+
|
|
147
|
+
## Relay Lifecycle (Generic HITL Request Store)
|
|
148
|
+
|
|
149
|
+
The generic relay request lifecycle is native to the gem
|
|
150
|
+
(`Ace::Hitl::Lifecycle`; migration spec 8wm.t.y21). Requests are
|
|
151
|
+
file-backed, never expire on a timer, and every terminal transition
|
|
152
|
+
shares one per-request lock. The store root is `ACE_HITL_STORE_ROOT`
|
|
153
|
+
(default `/run/lab/hitl`); the Overseer channel root is
|
|
154
|
+
`ACE_HITL_OVERSEER_CHANNEL_ROOT` (default `/lab/state/overseer-channel`).
|
|
155
|
+
Machine output is one JSON line.
|
|
156
|
+
|
|
157
|
+
Operator/broker side (host-broker operations are root-only):
|
|
158
|
+
|
|
159
|
+
```bash
|
|
160
|
+
ace-hitl pending # answerable requests
|
|
161
|
+
ace-hitl states # all public lifecycle projections
|
|
162
|
+
ace-hitl duty # pending + escalated projection
|
|
163
|
+
ace-hitl deliver hitl-0a1b2c3d4e5f6708 <<< "approved"
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
`deliver` reads the answer from stdin, relays it unchanged (0400,
|
|
167
|
+
owner = requester) into `secrets/` for OTP kinds or `answers/` for
|
|
168
|
+
everything else, re-verifies attempt liveness under the request lock,
|
|
169
|
+
and then executes the declared effect callback AS THE REQUESTER:
|
|
170
|
+
exec-style argv (never a shell), `{answer}` substituted once per
|
|
171
|
+
element, optional fullmatch regex gate, bounded timeout, attempts
|
|
172
|
+
logged redacted in the root-only effects log, and one deduped
|
|
173
|
+
escalation with `effect_state: callback-escalated` in the public
|
|
174
|
+
projection on failure (`callback-ok` on success).
|
|
175
|
+
|
|
176
|
+
Requester side:
|
|
177
|
+
|
|
178
|
+
```bash
|
|
179
|
+
ace-hitl consume hitl-0a1b2c3d4e5f6708
|
|
180
|
+
ace-hitl consume hitl-0a1b2c3d4e5f6708 --timeout 600
|
|
181
|
+
ace-hitl cancel hitl-0a1b2c3d4e5f6708 --reason "operator stopped the work"
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
A consume timeout bounds ONLY the local wait — the request stays
|
|
185
|
+
pending and answerable. Cancel is the ONLY way to abandon a request;
|
|
186
|
+
it records `cancelled_by` and the `reason` in the public projection,
|
|
187
|
+
and a late answer fails closed.
|
|
188
|
+
|
|
189
|
+
Overseer reverse address (bounded, type-tagged responses):
|
|
190
|
+
|
|
191
|
+
```bash
|
|
192
|
+
ace-hitl overseer-send --reply-to 321 <<< "[decyzja] Rekomendacja: A."
|
|
193
|
+
ace-hitl overseer-pending
|
|
194
|
+
ace-hitl overseer-ack msg-0123456789abcdef
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
Responses are 1..1200 characters and must open with a type tag
|
|
198
|
+
(`[decyzja]`, `[pytanie]`, or `[info]`); full SHAs, Work/Attempt/task
|
|
199
|
+
IDs, or the word "SHA" are rejected. `overseer-send` is the overseer
|
|
200
|
+
user's operation; `overseer-pending`/`overseer-ack` are host-broker
|
|
201
|
+
(root) operations used by the transport to drain and acknowledge
|
|
202
|
+
relayed responses.
|
|
203
|
+
|
|
105
204
|
## Wait (Polling Default)
|
|
106
205
|
|
|
107
206
|
Wait only for a specific HITL id. This is the default reliability path for the requester agent.
|
|
@@ -112,6 +211,26 @@ ace-hitl wait abc123 --poll-every 600 --timeout 14400
|
|
|
112
211
|
ace-hitl wait abc123 --scope current
|
|
113
212
|
```
|
|
114
213
|
|
|
214
|
+
When the event carries a Lab request (`lab_request_id`), wait also observes
|
|
215
|
+
the Lab public projection instead of hanging blind. Both projection fields
|
|
216
|
+
are observed: the lifecycle `state` (created / answer-delivered / consumed /
|
|
217
|
+
cancelled) and the separate `effect_state` (callback-pending-with-answer /
|
|
218
|
+
callback-ok / callback-escalated). Terminal semantics are effect-aware:
|
|
219
|
+
|
|
220
|
+
- Requests without a declared effect terminate on lifecycle states
|
|
221
|
+
(`answer-delivered`, `consumed`, `cancelled`).
|
|
222
|
+
- Effect-declaring requests keep waiting until the callback verdict
|
|
223
|
+
(`callback-ok` or `callback-escalated`) appears — they never end
|
|
224
|
+
silently at answer delivery; `callback-escalated` output points at
|
|
225
|
+
the lab duty projection for the escalation.
|
|
226
|
+
- The event's `lab_request_state` records the effective state, so it
|
|
227
|
+
never claims plain `answer-delivered` while an effect outcome exists.
|
|
228
|
+
|
|
229
|
+
Relay consumption stays on the operator side via `ace-hitl consume`.
|
|
230
|
+
`wait` is the pane-less script path: agents with a herdr pane ask
|
|
231
|
+
through the provider adapter and receive answers delivered back to
|
|
232
|
+
their pane.
|
|
233
|
+
|
|
115
234
|
## Lifecycle Event Names
|
|
116
235
|
|
|
117
236
|
Canonical namespace for HITL lifecycle signaling:
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ace
|
|
4
|
+
module Hitl
|
|
5
|
+
module Atoms
|
|
6
|
+
# Client-side mirror of the Lab effect-declaration bounds so invalid
|
|
7
|
+
# requests fail fast, before any external call. Declarations are never
|
|
8
|
+
# rewritten or "fixed": values pass through verbatim or fail.
|
|
9
|
+
class HitlEffectValidator
|
|
10
|
+
MAX_MATCH_LENGTH = 200
|
|
11
|
+
MIN_ARGV_ELEMENTS = 1
|
|
12
|
+
MAX_ARGV_ELEMENTS = 16
|
|
13
|
+
MAX_ARG_LENGTH = 512
|
|
14
|
+
MIN_TIMEOUT = 1
|
|
15
|
+
MAX_TIMEOUT = 600
|
|
16
|
+
|
|
17
|
+
class ValidationError < StandardError; end
|
|
18
|
+
|
|
19
|
+
def self.validate!(match: nil, effect_args: [], effect_cwd: nil, effect_timeout: nil)
|
|
20
|
+
new(
|
|
21
|
+
match: match,
|
|
22
|
+
effect_args: effect_args,
|
|
23
|
+
effect_cwd: effect_cwd,
|
|
24
|
+
effect_timeout: effect_timeout
|
|
25
|
+
).validate!
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def initialize(match:, effect_args:, effect_cwd:, effect_timeout:)
|
|
29
|
+
@match = present?(match) ? match.to_s : nil
|
|
30
|
+
@effect_args = Array(effect_args)
|
|
31
|
+
@effect_cwd = present?(effect_cwd) ? effect_cwd.to_s : nil
|
|
32
|
+
@effect_timeout = present?(effect_timeout) ? effect_timeout.to_s : nil
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def validate!
|
|
36
|
+
validate_match
|
|
37
|
+
validate_effect_args
|
|
38
|
+
validate_effect_cwd
|
|
39
|
+
validate_effect_timeout
|
|
40
|
+
nil
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
private
|
|
44
|
+
|
|
45
|
+
def present?(value)
|
|
46
|
+
!(value.nil? || (value.respond_to?(:strip) ? value.strip.empty? : value.empty?))
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def any_effect_flag?
|
|
50
|
+
@match || @effect_cwd || @effect_timeout || !@effect_args.empty?
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def validate_match
|
|
54
|
+
return if @match.nil?
|
|
55
|
+
|
|
56
|
+
if @match.length > MAX_MATCH_LENGTH
|
|
57
|
+
raise ValidationError, "--effect-match exceeds #{MAX_MATCH_LENGTH} characters (got #{@match.length})"
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
begin
|
|
61
|
+
Regexp.new(@match)
|
|
62
|
+
rescue RegexpError => e
|
|
63
|
+
raise ValidationError, "--effect-match does not compile: #{e.message}"
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def validate_effect_args
|
|
68
|
+
if any_effect_flag? && @effect_args.empty?
|
|
69
|
+
raise ValidationError, "at least one --effect-arg is required when any effect flag is present"
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
if @effect_args.length > MAX_ARGV_ELEMENTS
|
|
73
|
+
raise ValidationError, "too many --effect-arg values (#{@effect_args.length}); max #{MAX_ARGV_ELEMENTS}"
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
@effect_args.each_with_index do |arg, index|
|
|
77
|
+
# Mirror the lab's strip-then-bounds check; the value itself
|
|
78
|
+
# still passes through verbatim.
|
|
79
|
+
stripped = arg.to_s.strip
|
|
80
|
+
if stripped.empty?
|
|
81
|
+
raise ValidationError, "--effect-arg ##{index + 1} is empty"
|
|
82
|
+
end
|
|
83
|
+
if stripped.length > MAX_ARG_LENGTH
|
|
84
|
+
raise ValidationError, "--effect-arg ##{index + 1} exceeds #{MAX_ARG_LENGTH} characters (got #{stripped.length})"
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def validate_effect_cwd
|
|
90
|
+
return if @effect_cwd.nil?
|
|
91
|
+
|
|
92
|
+
unless Pathname.new(@effect_cwd).absolute?
|
|
93
|
+
raise ValidationError, "--effect-cwd must be an absolute path (got '#{@effect_cwd}')"
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
return if File.directory?(@effect_cwd)
|
|
97
|
+
|
|
98
|
+
raise ValidationError, "--effect-cwd does not exist: #{@effect_cwd}"
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def validate_effect_timeout
|
|
102
|
+
return if @effect_timeout.nil?
|
|
103
|
+
|
|
104
|
+
seconds = begin
|
|
105
|
+
Integer(@effect_timeout, 10)
|
|
106
|
+
rescue ArgumentError
|
|
107
|
+
raise ValidationError, "--effect-timeout-s must be an integer (got '#{@effect_timeout}')"
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
return if seconds.between?(MIN_TIMEOUT, MAX_TIMEOUT)
|
|
111
|
+
|
|
112
|
+
raise ValidationError, "--effect-timeout-s must be between #{MIN_TIMEOUT} and #{MAX_TIMEOUT} (got #{@effect_timeout})"
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "ace/support/cli"
|
|
4
|
+
require_relative "../../lifecycle"
|
|
5
|
+
require_relative "../../atoms/hitl_effect_validator"
|
|
6
|
+
require_relative "../../providers/providers"
|
|
7
|
+
|
|
8
|
+
module Ace
|
|
9
|
+
module Hitl
|
|
10
|
+
module CLI
|
|
11
|
+
module Commands
|
|
12
|
+
class Ask < Ace::Support::Cli::Command
|
|
13
|
+
include Ace::Support::Cli::Base
|
|
14
|
+
|
|
15
|
+
desc "Ask a human via HITL and forward the request through a provider adapter"
|
|
16
|
+
|
|
17
|
+
argument :question, required: true, desc: "Question text for the human"
|
|
18
|
+
|
|
19
|
+
option :title, type: :string, desc: "Local HITL event title (defaults to the question)"
|
|
20
|
+
option :provider, type: :string, desc: "HITL provider adapter (default: ACE_HITL_PROVIDER or lab)"
|
|
21
|
+
option :work, type: :string, desc: "Lab Work id (W...)"
|
|
22
|
+
option :attempt, type: :string, desc: "Lab Attempt id (A-...); defaults to LAB_ATTEMPT_ID"
|
|
23
|
+
option :project, type: :string, desc: "Lab project label (default: ace)"
|
|
24
|
+
option :harness, type: :string, desc: "Lab harness label (default: lab-admin)"
|
|
25
|
+
option :plan, type: :string, desc: "Lab plan label (default: ace-hitl ask)"
|
|
26
|
+
|
|
27
|
+
option :"effect-match", type: :string, desc: "Effect callback regex gate on the answer (<= 200 chars)"
|
|
28
|
+
option :"effect-arg", type: :string, repeat: true, desc: "Effect callback argv element, repeatable (1..16 x 1..512 chars)"
|
|
29
|
+
option :"effect-cwd", type: :string, desc: "Effect callback working directory (absolute, must exist)"
|
|
30
|
+
option :"effect-timeout-s", type: :string, desc: "Effect callback timeout in seconds (1..600)"
|
|
31
|
+
|
|
32
|
+
option :quiet, type: :boolean, aliases: %w[-q], desc: "Suppress non-essential output"
|
|
33
|
+
option :verbose, type: :boolean, aliases: %w[-v], desc: "Show verbose output"
|
|
34
|
+
option :debug, type: :boolean, aliases: %w[-d], desc: "Show debug output"
|
|
35
|
+
|
|
36
|
+
def call(question:, **options)
|
|
37
|
+
effect = build_effect(options)
|
|
38
|
+
validate_effect!(effect)
|
|
39
|
+
|
|
40
|
+
work = require_work!(options)
|
|
41
|
+
attempt = require_attempt!(options)
|
|
42
|
+
provider = resolve_provider(options[:provider])
|
|
43
|
+
ref = capture_ref
|
|
44
|
+
|
|
45
|
+
result = provider.ask(
|
|
46
|
+
question: question,
|
|
47
|
+
title: options[:title],
|
|
48
|
+
ref: ref,
|
|
49
|
+
work: work,
|
|
50
|
+
attempt: attempt,
|
|
51
|
+
project: options[:project] || Providers::Lab::DEFAULT_PROJECT,
|
|
52
|
+
harness: options[:harness] || Providers::Lab::DEFAULT_HARNESS,
|
|
53
|
+
plan: options[:plan] || Providers::Lab::DEFAULT_PLAN,
|
|
54
|
+
effect: effect
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
puts "HITL event: #{result.event_id}"
|
|
58
|
+
puts "Provider: #{Providers::Lab::PROVIDER_NAME} (ref #{ref.session}/#{ref.pane}, #{Providers::Ref::SCHEMA})"
|
|
59
|
+
puts "Lab request: #{result.request_id}"
|
|
60
|
+
rescue Providers::ProviderUnavailableError => e
|
|
61
|
+
raise_cli_error(e.message)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
private
|
|
65
|
+
|
|
66
|
+
def build_effect(options)
|
|
67
|
+
{
|
|
68
|
+
match: options[:"effect-match"],
|
|
69
|
+
effect_args: Array(options[:"effect-arg"]),
|
|
70
|
+
effect_cwd: options[:"effect-cwd"],
|
|
71
|
+
effect_timeout: options[:"effect-timeout-s"]
|
|
72
|
+
}
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def validate_effect!(effect)
|
|
76
|
+
Atoms::HitlEffectValidator.validate!(**effect)
|
|
77
|
+
rescue Atoms::HitlEffectValidator::ValidationError => e
|
|
78
|
+
raise_cli_error(e.message)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def require_work!(options)
|
|
82
|
+
work = options[:work]
|
|
83
|
+
raise_cli_error("--work required (Lab Work id, e.g. W685)") if work.nil? || work.strip.empty?
|
|
84
|
+
|
|
85
|
+
work
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def require_attempt!(options)
|
|
89
|
+
attempt = options[:attempt] || ENV["LAB_ATTEMPT_ID"]
|
|
90
|
+
unless attempt && !attempt.strip.empty?
|
|
91
|
+
raise_cli_error("--attempt required (or set LAB_ATTEMPT_ID)")
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
attempt
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def resolve_provider(raw)
|
|
98
|
+
name = raw || ENV["ACE_HITL_PROVIDER"] || Providers::Lab::PROVIDER_NAME
|
|
99
|
+
Providers.resolve(name)
|
|
100
|
+
rescue Providers::UnknownProviderError => e
|
|
101
|
+
raise_cli_error(e.message)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# Fail closed BEFORE any state is created: the reverse address is
|
|
105
|
+
# required so the answer can be delivered back to this pane.
|
|
106
|
+
def capture_ref
|
|
107
|
+
Providers::Ref.from_env
|
|
108
|
+
rescue Providers::InvalidRefError => e
|
|
109
|
+
raise_cli_error(e.message)
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "ace/support/cli"
|
|
4
|
+
require_relative "lifecycle_command"
|
|
5
|
+
|
|
6
|
+
module Ace
|
|
7
|
+
module Hitl
|
|
8
|
+
module CLI
|
|
9
|
+
module Commands
|
|
10
|
+
# The ONLY way to abandon a relay request: explicit, audited
|
|
11
|
+
# cancellation recorded in the public lifecycle projection
|
|
12
|
+
# (spec 8wm.t.y21 §3).
|
|
13
|
+
class Cancel < Ace::Support::Cli::Command
|
|
14
|
+
include Ace::Support::Cli::Base
|
|
15
|
+
include LifecycleCommand
|
|
16
|
+
|
|
17
|
+
desc "Cancel one HITL relay request with an audited reason"
|
|
18
|
+
|
|
19
|
+
argument :id, required: true, desc: "HITL relay request id"
|
|
20
|
+
|
|
21
|
+
option :reason, type: :string, desc: "Audited reason recorded in the public lifecycle record"
|
|
22
|
+
|
|
23
|
+
option :quiet, type: :boolean, aliases: %w[-q], desc: "Suppress non-essential output"
|
|
24
|
+
|
|
25
|
+
def call(id:, **options)
|
|
26
|
+
emit(lifecycle_store.cancel(id, reason: options[:reason] || ""))
|
|
27
|
+
rescue Lifecycle::Error => e
|
|
28
|
+
raise_lifecycle_error(e.message)
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "ace/support/cli"
|
|
4
|
+
require_relative "lifecycle_command"
|
|
5
|
+
|
|
6
|
+
module Ace
|
|
7
|
+
module Hitl
|
|
8
|
+
module CLI
|
|
9
|
+
module Commands
|
|
10
|
+
# Requester-side answer consumption. A positive timeout bounds
|
|
11
|
+
# ONLY the local wait and never cancels the request (W651);
|
|
12
|
+
# without it the wait is indefinite (spec 8wm.t.y21 §3).
|
|
13
|
+
class Consume < Ace::Support::Cli::Command
|
|
14
|
+
include Ace::Support::Cli::Base
|
|
15
|
+
include LifecycleCommand
|
|
16
|
+
|
|
17
|
+
desc "Wait for and consume the answer of one own HITL relay request"
|
|
18
|
+
|
|
19
|
+
argument :id, required: true, desc: "HITL relay request id"
|
|
20
|
+
|
|
21
|
+
option :timeout, type: :integer, desc: "Local wait bound in seconds; 0 (default) waits indefinitely"
|
|
22
|
+
|
|
23
|
+
option :quiet, type: :boolean, aliases: %w[-q], desc: "Suppress non-essential output"
|
|
24
|
+
|
|
25
|
+
def call(id:, **options)
|
|
26
|
+
emit(lifecycle_store.consume(id, timeout: options[:timeout] || 0))
|
|
27
|
+
rescue Lifecycle::Error => e
|
|
28
|
+
raise_lifecycle_error(e.message)
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "ace/support/cli"
|
|
4
|
+
require_relative "lifecycle_command"
|
|
5
|
+
|
|
6
|
+
module Ace
|
|
7
|
+
module Hitl
|
|
8
|
+
module CLI
|
|
9
|
+
module Commands
|
|
10
|
+
# Operator/broker answering of one relay request (the "respond"
|
|
11
|
+
# surface). The answer is read from stdin; the effect callback,
|
|
12
|
+
# if declared, executes after the relay (spec 8wm.t.y21 §3, §5).
|
|
13
|
+
class Deliver < Ace::Support::Cli::Command
|
|
14
|
+
include Ace::Support::Cli::Base
|
|
15
|
+
include LifecycleCommand
|
|
16
|
+
|
|
17
|
+
desc "Deliver an answer (stdin) to a pending HITL relay request"
|
|
18
|
+
|
|
19
|
+
argument :id, required: true, desc: "HITL relay request id"
|
|
20
|
+
|
|
21
|
+
option :quiet, type: :boolean, aliases: %w[-q], desc: "Suppress non-essential output"
|
|
22
|
+
|
|
23
|
+
def call(id:, **options)
|
|
24
|
+
emit(lifecycle_store.deliver(id, LifecycleCommand::STDIN_READER))
|
|
25
|
+
rescue Lifecycle::Error => e
|
|
26
|
+
raise_lifecycle_error(e.message)
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "ace/support/cli"
|
|
4
|
+
require_relative "lifecycle_command"
|
|
5
|
+
|
|
6
|
+
module Ace
|
|
7
|
+
module Hitl
|
|
8
|
+
module CLI
|
|
9
|
+
module Commands
|
|
10
|
+
# The standing-duty projection: pending + escalated (spec
|
|
11
|
+
# 8wm.t.y21 §7).
|
|
12
|
+
class Duty < Ace::Support::Cli::Command
|
|
13
|
+
include Ace::Support::Cli::Base
|
|
14
|
+
include LifecycleCommand
|
|
15
|
+
|
|
16
|
+
desc "Project pending and escalated HITL requests (host-broker operation)"
|
|
17
|
+
|
|
18
|
+
option :quiet, type: :boolean, aliases: %w[-q], desc: "Suppress non-essential output"
|
|
19
|
+
|
|
20
|
+
def call(**options)
|
|
21
|
+
emit(Lifecycle::Duty.project(lifecycle_store))
|
|
22
|
+
rescue Lifecycle::Error => e
|
|
23
|
+
raise_lifecycle_error(e.message)
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Ace
|
|
6
|
+
module Hitl
|
|
7
|
+
module CLI
|
|
8
|
+
module Commands
|
|
9
|
+
# Shared plumbing for the operator/broker lifecycle commands
|
|
10
|
+
# (spec 8wm.t.y21 §8): the store is built through the provider=lab
|
|
11
|
+
# seam and machine outputs are one JSON line, byte-compatible
|
|
12
|
+
# with the migrated CLI contract.
|
|
13
|
+
module LifecycleCommand
|
|
14
|
+
STDIN_READER = ->(limit) { $stdin.read(limit) }.freeze
|
|
15
|
+
|
|
16
|
+
def lifecycle_store(store: nil)
|
|
17
|
+
Providers::Lab.lifecycle_store(store: store)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def overseer
|
|
21
|
+
channel_root = ENV.fetch("ACE_HITL_OVERSEER_CHANNEL_ROOT", "/lab/state/overseer-channel")
|
|
22
|
+
Lifecycle::Overseer.new(outbox_dir: File.join(channel_root, "outbox"))
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def emit(result)
|
|
26
|
+
puts JSON.generate(result)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def raise_lifecycle_error(message)
|
|
30
|
+
raise Ace::Support::Cli::Error.new(message)
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "ace/support/cli"
|
|
4
|
+
require_relative "lifecycle_command"
|
|
5
|
+
|
|
6
|
+
module Ace
|
|
7
|
+
module Hitl
|
|
8
|
+
module CLI
|
|
9
|
+
module Commands
|
|
10
|
+
# Host-broker acknowledgement of one relayed Overseer response.
|
|
11
|
+
class OverseerAck < Ace::Support::Cli::Command
|
|
12
|
+
include Ace::Support::Cli::Base
|
|
13
|
+
include LifecycleCommand
|
|
14
|
+
|
|
15
|
+
desc "Acknowledge (remove) one relayed Overseer response"
|
|
16
|
+
|
|
17
|
+
argument :id, required: true, desc: "Overseer response message id"
|
|
18
|
+
|
|
19
|
+
option :quiet, type: :boolean, aliases: %w[-q], desc: "Suppress non-essential output"
|
|
20
|
+
|
|
21
|
+
def call(id:, **options)
|
|
22
|
+
emit(overseer.ack(id))
|
|
23
|
+
rescue Lifecycle::Error => e
|
|
24
|
+
raise_lifecycle_error(e.message)
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|