hitl-client 0.1.0__tar.gz
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.
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: hitl-client
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Zero-dependency Python SDK for HITL Broker: create/wait/consume human-in-the-loop checkpoints (approvals, OTP/short text, phone/app completion, image/QR display, live browser handoff).
|
|
5
|
+
Project-URL: Homepage, https://github.com/aqiu9/HITL-Broker
|
|
6
|
+
Project-URL: Repository, https://github.com/aqiu9/HITL-Broker
|
|
7
|
+
License: MIT
|
|
8
|
+
Keywords: approval,client,hitl,human-in-the-loop,otp,sdk
|
|
9
|
+
Requires-Python: >=3.9
|
|
10
|
+
Provides-Extra: browser
|
|
11
|
+
Requires-Dist: hitl-cdp-browser-provider; extra == 'browser'
|
|
12
|
+
Description-Content-Type: text/markdown
|
|
13
|
+
|
|
14
|
+
# hitl-client
|
|
15
|
+
|
|
16
|
+
Zero-dependency Python SDK for [HITL Broker](https://github.com/aqiu9/HITL-Broker) —
|
|
17
|
+
create, wait for, and consume human-in-the-loop checkpoints from an agent or
|
|
18
|
+
automation script: approvals (`decision`), OTP/short text (`text`),
|
|
19
|
+
phone/app confirmations (`completion`), image or QR display (`image`/`qr`),
|
|
20
|
+
and live browser handoff (`browser`).
|
|
21
|
+
|
|
22
|
+
This is the packaged form of the HITL-Broker repo's own
|
|
23
|
+
`client/hitl_client.py` — same file, published so callers don't need a full
|
|
24
|
+
repo checkout to `import` it. Talks to the Broker over plain
|
|
25
|
+
`urllib` (stdlib only, no runtime dependencies).
|
|
26
|
+
|
|
27
|
+
## Install
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
pip install hitl-client
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## Use
|
|
34
|
+
|
|
35
|
+
```python
|
|
36
|
+
import os
|
|
37
|
+
from pathlib import Path
|
|
38
|
+
from hitl_client import HitlClient
|
|
39
|
+
|
|
40
|
+
base_url = os.environ["HITL_INTERNAL_URL"] # e.g. http://192.168.1.10:8081
|
|
41
|
+
api_key = Path(os.environ["HITL_API_KEY_FILE"]).read_text(encoding="utf-8").strip()
|
|
42
|
+
client = HitlClient(base_url, api_key)
|
|
43
|
+
|
|
44
|
+
result = client.wait_for_decision(
|
|
45
|
+
"Approve this deploy?",
|
|
46
|
+
description="Applies a schema migration if approved.",
|
|
47
|
+
external_id="run-42:approve-deploy",
|
|
48
|
+
)
|
|
49
|
+
if result.response["decision"] == "approve":
|
|
50
|
+
apply_deploy()
|
|
51
|
+
else:
|
|
52
|
+
abort_deploy()
|
|
53
|
+
client.consume(result.task_id)
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
`create_*`/`wait_for_*` cover all six task kinds; see the class docstrings
|
|
57
|
+
in `hitl_client/client.py` for the full non-blocking + blocking API, and
|
|
58
|
+
the main [HITL-Broker README](https://github.com/aqiu9/HITL-Broker) for
|
|
59
|
+
task-kind semantics, the state machine, and realistic `expires_in` values
|
|
60
|
+
(especially the OTP two-step timing pattern — trigger the SMS only after a
|
|
61
|
+
human has confirmed they're ready, not before).
|
|
62
|
+
|
|
63
|
+
## Do you also need browser support?
|
|
64
|
+
|
|
65
|
+
Only if you're using the `browser` task kind (a human takes over a live
|
|
66
|
+
browser session). Everything else — `decision`/`text`/`completion`/
|
|
67
|
+
`image`/`qr` — works with just `pip install hitl-client`, nothing else.
|
|
68
|
+
|
|
69
|
+
For `browser` tasks you need a small bridge process running on **whichever
|
|
70
|
+
machine is actually hosting the browser session being handed off** — not
|
|
71
|
+
necessarily the same machine that installed `hitl-client`. Pick one based on
|
|
72
|
+
what that machine has, they're functionally identical:
|
|
73
|
+
|
|
74
|
+
```bash
|
|
75
|
+
# That machine runs Python:
|
|
76
|
+
pip install hitl-cdp-browser-provider
|
|
77
|
+
# — or, to also install it here as an extra when you already know you'll need it:
|
|
78
|
+
pip install hitl-client[browser]
|
|
79
|
+
|
|
80
|
+
# That machine runs Node instead:
|
|
81
|
+
npm install -g hitl-cdp-browser-provider
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
You only need one of the two, not both. See
|
|
85
|
+
[`hitl-cdp-browser-provider`](https://github.com/aqiu9/HITL-Broker/tree/main/packages)
|
|
86
|
+
for setup and the `--token` security flag.
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# hitl-client
|
|
2
|
+
|
|
3
|
+
Zero-dependency Python SDK for [HITL Broker](https://github.com/aqiu9/HITL-Broker) —
|
|
4
|
+
create, wait for, and consume human-in-the-loop checkpoints from an agent or
|
|
5
|
+
automation script: approvals (`decision`), OTP/short text (`text`),
|
|
6
|
+
phone/app confirmations (`completion`), image or QR display (`image`/`qr`),
|
|
7
|
+
and live browser handoff (`browser`).
|
|
8
|
+
|
|
9
|
+
This is the packaged form of the HITL-Broker repo's own
|
|
10
|
+
`client/hitl_client.py` — same file, published so callers don't need a full
|
|
11
|
+
repo checkout to `import` it. Talks to the Broker over plain
|
|
12
|
+
`urllib` (stdlib only, no runtime dependencies).
|
|
13
|
+
|
|
14
|
+
## Install
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
pip install hitl-client
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
## Use
|
|
21
|
+
|
|
22
|
+
```python
|
|
23
|
+
import os
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
from hitl_client import HitlClient
|
|
26
|
+
|
|
27
|
+
base_url = os.environ["HITL_INTERNAL_URL"] # e.g. http://192.168.1.10:8081
|
|
28
|
+
api_key = Path(os.environ["HITL_API_KEY_FILE"]).read_text(encoding="utf-8").strip()
|
|
29
|
+
client = HitlClient(base_url, api_key)
|
|
30
|
+
|
|
31
|
+
result = client.wait_for_decision(
|
|
32
|
+
"Approve this deploy?",
|
|
33
|
+
description="Applies a schema migration if approved.",
|
|
34
|
+
external_id="run-42:approve-deploy",
|
|
35
|
+
)
|
|
36
|
+
if result.response["decision"] == "approve":
|
|
37
|
+
apply_deploy()
|
|
38
|
+
else:
|
|
39
|
+
abort_deploy()
|
|
40
|
+
client.consume(result.task_id)
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
`create_*`/`wait_for_*` cover all six task kinds; see the class docstrings
|
|
44
|
+
in `hitl_client/client.py` for the full non-blocking + blocking API, and
|
|
45
|
+
the main [HITL-Broker README](https://github.com/aqiu9/HITL-Broker) for
|
|
46
|
+
task-kind semantics, the state machine, and realistic `expires_in` values
|
|
47
|
+
(especially the OTP two-step timing pattern — trigger the SMS only after a
|
|
48
|
+
human has confirmed they're ready, not before).
|
|
49
|
+
|
|
50
|
+
## Do you also need browser support?
|
|
51
|
+
|
|
52
|
+
Only if you're using the `browser` task kind (a human takes over a live
|
|
53
|
+
browser session). Everything else — `decision`/`text`/`completion`/
|
|
54
|
+
`image`/`qr` — works with just `pip install hitl-client`, nothing else.
|
|
55
|
+
|
|
56
|
+
For `browser` tasks you need a small bridge process running on **whichever
|
|
57
|
+
machine is actually hosting the browser session being handed off** — not
|
|
58
|
+
necessarily the same machine that installed `hitl-client`. Pick one based on
|
|
59
|
+
what that machine has, they're functionally identical:
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
# That machine runs Python:
|
|
63
|
+
pip install hitl-cdp-browser-provider
|
|
64
|
+
# — or, to also install it here as an extra when you already know you'll need it:
|
|
65
|
+
pip install hitl-client[browser]
|
|
66
|
+
|
|
67
|
+
# That machine runs Node instead:
|
|
68
|
+
npm install -g hitl-cdp-browser-provider
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
You only need one of the two, not both. See
|
|
72
|
+
[`hitl-cdp-browser-provider`](https://github.com/aqiu9/HITL-Broker/tree/main/packages)
|
|
73
|
+
for setup and the `--token` security flag.
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "hitl-client"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Zero-dependency Python SDK for HITL Broker: create/wait/consume human-in-the-loop checkpoints (approvals, OTP/short text, phone/app completion, image/QR display, live browser handoff)."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
keywords = ["hitl", "human-in-the-loop", "approval", "otp", "sdk", "client"]
|
|
13
|
+
|
|
14
|
+
[project.optional-dependencies]
|
|
15
|
+
browser = ["hitl-cdp-browser-provider"]
|
|
16
|
+
|
|
17
|
+
[project.urls]
|
|
18
|
+
Homepage = "https://github.com/aqiu9/HITL-Broker"
|
|
19
|
+
Repository = "https://github.com/aqiu9/HITL-Broker"
|
|
20
|
+
|
|
21
|
+
[tool.hatch.build.targets.wheel]
|
|
22
|
+
packages = ["src/hitl_client"]
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
from .client import (
|
|
2
|
+
DEFAULT_POLL_POLICY,
|
|
3
|
+
HitlClient,
|
|
4
|
+
HitlError,
|
|
5
|
+
HitlHTTPError,
|
|
6
|
+
HitlTimeoutError,
|
|
7
|
+
HitlUnansweredError,
|
|
8
|
+
PollPolicy,
|
|
9
|
+
SubmittedResponse,
|
|
10
|
+
TaskHandle,
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
__all__ = [
|
|
14
|
+
"HitlClient",
|
|
15
|
+
"HitlError",
|
|
16
|
+
"HitlHTTPError",
|
|
17
|
+
"HitlTimeoutError",
|
|
18
|
+
"HitlUnansweredError",
|
|
19
|
+
"TaskHandle",
|
|
20
|
+
"SubmittedResponse",
|
|
21
|
+
"PollPolicy",
|
|
22
|
+
"DEFAULT_POLL_POLICY",
|
|
23
|
+
]
|
|
24
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,408 @@
|
|
|
1
|
+
"""HITL Broker Python client SDK (design doc section 19, extended by the
|
|
2
|
+
Image/QR/Browser increment doc section 23).
|
|
3
|
+
|
|
4
|
+
Zero external dependencies (stdlib ``urllib`` only) so it can be dropped
|
|
5
|
+
into any CT without adding a package footprint -- consistent with section
|
|
6
|
+
25's minimal-stack philosophy. This is the packaged/published form (PyPI:
|
|
7
|
+
``hitl-client``) of the HITL-Broker repo's own ``client/hitl_client.py``;
|
|
8
|
+
either ``pip install hitl-client`` or copy that single file directly onto a
|
|
9
|
+
client CT -- both are supported and stay in sync.
|
|
10
|
+
|
|
11
|
+
Two layers, per section 19.1/19.2:
|
|
12
|
+
|
|
13
|
+
* Non-blocking primitives -- ``create_decision`` / ``create_text`` /
|
|
14
|
+
``create_completion`` / ``create_image`` / ``create_qr`` /
|
|
15
|
+
``create_browser`` / ``put_artifact`` / ``get`` / ``consume`` /
|
|
16
|
+
``resolve`` / ``cancel`` -- for a proper checkpoint/resume automation
|
|
17
|
+
that can shut down and come back.
|
|
18
|
+
* Blocking convenience wrappers -- ``wait_for_decision`` /
|
|
19
|
+
``wait_for_text`` / ``wait_for_completion`` -- for short-lived scripts
|
|
20
|
+
(typically the OTP flow, section 20) that are fine blocking in place.
|
|
21
|
+
They return a ``SubmittedResponse`` that the caller explicitly consumes
|
|
22
|
+
only after the downstream action accepts it. image/qr/browser tasks have
|
|
23
|
+
no analogous helper; the automation calls ``resolve()`` after confirming
|
|
24
|
+
the outcome by its own means.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
import json
|
|
30
|
+
import random
|
|
31
|
+
import time
|
|
32
|
+
import urllib.error
|
|
33
|
+
import urllib.request
|
|
34
|
+
from dataclasses import dataclass
|
|
35
|
+
from typing import Any
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class HitlError(RuntimeError):
|
|
39
|
+
"""Base class for all client-raised errors."""
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class HitlHTTPError(HitlError):
|
|
43
|
+
def __init__(self, status_code: int, code: str, message: str):
|
|
44
|
+
super().__init__(f"HTTP {status_code} [{code}]: {message}")
|
|
45
|
+
self.status_code = status_code
|
|
46
|
+
self.code = code
|
|
47
|
+
self.message = message
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class HitlTimeoutError(HitlError):
|
|
51
|
+
"""The client's own poll budget was exhausted while the task was still
|
|
52
|
+
``pending`` -- the task may still be answered later; it is NOT
|
|
53
|
+
automatically cancelled (see wait_for_* docstrings)."""
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class HitlUnansweredError(HitlError):
|
|
57
|
+
"""The task left ``pending`` without a human response (expired or was
|
|
58
|
+
cancelled), as opposed to being genuinely approved/rejected/filled."""
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@dataclass
|
|
62
|
+
class TaskHandle:
|
|
63
|
+
task_id: str
|
|
64
|
+
status: str
|
|
65
|
+
review_url: str | None
|
|
66
|
+
expires_at: str
|
|
67
|
+
poll_after: int
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@dataclass(frozen=True)
|
|
71
|
+
class SubmittedResponse:
|
|
72
|
+
"""A human response that remains readable until the caller confirms
|
|
73
|
+
successful downstream use with ``consume(task_id)``."""
|
|
74
|
+
|
|
75
|
+
task_id: str
|
|
76
|
+
kind: str
|
|
77
|
+
response: dict[str, Any]
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@dataclass(frozen=True)
|
|
81
|
+
class PollPolicy:
|
|
82
|
+
"""Section 19.3 tiered polling: fast during the OTP-scale first 30s,
|
|
83
|
+
settling to a slower cadence, with jitter to avoid a thundering herd
|
|
84
|
+
against the broker when many tasks are in flight concurrently."""
|
|
85
|
+
|
|
86
|
+
fast_window_seconds: float = 30.0
|
|
87
|
+
fast_interval_seconds: float = 3.0
|
|
88
|
+
medium_window_seconds: float = 120.0
|
|
89
|
+
medium_interval_seconds: float = 10.0
|
|
90
|
+
slow_interval_seconds: float = 25.0
|
|
91
|
+
jitter_seconds: float = 3.0
|
|
92
|
+
|
|
93
|
+
def interval_at(self, elapsed_seconds: float) -> float:
|
|
94
|
+
if elapsed_seconds < self.fast_window_seconds:
|
|
95
|
+
base = self.fast_interval_seconds
|
|
96
|
+
elif elapsed_seconds < self.fast_window_seconds + self.medium_window_seconds:
|
|
97
|
+
base = self.medium_interval_seconds
|
|
98
|
+
else:
|
|
99
|
+
base = self.slow_interval_seconds
|
|
100
|
+
return max(0.5, base + random.uniform(0, self.jitter_seconds))
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
DEFAULT_POLL_POLICY = PollPolicy()
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
class HitlClient:
|
|
107
|
+
def __init__(self, base_url: str, api_key: str, *, timeout: float = 10.0):
|
|
108
|
+
self._base_url = base_url.rstrip("/")
|
|
109
|
+
self._api_key = api_key
|
|
110
|
+
self._timeout = timeout
|
|
111
|
+
|
|
112
|
+
# -- low-level HTTP -----------------------------------------------
|
|
113
|
+
|
|
114
|
+
def _request(
|
|
115
|
+
self,
|
|
116
|
+
method: str,
|
|
117
|
+
path: str,
|
|
118
|
+
*,
|
|
119
|
+
json_body: dict[str, Any] | None = None,
|
|
120
|
+
idempotency_key: str | None = None,
|
|
121
|
+
) -> dict[str, Any]:
|
|
122
|
+
url = f"{self._base_url}{path}"
|
|
123
|
+
headers = {"Authorization": f"Bearer {self._api_key}"}
|
|
124
|
+
data = None
|
|
125
|
+
if json_body is not None:
|
|
126
|
+
data = json.dumps(json_body).encode("utf-8")
|
|
127
|
+
headers["Content-Type"] = "application/json"
|
|
128
|
+
if idempotency_key:
|
|
129
|
+
headers["Idempotency-Key"] = idempotency_key
|
|
130
|
+
|
|
131
|
+
request = urllib.request.Request(url, data=data, method=method, headers=headers)
|
|
132
|
+
try:
|
|
133
|
+
with urllib.request.urlopen(request, timeout=self._timeout) as response:
|
|
134
|
+
raw = response.read()
|
|
135
|
+
return json.loads(raw) if raw else {}
|
|
136
|
+
except urllib.error.HTTPError as exc:
|
|
137
|
+
raw = exc.read()
|
|
138
|
+
try:
|
|
139
|
+
body = json.loads(raw) if raw else {}
|
|
140
|
+
except json.JSONDecodeError:
|
|
141
|
+
body = {}
|
|
142
|
+
error = body.get("error", {}) if isinstance(body, dict) else {}
|
|
143
|
+
raise HitlHTTPError(
|
|
144
|
+
exc.code, error.get("code", "http_error"), error.get("message", str(exc.reason))
|
|
145
|
+
) from exc
|
|
146
|
+
except urllib.error.URLError as exc:
|
|
147
|
+
raise HitlError(f"request to {path} failed: {exc.reason}") from exc
|
|
148
|
+
|
|
149
|
+
def _request_bytes(self, method: str, path: str, *, data: bytes, content_type: str) -> dict[str, Any]:
|
|
150
|
+
url = f"{self._base_url}{path}"
|
|
151
|
+
headers = {"Authorization": f"Bearer {self._api_key}", "Content-Type": content_type}
|
|
152
|
+
request = urllib.request.Request(url, data=data, method=method, headers=headers)
|
|
153
|
+
try:
|
|
154
|
+
with urllib.request.urlopen(request, timeout=self._timeout) as response:
|
|
155
|
+
raw = response.read()
|
|
156
|
+
return json.loads(raw) if raw else {}
|
|
157
|
+
except urllib.error.HTTPError as exc:
|
|
158
|
+
raw = exc.read()
|
|
159
|
+
try:
|
|
160
|
+
body = json.loads(raw) if raw else {}
|
|
161
|
+
except json.JSONDecodeError:
|
|
162
|
+
body = {}
|
|
163
|
+
error = body.get("error", {}) if isinstance(body, dict) else {}
|
|
164
|
+
raise HitlHTTPError(
|
|
165
|
+
exc.code, error.get("code", "http_error"), error.get("message", str(exc.reason))
|
|
166
|
+
) from exc
|
|
167
|
+
except urllib.error.URLError as exc:
|
|
168
|
+
raise HitlError(f"request to {path} failed: {exc.reason}") from exc
|
|
169
|
+
|
|
170
|
+
# -- non-blocking primitives (section 19.1) -----------------------
|
|
171
|
+
|
|
172
|
+
def create_decision(
|
|
173
|
+
self,
|
|
174
|
+
title: str,
|
|
175
|
+
*,
|
|
176
|
+
description: str = "",
|
|
177
|
+
expires_in: int | None = None,
|
|
178
|
+
notify: bool = True,
|
|
179
|
+
external_id: str | None = None,
|
|
180
|
+
) -> TaskHandle:
|
|
181
|
+
return self._create(
|
|
182
|
+
"decision", title=title, description=description, input_spec=None,
|
|
183
|
+
expires_in=expires_in, notify=notify, external_id=external_id,
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
def create_text(
|
|
187
|
+
self,
|
|
188
|
+
title: str,
|
|
189
|
+
*,
|
|
190
|
+
label: str = "",
|
|
191
|
+
secret: bool = True,
|
|
192
|
+
min_length: int | None = None,
|
|
193
|
+
max_length: int | None = None,
|
|
194
|
+
pattern: str | None = None,
|
|
195
|
+
description: str = "",
|
|
196
|
+
expires_in: int | None = None,
|
|
197
|
+
notify: bool = True,
|
|
198
|
+
external_id: str | None = None,
|
|
199
|
+
) -> TaskHandle:
|
|
200
|
+
input_spec = {
|
|
201
|
+
"label": label, "secret": secret,
|
|
202
|
+
"min_length": min_length, "max_length": max_length, "pattern": pattern,
|
|
203
|
+
}
|
|
204
|
+
return self._create(
|
|
205
|
+
"text", title=title, description=description, input_spec=input_spec,
|
|
206
|
+
expires_in=expires_in, notify=notify, external_id=external_id,
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
def create_completion(
|
|
210
|
+
self,
|
|
211
|
+
title: str,
|
|
212
|
+
*,
|
|
213
|
+
description: str = "",
|
|
214
|
+
expires_in: int | None = None,
|
|
215
|
+
notify: bool = True,
|
|
216
|
+
external_id: str | None = None,
|
|
217
|
+
) -> TaskHandle:
|
|
218
|
+
return self._create(
|
|
219
|
+
"completion", title=title, description=description, input_spec=None,
|
|
220
|
+
expires_in=expires_in, notify=notify, external_id=external_id,
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
# -- image/qr/browser (increment doc section 23) -----------------------
|
|
224
|
+
|
|
225
|
+
def create_image(
|
|
226
|
+
self,
|
|
227
|
+
title: str,
|
|
228
|
+
*,
|
|
229
|
+
description: str = "",
|
|
230
|
+
expires_in: int | None = None,
|
|
231
|
+
notify: bool = True,
|
|
232
|
+
external_id: str | None = None,
|
|
233
|
+
) -> TaskHandle:
|
|
234
|
+
"""Create the task, then call put_artifact() with the image bytes
|
|
235
|
+
(increment doc section 4.2's two-step flow: create, then upload)."""
|
|
236
|
+
return self._create(
|
|
237
|
+
"image", title=title, description=description, input_spec=None,
|
|
238
|
+
expires_in=expires_in, notify=notify, external_id=external_id,
|
|
239
|
+
)
|
|
240
|
+
|
|
241
|
+
def create_qr(
|
|
242
|
+
self,
|
|
243
|
+
title: str,
|
|
244
|
+
*,
|
|
245
|
+
description: str = "",
|
|
246
|
+
expires_in: int | None = None,
|
|
247
|
+
notify: bool = True,
|
|
248
|
+
external_id: str | None = None,
|
|
249
|
+
) -> TaskHandle:
|
|
250
|
+
"""Create the task, then call put_artifact() with the QR image.
|
|
251
|
+
Call put_artifact() again (same task_id) whenever the QR refreshes
|
|
252
|
+
-- section 8: no new task, just a new artifact version."""
|
|
253
|
+
return self._create(
|
|
254
|
+
"qr", title=title, description=description, input_spec=None,
|
|
255
|
+
expires_in=expires_in, notify=notify, external_id=external_id,
|
|
256
|
+
)
|
|
257
|
+
|
|
258
|
+
def create_browser(
|
|
259
|
+
self,
|
|
260
|
+
title: str,
|
|
261
|
+
*,
|
|
262
|
+
internal_url: str,
|
|
263
|
+
description: str = "",
|
|
264
|
+
expires_in: int | None = None,
|
|
265
|
+
notify: bool = True,
|
|
266
|
+
external_id: str | None = None,
|
|
267
|
+
) -> TaskHandle:
|
|
268
|
+
"""internal_url must match the calling source's configured
|
|
269
|
+
allowed_browser_host/port (increment doc section 19) or the
|
|
270
|
+
broker rejects the create with a 422."""
|
|
271
|
+
return self._create(
|
|
272
|
+
"browser", title=title, description=description, input_spec=None,
|
|
273
|
+
expires_in=expires_in, notify=notify, external_id=external_id, internal_url=internal_url,
|
|
274
|
+
)
|
|
275
|
+
|
|
276
|
+
def put_artifact(self, task_id: str, data: bytes, *, content_type: str) -> dict[str, Any]:
|
|
277
|
+
"""Uploads or replaces a task's image artifact. Replacing
|
|
278
|
+
overwrites in place and bumps `version` -- no history is kept."""
|
|
279
|
+
return self._request_bytes("PUT", f"/v1/tasks/{task_id}/artifact", data=data, content_type=content_type)
|
|
280
|
+
|
|
281
|
+
def resolve(self, task_id: str, *, reason: str = "completed") -> dict[str, Any]:
|
|
282
|
+
"""Ends an image/qr/browser task directly (pending -> consumed)
|
|
283
|
+
once the automation has independently confirmed the outcome --
|
|
284
|
+
e.g. the QR login succeeded (section 20). Not valid for
|
|
285
|
+
decision/text/completion tasks; use consume() for those after the
|
|
286
|
+
human has submitted a response."""
|
|
287
|
+
return self._request("POST", f"/v1/tasks/{task_id}/resolve", json_body={"reason": reason})
|
|
288
|
+
|
|
289
|
+
def _create(
|
|
290
|
+
self, kind: str, *, title: str, description: str, input_spec: dict[str, Any] | None,
|
|
291
|
+
expires_in: int | None, notify: bool, external_id: str | None, internal_url: str | None = None,
|
|
292
|
+
) -> TaskHandle:
|
|
293
|
+
body: dict[str, Any] = {"kind": kind, "title": title, "description": description, "notify": notify}
|
|
294
|
+
if input_spec is not None:
|
|
295
|
+
body["input"] = {k: v for k, v in input_spec.items() if v is not None}
|
|
296
|
+
if internal_url is not None:
|
|
297
|
+
body["internal_url"] = internal_url
|
|
298
|
+
if expires_in is not None:
|
|
299
|
+
body["expires_in"] = expires_in
|
|
300
|
+
data = self._request("POST", "/v1/tasks", json_body=body, idempotency_key=external_id)
|
|
301
|
+
return TaskHandle(
|
|
302
|
+
task_id=data["task_id"],
|
|
303
|
+
status=data["status"],
|
|
304
|
+
review_url=data.get("review_url"),
|
|
305
|
+
expires_at=data["expires_at"],
|
|
306
|
+
poll_after=data.get("poll_after", 15),
|
|
307
|
+
)
|
|
308
|
+
|
|
309
|
+
def get(self, task_id: str) -> dict[str, Any]:
|
|
310
|
+
return self._request("GET", f"/v1/tasks/{task_id}")
|
|
311
|
+
|
|
312
|
+
def consume(self, task_id: str) -> dict[str, Any]:
|
|
313
|
+
return self._request("POST", f"/v1/tasks/{task_id}/consume")
|
|
314
|
+
|
|
315
|
+
def cancel(self, task_id: str) -> dict[str, Any]:
|
|
316
|
+
return self._request("POST", f"/v1/tasks/{task_id}/cancel")
|
|
317
|
+
|
|
318
|
+
# -- blocking convenience helpers (section 19.2/20) -----------------
|
|
319
|
+
|
|
320
|
+
def _poll_until_left_pending(self, task_id: str, *, timeout: float, policy: PollPolicy) -> dict[str, Any]:
|
|
321
|
+
start = time.monotonic()
|
|
322
|
+
while True:
|
|
323
|
+
status = self.get(task_id)
|
|
324
|
+
if status.get("status") != "pending":
|
|
325
|
+
return status
|
|
326
|
+
elapsed = time.monotonic() - start
|
|
327
|
+
if elapsed >= timeout:
|
|
328
|
+
raise HitlTimeoutError(f"task {task_id} still pending after {timeout:.0f}s")
|
|
329
|
+
time.sleep(min(policy.interval_at(elapsed), max(0.0, timeout - elapsed)))
|
|
330
|
+
|
|
331
|
+
def wait_for_decision(
|
|
332
|
+
self,
|
|
333
|
+
title: str,
|
|
334
|
+
*,
|
|
335
|
+
description: str = "",
|
|
336
|
+
expires_in: int = 86400,
|
|
337
|
+
timeout: float | None = None,
|
|
338
|
+
poll_policy: PollPolicy = DEFAULT_POLL_POLICY,
|
|
339
|
+
external_id: str | None = None,
|
|
340
|
+
) -> SubmittedResponse:
|
|
341
|
+
"""Block until a decision is submitted, without consuming it.
|
|
342
|
+
|
|
343
|
+
Inspect ``result.response['decision']`` and call
|
|
344
|
+
``consume(result.task_id)`` only after applying that decision.
|
|
345
|
+
"""
|
|
346
|
+
handle = self.create_decision(title, description=description, expires_in=expires_in, external_id=external_id)
|
|
347
|
+
budget = timeout if timeout is not None else float(expires_in)
|
|
348
|
+
status = self._poll_until_left_pending(handle.task_id, timeout=budget, policy=poll_policy)
|
|
349
|
+
if status.get("status") != "submitted":
|
|
350
|
+
raise HitlUnansweredError(f"task {handle.task_id} ended with status={status.get('status')}")
|
|
351
|
+
return SubmittedResponse(
|
|
352
|
+
task_id=handle.task_id,
|
|
353
|
+
kind="decision",
|
|
354
|
+
response=status.get("response") or {},
|
|
355
|
+
)
|
|
356
|
+
|
|
357
|
+
def wait_for_text(
|
|
358
|
+
self,
|
|
359
|
+
title: str,
|
|
360
|
+
*,
|
|
361
|
+
label: str = "",
|
|
362
|
+
secret: bool = True,
|
|
363
|
+
min_length: int | None = None,
|
|
364
|
+
max_length: int | None = None,
|
|
365
|
+
pattern: str | None = None,
|
|
366
|
+
description: str = "",
|
|
367
|
+
expires_in: int = 300,
|
|
368
|
+
timeout: float | None = None,
|
|
369
|
+
poll_policy: PollPolicy = DEFAULT_POLL_POLICY,
|
|
370
|
+
external_id: str | None = None,
|
|
371
|
+
) -> SubmittedResponse:
|
|
372
|
+
"""Blocks until the human submits text (typically an OTP, section
|
|
373
|
+
20) or the task ends unanswered."""
|
|
374
|
+
handle = self.create_text(
|
|
375
|
+
title, label=label, secret=secret, min_length=min_length, max_length=max_length,
|
|
376
|
+
pattern=pattern, description=description, expires_in=expires_in, external_id=external_id,
|
|
377
|
+
)
|
|
378
|
+
budget = timeout if timeout is not None else float(expires_in)
|
|
379
|
+
status = self._poll_until_left_pending(handle.task_id, timeout=budget, policy=poll_policy)
|
|
380
|
+
if status.get("status") != "submitted":
|
|
381
|
+
raise HitlUnansweredError(f"task {handle.task_id} ended with status={status.get('status')}")
|
|
382
|
+
return SubmittedResponse(
|
|
383
|
+
task_id=handle.task_id,
|
|
384
|
+
kind="text",
|
|
385
|
+
response=status.get("response") or {},
|
|
386
|
+
)
|
|
387
|
+
|
|
388
|
+
def wait_for_completion(
|
|
389
|
+
self,
|
|
390
|
+
title: str,
|
|
391
|
+
*,
|
|
392
|
+
description: str = "",
|
|
393
|
+
expires_in: int = 86400,
|
|
394
|
+
timeout: float | None = None,
|
|
395
|
+
poll_policy: PollPolicy = DEFAULT_POLL_POLICY,
|
|
396
|
+
external_id: str | None = None,
|
|
397
|
+
) -> SubmittedResponse:
|
|
398
|
+
"""Block until completion is submitted, without consuming it."""
|
|
399
|
+
handle = self.create_completion(title, description=description, expires_in=expires_in, external_id=external_id)
|
|
400
|
+
budget = timeout if timeout is not None else float(expires_in)
|
|
401
|
+
status = self._poll_until_left_pending(handle.task_id, timeout=budget, policy=poll_policy)
|
|
402
|
+
if status.get("status") != "submitted":
|
|
403
|
+
raise HitlUnansweredError(f"task {handle.task_id} ended with status={status.get('status')}")
|
|
404
|
+
return SubmittedResponse(
|
|
405
|
+
task_id=handle.task_id,
|
|
406
|
+
kind="completion",
|
|
407
|
+
response=status.get("response") or {},
|
|
408
|
+
)
|