velophex 0.2.0__py3-none-any.whl
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.
- velophex/__init__.py +38 -0
- velophex/_json.py +297 -0
- velophex/agents/__init__.py +130 -0
- velophex/agents/_errors.py +75 -0
- velophex/agents/_loop.py +436 -0
- velophex/agents/_tools.py +168 -0
- velophex/agents/_tracing.py +87 -0
- velophex/agents/testing.py +249 -0
- velophex/cli/__init__.py +5 -0
- velophex/cli/main.py +548 -0
- velophex/cli/publish.py +152 -0
- velophex/cli/sync.py +90 -0
- velophex/cli/templates.py +126 -0
- velophex/contracts/__init__.py +103 -0
- velophex/contracts/_model.py +26 -0
- velophex/contracts/agents.py +110 -0
- velophex/contracts/diagnostics.py +157 -0
- velophex/contracts/executor.py +1921 -0
- velophex/contracts/package.py +391 -0
- velophex/contracts/project.py +1008 -0
- velophex/contracts/runtimes.py +226 -0
- velophex/contracts/signin.py +53 -0
- velophex/contracts/validation.py +158 -0
- velophex/entrypoints.py +329 -0
- velophex/errors.py +44 -0
- velophex/packaging.py +465 -0
- velophex/py.typed +0 -0
- velophex/runtime/__init__.py +471 -0
- velophex/runtime/_session.py +192 -0
- velophex/runtime/client.py +957 -0
- velophex/runtime/models.py +326 -0
- velophex/signing.py +425 -0
- velophex/worker/__init__.py +27 -0
- velophex/worker/__main__.py +118 -0
- velophex/worker/framing.py +248 -0
- velophex/worker/loader.py +105 -0
- velophex/worker/outbox.py +147 -0
- velophex/worker/session.py +519 -0
- velophex-0.2.0.dist-info/METADATA +313 -0
- velophex-0.2.0.dist-info/RECORD +43 -0
- velophex-0.2.0.dist-info/WHEEL +4 -0
- velophex-0.2.0.dist-info/entry_points.txt +2 -0
- velophex-0.2.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,957 @@
|
|
|
1
|
+
"""HTTP client for the Orchestrator Runtime API (``/api/v1/runtime/*``).
|
|
2
|
+
|
|
3
|
+
The client is built from the ``start`` context and nothing else. In particular the execution
|
|
4
|
+
token (``vpx_rt_…``) is **never** read from the environment and never written to one: it
|
|
5
|
+
authorises this attempt only, it is revoked the moment the attempt ends, and a token that
|
|
6
|
+
reaches a child process or a log line has left the blast radius the platform designed for it
|
|
7
|
+
(``docs/runner-sdk-protocol.md`` section 3.3).
|
|
8
|
+
|
|
9
|
+
Every call is already scoped to one attempt by the token, so no method names a tenant, a
|
|
10
|
+
workspace or a job.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import time
|
|
16
|
+
from collections.abc import Iterator
|
|
17
|
+
from datetime import UTC, datetime
|
|
18
|
+
from typing import IO, Any, Final, NoReturn
|
|
19
|
+
from urllib.parse import quote
|
|
20
|
+
from uuid import UUID
|
|
21
|
+
|
|
22
|
+
import httpx
|
|
23
|
+
|
|
24
|
+
from velophex import _json
|
|
25
|
+
from velophex.contracts.executor import FAULT_ENVELOPE_VERSION, FaultInfo
|
|
26
|
+
from velophex.contracts.runtimes import FaultKinds
|
|
27
|
+
from velophex.errors import VelophexError
|
|
28
|
+
from velophex.runtime.models import (
|
|
29
|
+
ActionAppSchema,
|
|
30
|
+
ActionTaskResult,
|
|
31
|
+
AddQueueItemRequest,
|
|
32
|
+
CancellationState,
|
|
33
|
+
QueueItem,
|
|
34
|
+
ResolvedSecret,
|
|
35
|
+
RuntimeActionTask,
|
|
36
|
+
RuntimeAsset,
|
|
37
|
+
RuntimeContext,
|
|
38
|
+
RuntimeCredential,
|
|
39
|
+
RuntimeJob,
|
|
40
|
+
RuntimeQueue,
|
|
41
|
+
StartedJob,
|
|
42
|
+
StorageFile,
|
|
43
|
+
StorageFileList,
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
__all__ = [
|
|
47
|
+
"ACTION_TASKS_CAPABILITY",
|
|
48
|
+
"RUNTIME_API_PATH",
|
|
49
|
+
"STORAGE_BUCKETS_CAPABILITY",
|
|
50
|
+
"ActionAppNotFoundError",
|
|
51
|
+
"ActionHubDisabledError",
|
|
52
|
+
"ActionHubError",
|
|
53
|
+
"ActionHubRuntimeUnsupportedError",
|
|
54
|
+
"ActionTaskCorrelationConflictError",
|
|
55
|
+
"ActionTaskInputInvalidError",
|
|
56
|
+
"ActionTaskNotFoundError",
|
|
57
|
+
"ActionTaskNotOwnedError",
|
|
58
|
+
"ActionTaskTimeoutError",
|
|
59
|
+
"AgentsNotEnabledError",
|
|
60
|
+
"AiBudgetExhaustedError",
|
|
61
|
+
"AiError",
|
|
62
|
+
"AiPolicyDeniedError",
|
|
63
|
+
"AiProviderUnavailableError",
|
|
64
|
+
"AiRequestInvalidError",
|
|
65
|
+
"RuntimeApiError",
|
|
66
|
+
"RuntimeClient",
|
|
67
|
+
"StorageBucketsUnsupportedError",
|
|
68
|
+
"runtime_base_url",
|
|
69
|
+
"runtime_error",
|
|
70
|
+
]
|
|
71
|
+
|
|
72
|
+
RUNTIME_API_PATH: Final = "/api/v1/runtime"
|
|
73
|
+
|
|
74
|
+
_DEFAULT_TIMEOUT: Final = httpx.Timeout(30.0, connect=10.0)
|
|
75
|
+
|
|
76
|
+
#: The runtime capability of the action-task routes (``GET /runtime/capabilities``).
|
|
77
|
+
ACTION_TASKS_CAPABILITY: Final = "action-tasks/1"
|
|
78
|
+
|
|
79
|
+
#: The runtime capability of the storage bucket routes (``RuntimeCapabilities.StorageBuckets``).
|
|
80
|
+
STORAGE_BUCKETS_CAPABILITY: Final = "storage-buckets/1"
|
|
81
|
+
|
|
82
|
+
#: Transport timeout of one upload or download: a file may be 100 MiB.
|
|
83
|
+
_FILE_TIMEOUT: Final = httpx.Timeout(300.0, connect=10.0)
|
|
84
|
+
|
|
85
|
+
#: The server holds one wait at most this long (``ActionTaskLimits.MaxWaitSeconds``).
|
|
86
|
+
_MAX_WAIT_SECONDS: Final = 25
|
|
87
|
+
|
|
88
|
+
#: Transport timeout of one wait: the server cap plus ten seconds (plan X3).
|
|
89
|
+
_WAIT_TIMEOUT: Final = httpx.Timeout(_MAX_WAIT_SECONDS + 10.0, connect=10.0)
|
|
90
|
+
|
|
91
|
+
#: Transient failures of one wait retried before giving up (1, 2, 4, 8, 16 s apart).
|
|
92
|
+
_WAIT_RETRIES: Final = 5
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
class RuntimeApiError(VelophexError):
|
|
96
|
+
"""The Runtime API refused a call.
|
|
97
|
+
|
|
98
|
+
:attr:`status_code` and :attr:`problem` carry the RFC 9457 problem details the Orchestrator
|
|
99
|
+
returns, so an automation can tell ``no_capable_runner`` from a transport failure. The error
|
|
100
|
+
envelope (code, cause, fix, request id, retry scope, docs URL) is read from the same problem.
|
|
101
|
+
"""
|
|
102
|
+
|
|
103
|
+
def __init__(self, status_code: int, problem: dict[str, Any] | None, message: str) -> None:
|
|
104
|
+
super().__init__(message)
|
|
105
|
+
self.status_code = status_code
|
|
106
|
+
self.problem = problem or {}
|
|
107
|
+
|
|
108
|
+
@property
|
|
109
|
+
def code(self) -> str | None:
|
|
110
|
+
"""The Orchestrator's machine-readable error code, when it sent one."""
|
|
111
|
+
return self._text("code")
|
|
112
|
+
|
|
113
|
+
@property
|
|
114
|
+
def cause(self) -> str | None:
|
|
115
|
+
"""Why it happened, in one line."""
|
|
116
|
+
return self._text("cause")
|
|
117
|
+
|
|
118
|
+
@property
|
|
119
|
+
def fix(self) -> str | None:
|
|
120
|
+
"""What to do about it."""
|
|
121
|
+
return self._text("fix")
|
|
122
|
+
|
|
123
|
+
@property
|
|
124
|
+
def request_id(self) -> str | None:
|
|
125
|
+
"""The Orchestrator's id of the refused request."""
|
|
126
|
+
return self._text("requestId") or self._text("traceId")
|
|
127
|
+
|
|
128
|
+
@property
|
|
129
|
+
def retry_scope(self) -> str | None:
|
|
130
|
+
"""What a retry would repeat: ``none``, ``model-call`` or ``run``."""
|
|
131
|
+
return self._text("retryScope")
|
|
132
|
+
|
|
133
|
+
@property
|
|
134
|
+
def docs_url(self) -> str | None:
|
|
135
|
+
"""The reference page of :attr:`code`."""
|
|
136
|
+
return self._text("docsUrl")
|
|
137
|
+
|
|
138
|
+
@property
|
|
139
|
+
def retryable(self) -> bool:
|
|
140
|
+
"""Whether sending the same call again could succeed."""
|
|
141
|
+
value = self.problem.get("retryable")
|
|
142
|
+
return value if isinstance(value, bool) else self.status_code == 0 or self.status_code >= 500
|
|
143
|
+
|
|
144
|
+
def to_fault(self) -> FaultInfo | None:
|
|
145
|
+
"""A refusal with a code is reported under that code; a bare transport failure is not."""
|
|
146
|
+
if self.code is None:
|
|
147
|
+
return None
|
|
148
|
+
|
|
149
|
+
infrastructure = self.status_code == 0 or self.status_code >= 500
|
|
150
|
+
|
|
151
|
+
return FaultInfo(
|
|
152
|
+
kind=FaultKinds.INFRASTRUCTURE if infrastructure else FaultKinds.APPLICATION,
|
|
153
|
+
code=self.code,
|
|
154
|
+
message=str(self),
|
|
155
|
+
retryable=self.retryable,
|
|
156
|
+
version=FAULT_ENVELOPE_VERSION,
|
|
157
|
+
cause=self.cause or self._text("detail"),
|
|
158
|
+
fix=self.fix,
|
|
159
|
+
retry_scope=self.retry_scope,
|
|
160
|
+
request_id=self.request_id,
|
|
161
|
+
docs_url=self.docs_url,
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
def _text(self, name: str) -> str | None:
|
|
165
|
+
value = self.problem.get(name)
|
|
166
|
+
return value if isinstance(value, str) else None
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
class ActionHubError(RuntimeApiError):
|
|
170
|
+
"""An Action Hub refusal, named by its catalogue code (``docs/action-hub.md``).
|
|
171
|
+
|
|
172
|
+
:attr:`fix` is what to do about it, and :attr:`retryable` whether the same call could succeed.
|
|
173
|
+
"""
|
|
174
|
+
|
|
175
|
+
@property
|
|
176
|
+
def retryable(self) -> bool:
|
|
177
|
+
"""Whether sending the same call again could succeed."""
|
|
178
|
+
return self.problem.get("retryable") is True
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
class AiError(RuntimeApiError):
|
|
182
|
+
"""A refusal of the LLM gateway, named by its ``ai_*`` code."""
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
class AiBudgetExhaustedError(AiError):
|
|
186
|
+
"""``ai_budget_exhausted``: the workspace's AI budget is spent."""
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
class AiPolicyDeniedError(AiError):
|
|
190
|
+
"""``ai_policy_denied``: the workspace's AI policy refused the call."""
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
class AiProviderUnavailableError(AiError):
|
|
194
|
+
"""``ai_provider_unavailable``: the provider failed after the gateway's retries."""
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
class AiRequestInvalidError(AiError):
|
|
198
|
+
"""``ai_request_invalid``: the call is invalid, e.g. a model that is not a binding."""
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
class AgentsNotEnabledError(AiError):
|
|
202
|
+
"""``agents_not_enabled`` or ``agentic_feature_off``: an administrator turns Agents on."""
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
class ActionHubDisabledError(ActionHubError):
|
|
206
|
+
"""``action_hub_disabled``: the tenant has not enabled Action Hub."""
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
class ActionAppNotFoundError(ActionHubError):
|
|
210
|
+
"""``action_app_not_found`` or ``action_app_version_not_published``."""
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
class ActionTaskInputInvalidError(ActionHubError):
|
|
214
|
+
"""``action_task_input_invalid``: :attr:`errors` pins each problem by JSON Pointer."""
|
|
215
|
+
|
|
216
|
+
@property
|
|
217
|
+
def errors(self) -> list[dict[str, Any]]:
|
|
218
|
+
"""``[{code, path, message}]``, one per problem."""
|
|
219
|
+
value = self.problem.get("errors")
|
|
220
|
+
return value if isinstance(value, list) else []
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
class ActionTaskCorrelationConflictError(ActionHubError):
|
|
224
|
+
"""``action_task_correlation_conflict``: the key was used with a different payload."""
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
class ActionTaskNotFoundError(ActionHubError):
|
|
228
|
+
"""``action_task_not_found``."""
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
class ActionTaskNotOwnedError(ActionHubError):
|
|
232
|
+
"""``action_task_not_owned_by_job``: a job reads only the tasks it created."""
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
class ActionHubRuntimeUnsupportedError(ActionHubError):
|
|
236
|
+
"""``action_hub_runtime_unsupported``: the Orchestrator predates Action Hub automation."""
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
class ActionTaskTimeoutError(ActionHubError):
|
|
240
|
+
"""The wait ran out with ``raise_on_timeout=True``. The task stays open."""
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
class StorageBucketsUnsupportedError(RuntimeApiError):
|
|
244
|
+
"""The Orchestrator predates storage buckets (no ``storage-buckets/1`` capability)."""
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
_AI_ERRORS: Final[dict[str, type[AiError]]] = {
|
|
248
|
+
"ai_budget_exhausted": AiBudgetExhaustedError,
|
|
249
|
+
"ai_policy_denied": AiPolicyDeniedError,
|
|
250
|
+
"ai_provider_unavailable": AiProviderUnavailableError,
|
|
251
|
+
"ai_request_invalid": AiRequestInvalidError,
|
|
252
|
+
"agents_not_enabled": AgentsNotEnabledError,
|
|
253
|
+
"agentic_feature_off": AgentsNotEnabledError,
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
_ACTION_HUB_ERRORS: Final[dict[str, type[ActionHubError]]] = {
|
|
257
|
+
"action_hub_disabled": ActionHubDisabledError,
|
|
258
|
+
"action_app_not_found": ActionAppNotFoundError,
|
|
259
|
+
"action_app_version_not_published": ActionAppNotFoundError,
|
|
260
|
+
"action_task_input_invalid": ActionTaskInputInvalidError,
|
|
261
|
+
"action_task_correlation_conflict": ActionTaskCorrelationConflictError,
|
|
262
|
+
"action_task_not_found": ActionTaskNotFoundError,
|
|
263
|
+
"action_task_not_owned_by_job": ActionTaskNotOwnedError,
|
|
264
|
+
"action_hub_runtime_unsupported": ActionHubRuntimeUnsupportedError,
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def runtime_base_url(orchestrator_url: str) -> str:
|
|
269
|
+
"""Derive the Runtime API base from the URL the ``start`` context carries.
|
|
270
|
+
|
|
271
|
+
The Runner may pass the site root, the API base or the runtime base; all three appear in
|
|
272
|
+
``docs/runner-sdk-protocol.md`` section 2 discovery. Normalising here keeps every caller
|
|
273
|
+
from guessing.
|
|
274
|
+
"""
|
|
275
|
+
trimmed = orchestrator_url.rstrip("/")
|
|
276
|
+
|
|
277
|
+
if trimmed.endswith(RUNTIME_API_PATH):
|
|
278
|
+
return trimmed
|
|
279
|
+
|
|
280
|
+
if trimmed.endswith("/api/v1"):
|
|
281
|
+
return trimmed + "/runtime"
|
|
282
|
+
|
|
283
|
+
return trimmed + RUNTIME_API_PATH
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
class RuntimeClient:
|
|
287
|
+
"""Synchronous client for the Runtime API.
|
|
288
|
+
|
|
289
|
+
A handler declared ``async def`` should call it through :func:`asyncio.to_thread`: the
|
|
290
|
+
automation owns the process, so a blocking call costs nothing but a thread, and one HTTP
|
|
291
|
+
surface is easier to keep in step with the protocol than two.
|
|
292
|
+
"""
|
|
293
|
+
|
|
294
|
+
def __init__(
|
|
295
|
+
self,
|
|
296
|
+
base_url: str,
|
|
297
|
+
token: str,
|
|
298
|
+
*,
|
|
299
|
+
timeout: httpx.Timeout | float = _DEFAULT_TIMEOUT,
|
|
300
|
+
transport: httpx.BaseTransport | None = None,
|
|
301
|
+
trace_parent: str | None = None,
|
|
302
|
+
) -> None:
|
|
303
|
+
headers = {"Authorization": f"Bearer {token}", "Accept": "application/json"}
|
|
304
|
+
|
|
305
|
+
if trace_parent:
|
|
306
|
+
headers["traceparent"] = trace_parent
|
|
307
|
+
|
|
308
|
+
self._capabilities: frozenset[str] | None = None
|
|
309
|
+
self._client = httpx.Client(
|
|
310
|
+
base_url=runtime_base_url(base_url),
|
|
311
|
+
headers=headers,
|
|
312
|
+
timeout=timeout,
|
|
313
|
+
transport=transport,
|
|
314
|
+
)
|
|
315
|
+
|
|
316
|
+
def __enter__(self) -> RuntimeClient:
|
|
317
|
+
return self
|
|
318
|
+
|
|
319
|
+
def __exit__(self, *exception: object) -> None:
|
|
320
|
+
self.close()
|
|
321
|
+
|
|
322
|
+
def close(self) -> None:
|
|
323
|
+
"""Release the connection pool."""
|
|
324
|
+
self._client.close()
|
|
325
|
+
|
|
326
|
+
# ---------------------------------------------------------------- context
|
|
327
|
+
|
|
328
|
+
def context(self) -> RuntimeContext:
|
|
329
|
+
"""Job, attempt, workspace, automation, entry point, inputs and deadline."""
|
|
330
|
+
return RuntimeContext.model_validate(self._request("GET", "/context"))
|
|
331
|
+
|
|
332
|
+
def cancellation(self) -> CancellationState:
|
|
333
|
+
"""Whether a stop was requested for this attempt, and why."""
|
|
334
|
+
return CancellationState.model_validate(self._request("GET", "/cancellation"))
|
|
335
|
+
|
|
336
|
+
# ---------------------------------------------------------------- logs
|
|
337
|
+
|
|
338
|
+
def write_logs(self, entries: list[dict[str, Any]]) -> dict[str, Any]:
|
|
339
|
+
"""Send a batch of log entries (at most 500, de-duplicated on sequence)."""
|
|
340
|
+
return self._request("POST", "/logs", body={"entries": entries}) or {}
|
|
341
|
+
|
|
342
|
+
# ---------------------------------------------------------------- assets
|
|
343
|
+
|
|
344
|
+
def asset(self, name: str) -> RuntimeAsset:
|
|
345
|
+
"""Read an asset of the job's workspace, including its decrypted secret."""
|
|
346
|
+
return RuntimeAsset.model_validate(self._request("GET", f"/assets/{_segment(name)}"))
|
|
347
|
+
|
|
348
|
+
def set_asset(
|
|
349
|
+
self,
|
|
350
|
+
name: str,
|
|
351
|
+
*,
|
|
352
|
+
text: str | None = None,
|
|
353
|
+
integer: int | None = None,
|
|
354
|
+
boolean: bool | None = None,
|
|
355
|
+
secret: str | None = None,
|
|
356
|
+
user_name: str | None = None,
|
|
357
|
+
password: str | None = None,
|
|
358
|
+
) -> None:
|
|
359
|
+
"""Set the value of an asset the job's workspace owns."""
|
|
360
|
+
body = {
|
|
361
|
+
"text": text,
|
|
362
|
+
"integer": integer,
|
|
363
|
+
"boolean": boolean,
|
|
364
|
+
"secret": secret,
|
|
365
|
+
"userName": user_name,
|
|
366
|
+
"password": password,
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
self._request("PUT", f"/assets/{_segment(name)}", body={k: v for k, v in body.items() if v is not None})
|
|
370
|
+
|
|
371
|
+
def resolve_secret(self, name: str) -> ResolvedSecret:
|
|
372
|
+
"""Resolve a secret asset, or ``"<credential>.password"`` for a credential's password."""
|
|
373
|
+
return ResolvedSecret.model_validate(self._request("POST", "/secrets/resolve", body={"name": name}))
|
|
374
|
+
|
|
375
|
+
def credential(self, name: str) -> RuntimeCredential:
|
|
376
|
+
"""The account name of a credential asset. The password comes from :meth:`resolve_secret`."""
|
|
377
|
+
return RuntimeCredential.model_validate(self._request("GET", f"/credentials/{_segment(name)}"))
|
|
378
|
+
|
|
379
|
+
# ---------------------------------------------------------------- queues
|
|
380
|
+
|
|
381
|
+
def queue(self, name: str) -> RuntimeQueue:
|
|
382
|
+
"""Queue metadata by name: retry budget, unique-reference flag and SLA."""
|
|
383
|
+
return RuntimeQueue.model_validate(self._request("GET", f"/queues/{_segment(name)}"))
|
|
384
|
+
|
|
385
|
+
def add_queue_item(self, queue: str, item: AddQueueItemRequest) -> QueueItem:
|
|
386
|
+
"""Add an item to a queue of the job's workspace."""
|
|
387
|
+
body = item.model_dump(by_alias=True, exclude_none=True, mode="json")
|
|
388
|
+
return QueueItem.model_validate(self._request("POST", f"/queues/{_segment(queue)}/items", body=body))
|
|
389
|
+
|
|
390
|
+
def claim_queue_item(self, queue: str) -> QueueItem | None:
|
|
391
|
+
"""Claim the next item, or ``None`` when the queue has nothing due."""
|
|
392
|
+
payload = self._request("POST", f"/queues/{_segment(queue)}/items/claim")
|
|
393
|
+
return QueueItem.model_validate(payload) if payload is not None else None
|
|
394
|
+
|
|
395
|
+
def complete_queue_item(self, item_id: UUID | str, output: Any | None = None) -> QueueItem:
|
|
396
|
+
"""Mark an item successful."""
|
|
397
|
+
return QueueItem.model_validate(
|
|
398
|
+
self._request("POST", f"/queue-items/{item_id}/complete", body={"output": output})
|
|
399
|
+
)
|
|
400
|
+
|
|
401
|
+
def fail_queue_item(
|
|
402
|
+
self,
|
|
403
|
+
item_id: UUID | str,
|
|
404
|
+
*,
|
|
405
|
+
kind: str,
|
|
406
|
+
reason: str | None = None,
|
|
407
|
+
output: Any | None = None,
|
|
408
|
+
) -> QueueItem:
|
|
409
|
+
"""Mark an item failed. ``kind`` is ``Business`` (final) or ``Application`` (retried)."""
|
|
410
|
+
return QueueItem.model_validate(
|
|
411
|
+
self._request(
|
|
412
|
+
"POST",
|
|
413
|
+
f"/queue-items/{item_id}/fail",
|
|
414
|
+
body={"kind": kind, "reason": reason, "output": output},
|
|
415
|
+
)
|
|
416
|
+
)
|
|
417
|
+
|
|
418
|
+
def extend_queue_item_lease(self, item_id: UUID | str) -> QueueItem:
|
|
419
|
+
"""Extend the lease of an item this attempt holds."""
|
|
420
|
+
return QueueItem.model_validate(self._request("POST", f"/queue-items/{item_id}/lease"))
|
|
421
|
+
|
|
422
|
+
def postpone_queue_item(
|
|
423
|
+
self,
|
|
424
|
+
item_id: UUID | str,
|
|
425
|
+
defer_until: datetime,
|
|
426
|
+
deadline: datetime | None = None,
|
|
427
|
+
) -> QueueItem:
|
|
428
|
+
"""Give an item back to the queue until *defer_until*, without spending a retry."""
|
|
429
|
+
body = {"deferUntil": defer_until.isoformat(), "deadline": deadline.isoformat() if deadline else None}
|
|
430
|
+
return QueueItem.model_validate(self._request("POST", f"/queue-items/{item_id}/postpone", body=body))
|
|
431
|
+
|
|
432
|
+
def report_queue_item_progress(self, item_id: UUID | str, progress: str | None) -> QueueItem:
|
|
433
|
+
"""Record progress text on an item this attempt holds."""
|
|
434
|
+
return QueueItem.model_validate(
|
|
435
|
+
self._request("PUT", f"/queue-items/{item_id}/progress", body={"progress": progress})
|
|
436
|
+
)
|
|
437
|
+
|
|
438
|
+
# ---------------------------------------------------------------- child jobs
|
|
439
|
+
|
|
440
|
+
def start_job(
|
|
441
|
+
self,
|
|
442
|
+
*,
|
|
443
|
+
automation_id: UUID | str | None = None,
|
|
444
|
+
automation_name: str | None = None,
|
|
445
|
+
inputs: Any | None = None,
|
|
446
|
+
priority: int | None = None,
|
|
447
|
+
) -> StartedJob:
|
|
448
|
+
"""Start a child automation in the parent's own workspace.
|
|
449
|
+
|
|
450
|
+
The chain depth is bounded by the server (``Jobs:MaxChainDepth``); inputs are
|
|
451
|
+
validated against the child's entry point schema, so a bad call is ``422`` rather
|
|
452
|
+
than a job that fails on the robot.
|
|
453
|
+
"""
|
|
454
|
+
body = {
|
|
455
|
+
"automationId": str(automation_id) if automation_id else None,
|
|
456
|
+
"automationName": automation_name,
|
|
457
|
+
"inputs": inputs,
|
|
458
|
+
"priority": priority,
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
return StartedJob.model_validate(self._request("POST", "/jobs", body=body))
|
|
462
|
+
|
|
463
|
+
def list_jobs(
|
|
464
|
+
self,
|
|
465
|
+
*,
|
|
466
|
+
automation_name: str | None = None,
|
|
467
|
+
statuses: list[str] | None = None,
|
|
468
|
+
top: int = 50,
|
|
469
|
+
) -> list[RuntimeJob]:
|
|
470
|
+
"""The newest jobs of this workspace (at most 100), optionally of one automation and in some statuses."""
|
|
471
|
+
params = {"top": str(max(1, min(top, 100)))}
|
|
472
|
+
|
|
473
|
+
if automation_name:
|
|
474
|
+
params["automationName"] = automation_name
|
|
475
|
+
|
|
476
|
+
if statuses:
|
|
477
|
+
params["status"] = ",".join(statuses)
|
|
478
|
+
|
|
479
|
+
answer = self._request("GET", "/jobs", params=params)
|
|
480
|
+
|
|
481
|
+
return [RuntimeJob.model_validate(item) for item in answer.get("items") or []]
|
|
482
|
+
|
|
483
|
+
def stop_job(self, job_id: UUID | str, *, kill: bool = False) -> None:
|
|
484
|
+
"""Ask a job of this workspace to stop, or with ``kill`` end it at once; a pending job is canceled."""
|
|
485
|
+
self._request("POST", f"/jobs/{_segment(str(job_id))}/stop", body={"strategy": "Kill" if kill else "SoftStop"})
|
|
486
|
+
|
|
487
|
+
def raise_alert(self, message: str, *, severity: str = "Info") -> None:
|
|
488
|
+
"""Raise an alert (``Info``, ``Warning`` or ``Error``): a workspace notification tied to this job."""
|
|
489
|
+
self._request("POST", "/alerts", body={"severity": severity, "message": message})
|
|
490
|
+
|
|
491
|
+
# ---------------------------------------------------------------- action tasks
|
|
492
|
+
|
|
493
|
+
def create_action_task(
|
|
494
|
+
self,
|
|
495
|
+
app_key: str,
|
|
496
|
+
version: int,
|
|
497
|
+
input: dict[str, Any] | None = None,
|
|
498
|
+
*,
|
|
499
|
+
correlation_key: str | None = None,
|
|
500
|
+
title: str | None = None,
|
|
501
|
+
assignee_user_id: UUID | str | None = None,
|
|
502
|
+
assignee_group: str | None = None,
|
|
503
|
+
due_at: datetime | None = None,
|
|
504
|
+
priority: str | None = None,
|
|
505
|
+
) -> UUID:
|
|
506
|
+
"""Raise an Action Hub task from ``app_key`` at a published ``version``; returns its id.
|
|
507
|
+
|
|
508
|
+
The input is validated against the version's input schema
|
|
509
|
+
(:class:`ActionTaskInputInvalidError`). With the same ``correlation_key`` and payload a
|
|
510
|
+
second call in this job returns the same task, so a retry never raises two.
|
|
511
|
+
"""
|
|
512
|
+
self._ensure_action_tasks()
|
|
513
|
+
|
|
514
|
+
body = {
|
|
515
|
+
"appKey": app_key,
|
|
516
|
+
"version": version,
|
|
517
|
+
"title": title,
|
|
518
|
+
"input": input,
|
|
519
|
+
"correlationKey": correlation_key,
|
|
520
|
+
"assigneeUserId": str(assignee_user_id) if assignee_user_id else None,
|
|
521
|
+
"assigneeGroup": assignee_group,
|
|
522
|
+
"dueAt": due_at.isoformat() if due_at else None,
|
|
523
|
+
"priority": priority,
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
created = self._request("POST", "/action-tasks", body=body)
|
|
527
|
+
return UUID(str(created["taskId"]))
|
|
528
|
+
|
|
529
|
+
def get_action_task(self, task_id: UUID | str) -> RuntimeActionTask:
|
|
530
|
+
"""The task's status, and its outcome and output once completed."""
|
|
531
|
+
self._ensure_action_tasks()
|
|
532
|
+
return RuntimeActionTask.model_validate(self._request("GET", f"/action-tasks/{task_id}"))
|
|
533
|
+
|
|
534
|
+
def wait_for_action_task(
|
|
535
|
+
self,
|
|
536
|
+
task_id: UUID | str,
|
|
537
|
+
*,
|
|
538
|
+
timeout: float = 900.0,
|
|
539
|
+
raise_on_timeout: bool = False,
|
|
540
|
+
deadline: datetime | None = None,
|
|
541
|
+
stop_requested: Any | None = None,
|
|
542
|
+
) -> ActionTaskResult:
|
|
543
|
+
"""Wait for the task's decision, one server long poll (at most 25 s) at a time.
|
|
544
|
+
|
|
545
|
+
Returns ``completed`` or ``canceled``; after ``timeout`` seconds (never past
|
|
546
|
+
``deadline``) the task stays open and the result is ``timedOut``, or
|
|
547
|
+
:class:`ActionTaskTimeoutError` with ``raise_on_timeout=True``. ``stop_requested``, a
|
|
548
|
+
callable, ends the wait within one poll when it turns true. Transient failures are
|
|
549
|
+
retried with a bounded backoff. It always waits in the job: a Python job cannot suspend.
|
|
550
|
+
For waits of hours or days, use the app's continuation.
|
|
551
|
+
"""
|
|
552
|
+
self._ensure_action_tasks()
|
|
553
|
+
|
|
554
|
+
answer = self._long_poll(
|
|
555
|
+
f"/action-tasks/{task_id}/wait",
|
|
556
|
+
("completed", "canceled"),
|
|
557
|
+
_until(timeout, deadline),
|
|
558
|
+
stop_requested,
|
|
559
|
+
lambda error: isinstance(error, ActionHubError),
|
|
560
|
+
)
|
|
561
|
+
|
|
562
|
+
if answer is None:
|
|
563
|
+
if raise_on_timeout:
|
|
564
|
+
raise ActionTaskTimeoutError(0, None, f"Action task {task_id} was not decided in time; it stays open.")
|
|
565
|
+
|
|
566
|
+
return ActionTaskResult(taskId=task_id, status="timedOut")
|
|
567
|
+
|
|
568
|
+
return ActionTaskResult.model_validate(answer)
|
|
569
|
+
|
|
570
|
+
def action_app_schema(self, app_key: str, version: int) -> ActionAppSchema:
|
|
571
|
+
"""The input and output JSON Schema of a published app version."""
|
|
572
|
+
self._ensure_action_tasks()
|
|
573
|
+
return ActionAppSchema.model_validate(
|
|
574
|
+
self._request("GET", f"/action-apps/{_segment(app_key)}/versions/{version}/schema")
|
|
575
|
+
)
|
|
576
|
+
|
|
577
|
+
# ---------------------------------------------------------------- agents
|
|
578
|
+
|
|
579
|
+
def agent_candidate(self, agent_key: str, candidate_id: str) -> dict[str, Any]:
|
|
580
|
+
"""The immutable candidate an agent run executes: ``{agentKey, candidateId, definition, ...}``."""
|
|
581
|
+
return self._request("GET", f"/agents/{_segment(agent_key)}/candidates/{_segment(candidate_id)}")
|
|
582
|
+
|
|
583
|
+
def write_spans(self, spans: list[dict[str, Any]], trace_id: UUID | str | None = None) -> None:
|
|
584
|
+
"""Record trace spans of this job (``traces/spans``)."""
|
|
585
|
+
self._request("POST", "/traces/spans", body={"traceId": str(trace_id) if trace_id else None, "spans": spans})
|
|
586
|
+
|
|
587
|
+
def search_vector_store(self, store: str, query: str, top_k: int | None = None) -> list[dict[str, Any]]:
|
|
588
|
+
"""The closest documents of a workspace vector store: ``[{documentId, content, metadata, score}]``."""
|
|
589
|
+
answer = self._request("POST", f"/vector-stores/{_segment(store)}/search", body={"query": query, "topK": top_k})
|
|
590
|
+
return list((answer or {}).get("results") or [])
|
|
591
|
+
|
|
592
|
+
def call_mcp_tool(
|
|
593
|
+
self,
|
|
594
|
+
server: str,
|
|
595
|
+
tool: str,
|
|
596
|
+
payload: Any | None = None,
|
|
597
|
+
*,
|
|
598
|
+
idempotency_key: str | None = None,
|
|
599
|
+
timeout_seconds: int | None = None,
|
|
600
|
+
) -> dict[str, Any]:
|
|
601
|
+
"""Call a tool of a workspace MCP server: ``{success, data, error, statusCode, durationMs}``.
|
|
602
|
+
|
|
603
|
+
The same ``idempotency_key`` replays the first answer instead of calling the server twice.
|
|
604
|
+
"""
|
|
605
|
+
return self._request(
|
|
606
|
+
"POST",
|
|
607
|
+
f"/mcp/servers/{_segment(server)}/call",
|
|
608
|
+
body={"toolName": tool, "payload": payload, "timeoutSeconds": timeout_seconds},
|
|
609
|
+
headers={"Idempotency-Key": idempotency_key} if idempotency_key else None,
|
|
610
|
+
)
|
|
611
|
+
|
|
612
|
+
def invoke_tool(
|
|
613
|
+
self, name: str, arguments: dict[str, Any], *, idempotency_key: str | None = None
|
|
614
|
+
) -> dict[str, Any]:
|
|
615
|
+
"""Invoke a workspace tool by name: ``{success, tool, data, error, durationMs}``."""
|
|
616
|
+
return self._request(
|
|
617
|
+
"POST",
|
|
618
|
+
f"/tools/{_segment(name)}/invoke",
|
|
619
|
+
body=arguments,
|
|
620
|
+
headers={"Idempotency-Key": idempotency_key} if idempotency_key else None,
|
|
621
|
+
)
|
|
622
|
+
|
|
623
|
+
def request_tool_approval(
|
|
624
|
+
self,
|
|
625
|
+
*,
|
|
626
|
+
tool_name: str,
|
|
627
|
+
tool_kind: str,
|
|
628
|
+
tool_ref: str,
|
|
629
|
+
arguments: dict[str, Any],
|
|
630
|
+
idempotency_key: str,
|
|
631
|
+
) -> dict[str, Any]:
|
|
632
|
+
"""Ask a person to approve one tool call with its exact arguments: ``{approvalId, status}``.
|
|
633
|
+
|
|
634
|
+
The same ``idempotency_key`` returns the same approval, so a retried request never asks twice.
|
|
635
|
+
"""
|
|
636
|
+
return self._request(
|
|
637
|
+
"POST",
|
|
638
|
+
"/agents/tool-approvals",
|
|
639
|
+
body={
|
|
640
|
+
"toolName": tool_name,
|
|
641
|
+
"toolKind": tool_kind,
|
|
642
|
+
"toolRef": tool_ref,
|
|
643
|
+
"arguments": arguments,
|
|
644
|
+
"idempotencyKey": idempotency_key,
|
|
645
|
+
},
|
|
646
|
+
)
|
|
647
|
+
|
|
648
|
+
def wait_for_tool_approval(
|
|
649
|
+
self,
|
|
650
|
+
approval_id: str,
|
|
651
|
+
*,
|
|
652
|
+
timeout: float,
|
|
653
|
+
deadline: datetime | None = None,
|
|
654
|
+
stop_requested: Any | None = None,
|
|
655
|
+
) -> str:
|
|
656
|
+
"""``approved``, ``rejected`` or ``expired``; ``timedOut`` when nobody decided within ``timeout``.
|
|
657
|
+
|
|
658
|
+
A bounded wait in the job, like :meth:`wait_for_action_task`: never past ``deadline``.
|
|
659
|
+
"""
|
|
660
|
+
answer = self._long_poll(
|
|
661
|
+
f"/agents/tool-approvals/{_segment(approval_id)}/wait",
|
|
662
|
+
("approved", "rejected", "expired"),
|
|
663
|
+
_until(timeout, deadline),
|
|
664
|
+
stop_requested,
|
|
665
|
+
lambda error: False,
|
|
666
|
+
)
|
|
667
|
+
|
|
668
|
+
return "timedOut" if answer is None else str(answer["status"])
|
|
669
|
+
|
|
670
|
+
def _long_poll(
|
|
671
|
+
self,
|
|
672
|
+
path: str,
|
|
673
|
+
finished: tuple[str, ...],
|
|
674
|
+
until: float,
|
|
675
|
+
stop_requested: Any | None,
|
|
676
|
+
final: Any,
|
|
677
|
+
) -> dict[str, Any] | None:
|
|
678
|
+
"""Poll ``path?timeoutSeconds=`` until its ``status`` is one of ``finished``; ``None`` at ``until``.
|
|
679
|
+
|
|
680
|
+
Transient failures are retried with a bounded backoff (1, 2, 4, 8, 16 s); a refusal the
|
|
681
|
+
caller calls ``final``, or any 4xx, is raised at once.
|
|
682
|
+
"""
|
|
683
|
+
failures = 0
|
|
684
|
+
|
|
685
|
+
while True:
|
|
686
|
+
remaining = until - time.monotonic()
|
|
687
|
+
|
|
688
|
+
if remaining <= 0 or (stop_requested is not None and stop_requested()):
|
|
689
|
+
return None
|
|
690
|
+
|
|
691
|
+
seconds = int(min(remaining, _MAX_WAIT_SECONDS))
|
|
692
|
+
|
|
693
|
+
try:
|
|
694
|
+
answer = self._request("GET", f"{path}?timeoutSeconds={seconds}", timeout=_WAIT_TIMEOUT) or {}
|
|
695
|
+
failures = 0
|
|
696
|
+
except RuntimeApiError as error:
|
|
697
|
+
failures += 1
|
|
698
|
+
|
|
699
|
+
if final(error) or 0 < error.status_code < 500 or failures > _WAIT_RETRIES:
|
|
700
|
+
raise
|
|
701
|
+
|
|
702
|
+
time.sleep(2 ** (failures - 1))
|
|
703
|
+
continue
|
|
704
|
+
|
|
705
|
+
if answer.get("status") in finished:
|
|
706
|
+
return answer
|
|
707
|
+
|
|
708
|
+
# ---------------------------------------------------------------- storage buckets
|
|
709
|
+
|
|
710
|
+
def list_files(
|
|
711
|
+
self,
|
|
712
|
+
bucket: str,
|
|
713
|
+
prefix: str | None = None,
|
|
714
|
+
*,
|
|
715
|
+
pattern: str | None = None,
|
|
716
|
+
recursive: bool = False,
|
|
717
|
+
cursor: str | None = None,
|
|
718
|
+
) -> StorageFileList:
|
|
719
|
+
"""One page of a bucket's files under ``prefix`` (the root when ``None``).
|
|
720
|
+
|
|
721
|
+
``pattern`` is ``*``/``?`` wildcards on the file name, several separated by ``;``. Pass the
|
|
722
|
+
page's ``next_cursor`` as ``cursor`` for the next page; :meth:`iter_files` does that for you.
|
|
723
|
+
"""
|
|
724
|
+
self._ensure_storage()
|
|
725
|
+
|
|
726
|
+
params: dict[str, str] = {"recursive": "true" if recursive else "false"}
|
|
727
|
+
|
|
728
|
+
for name, value in (("prefix", prefix), ("pattern", pattern), ("cursor", cursor)):
|
|
729
|
+
if value:
|
|
730
|
+
params[name] = value
|
|
731
|
+
|
|
732
|
+
return StorageFileList.model_validate(
|
|
733
|
+
self._request("GET", f"/storage-buckets/{_segment(bucket)}/files", params=params)
|
|
734
|
+
)
|
|
735
|
+
|
|
736
|
+
def iter_files(
|
|
737
|
+
self,
|
|
738
|
+
bucket: str,
|
|
739
|
+
prefix: str | None = None,
|
|
740
|
+
*,
|
|
741
|
+
pattern: str | None = None,
|
|
742
|
+
recursive: bool = False,
|
|
743
|
+
) -> Iterator[StorageFile]:
|
|
744
|
+
"""Every file of a listing, page after page."""
|
|
745
|
+
cursor: str | None = None
|
|
746
|
+
|
|
747
|
+
while True:
|
|
748
|
+
page = self.list_files(bucket, prefix, pattern=pattern, recursive=recursive, cursor=cursor)
|
|
749
|
+
yield from page.files
|
|
750
|
+
|
|
751
|
+
if not page.next_cursor:
|
|
752
|
+
return
|
|
753
|
+
|
|
754
|
+
cursor = page.next_cursor
|
|
755
|
+
|
|
756
|
+
def upload_file(
|
|
757
|
+
self,
|
|
758
|
+
bucket: str,
|
|
759
|
+
path: str,
|
|
760
|
+
content: bytes | IO[bytes],
|
|
761
|
+
*,
|
|
762
|
+
content_type: str | None = None,
|
|
763
|
+
) -> StorageFile:
|
|
764
|
+
"""Store ``content`` at ``path`` (a new version when the path exists in an internal bucket)."""
|
|
765
|
+
self._ensure_storage()
|
|
766
|
+
|
|
767
|
+
response = self._send(
|
|
768
|
+
"PUT",
|
|
769
|
+
f"/storage-buckets/{_segment(bucket)}/files/content",
|
|
770
|
+
params={"path": path},
|
|
771
|
+
content=content,
|
|
772
|
+
headers={"Content-Type": content_type or "application/octet-stream"},
|
|
773
|
+
)
|
|
774
|
+
|
|
775
|
+
return StorageFile.model_validate(_json.loads(response.content))
|
|
776
|
+
|
|
777
|
+
def download_file(self, bucket: str, path: str, destination: IO[bytes]) -> None:
|
|
778
|
+
"""Copy a file's bytes into ``destination`` (an open binary file or buffer)."""
|
|
779
|
+
self._ensure_storage()
|
|
780
|
+
|
|
781
|
+
url = f"/storage-buckets/{_segment(bucket)}/files/content"
|
|
782
|
+
|
|
783
|
+
try:
|
|
784
|
+
with self._client.stream("GET", url, params={"path": path}, timeout=_FILE_TIMEOUT) as response:
|
|
785
|
+
if not response.is_success:
|
|
786
|
+
response.read()
|
|
787
|
+
_raise_for(response, "GET", url)
|
|
788
|
+
|
|
789
|
+
for chunk in response.iter_bytes():
|
|
790
|
+
destination.write(chunk)
|
|
791
|
+
except httpx.HTTPError as error:
|
|
792
|
+
raise RuntimeApiError(0, None, f"The Runtime API could not be reached: {error}") from error
|
|
793
|
+
|
|
794
|
+
def read_file(self, bucket: str, path: str) -> bytes:
|
|
795
|
+
"""A file's bytes."""
|
|
796
|
+
self._ensure_storage()
|
|
797
|
+
|
|
798
|
+
return self._send("GET", f"/storage-buckets/{_segment(bucket)}/files/content", params={"path": path}).content
|
|
799
|
+
|
|
800
|
+
def delete_file(self, bucket: str, path: str) -> None:
|
|
801
|
+
"""Delete one file (``storage_file_not_found`` when there is none)."""
|
|
802
|
+
self._ensure_storage()
|
|
803
|
+
self._request("DELETE", f"/storage-buckets/{_segment(bucket)}/files", params={"path": path})
|
|
804
|
+
|
|
805
|
+
def _ensure_storage(self) -> None:
|
|
806
|
+
if STORAGE_BUCKETS_CAPABILITY not in self._capabilities_offered():
|
|
807
|
+
raise StorageBucketsUnsupportedError(
|
|
808
|
+
501,
|
|
809
|
+
{"code": "storage_buckets_runtime_unsupported"},
|
|
810
|
+
"The Orchestrator does not offer storage buckets "
|
|
811
|
+
f"(runtime capability {STORAGE_BUCKETS_CAPABILITY}); upgrade it.",
|
|
812
|
+
)
|
|
813
|
+
|
|
814
|
+
def _capabilities_offered(self) -> frozenset[str]:
|
|
815
|
+
"""The server's runtime capabilities, asked once per client (plan X7)."""
|
|
816
|
+
if self._capabilities is None:
|
|
817
|
+
try:
|
|
818
|
+
capabilities = self._request("GET", "/capabilities") or {}
|
|
819
|
+
self._capabilities = frozenset(capabilities.get("capabilities") or [])
|
|
820
|
+
except RuntimeApiError as error:
|
|
821
|
+
if error.status_code != httpx.codes.NOT_FOUND:
|
|
822
|
+
raise
|
|
823
|
+
|
|
824
|
+
# An Orchestrator that predates the capability route.
|
|
825
|
+
self._capabilities = frozenset()
|
|
826
|
+
|
|
827
|
+
return self._capabilities
|
|
828
|
+
|
|
829
|
+
def _ensure_action_tasks(self) -> None:
|
|
830
|
+
"""Checks the server's action-task capability once per client (plan X7)."""
|
|
831
|
+
if ACTION_TASKS_CAPABILITY not in self._capabilities_offered():
|
|
832
|
+
raise ActionHubRuntimeUnsupportedError(
|
|
833
|
+
501,
|
|
834
|
+
{"code": "action_hub_runtime_unsupported"},
|
|
835
|
+
"The Orchestrator does not offer Action Hub automation (runtime capability "
|
|
836
|
+
f"{ACTION_TASKS_CAPABILITY}); upgrade it to a version in the Action Hub compatibility matrix.",
|
|
837
|
+
)
|
|
838
|
+
|
|
839
|
+
# ---------------------------------------------------------------- plumbing
|
|
840
|
+
|
|
841
|
+
def _request(
|
|
842
|
+
self,
|
|
843
|
+
method: str,
|
|
844
|
+
path: str,
|
|
845
|
+
body: Any | None = None,
|
|
846
|
+
timeout: httpx.Timeout | None = None,
|
|
847
|
+
params: dict[str, str] | None = None,
|
|
848
|
+
headers: dict[str, str] | None = None,
|
|
849
|
+
) -> Any:
|
|
850
|
+
content = None
|
|
851
|
+
headers = dict(headers or {})
|
|
852
|
+
|
|
853
|
+
if body is not None:
|
|
854
|
+
# The platform writer, not httpx's json=, so payloads escape like every other
|
|
855
|
+
# VeloPhex document and a datetime never sneaks through unformatted.
|
|
856
|
+
content = _json.encode_wire(_strip_none(body))
|
|
857
|
+
headers["Content-Type"] = "application/json"
|
|
858
|
+
|
|
859
|
+
try:
|
|
860
|
+
if timeout is None:
|
|
861
|
+
response = self._client.request(method, path, content=content, headers=headers, params=params)
|
|
862
|
+
else:
|
|
863
|
+
response = self._client.request(
|
|
864
|
+
method, path, content=content, headers=headers, params=params, timeout=timeout
|
|
865
|
+
)
|
|
866
|
+
except httpx.HTTPError as error:
|
|
867
|
+
raise RuntimeApiError(0, None, f"The Runtime API could not be reached: {error}") from error
|
|
868
|
+
|
|
869
|
+
if response.status_code == httpx.codes.NO_CONTENT or not response.content:
|
|
870
|
+
if not response.is_success:
|
|
871
|
+
_raise_for(response, method, path)
|
|
872
|
+
|
|
873
|
+
return None
|
|
874
|
+
|
|
875
|
+
if response.is_success:
|
|
876
|
+
return _json.loads(response.content)
|
|
877
|
+
|
|
878
|
+
_raise_for(response, method, path)
|
|
879
|
+
|
|
880
|
+
def _send(
|
|
881
|
+
self,
|
|
882
|
+
method: str,
|
|
883
|
+
path: str,
|
|
884
|
+
*,
|
|
885
|
+
params: dict[str, str],
|
|
886
|
+
content: bytes | IO[bytes] | None = None,
|
|
887
|
+
headers: dict[str, str] | None = None,
|
|
888
|
+
) -> httpx.Response:
|
|
889
|
+
"""A request whose body or answer is a file's bytes rather than a JSON document."""
|
|
890
|
+
try:
|
|
891
|
+
response = self._client.request(
|
|
892
|
+
method, path, params=params, content=content, headers=headers, timeout=_FILE_TIMEOUT
|
|
893
|
+
)
|
|
894
|
+
except httpx.HTTPError as error:
|
|
895
|
+
raise RuntimeApiError(0, None, f"The Runtime API could not be reached: {error}") from error
|
|
896
|
+
|
|
897
|
+
if not response.is_success:
|
|
898
|
+
_raise_for(response, method, path)
|
|
899
|
+
|
|
900
|
+
return response
|
|
901
|
+
|
|
902
|
+
|
|
903
|
+
def _raise_for(response: httpx.Response, method: str, path: str) -> NoReturn:
|
|
904
|
+
"""Raise the typed error for a refused call, from its RFC 9457 problem details."""
|
|
905
|
+
problem = None
|
|
906
|
+
|
|
907
|
+
try:
|
|
908
|
+
parsed = _json.loads(response.content)
|
|
909
|
+
problem = parsed if isinstance(parsed, dict) else None
|
|
910
|
+
except ValueError:
|
|
911
|
+
problem = None
|
|
912
|
+
|
|
913
|
+
detail = (problem or {}).get("detail") or (problem or {}).get("title") or response.reason_phrase
|
|
914
|
+
|
|
915
|
+
raise runtime_error(response.status_code, problem, f"{method} {path} failed with {response.status_code}: {detail}")
|
|
916
|
+
|
|
917
|
+
|
|
918
|
+
def runtime_error(status_code: int, problem: dict[str, Any] | None, message: str) -> RuntimeApiError:
|
|
919
|
+
"""The typed error of a refusal: an Action Hub or AI gateway class by its ``code``, else the base."""
|
|
920
|
+
code = (problem or {}).get("code")
|
|
921
|
+
error_type: type[RuntimeApiError] = RuntimeApiError
|
|
922
|
+
|
|
923
|
+
if isinstance(code, str):
|
|
924
|
+
if _is_action_hub(code):
|
|
925
|
+
error_type = _ACTION_HUB_ERRORS.get(code, ActionHubError)
|
|
926
|
+
elif code in _AI_ERRORS:
|
|
927
|
+
error_type = _AI_ERRORS[code]
|
|
928
|
+
elif code.startswith("ai_"):
|
|
929
|
+
error_type = AiError
|
|
930
|
+
|
|
931
|
+
return error_type(status_code, problem, message)
|
|
932
|
+
|
|
933
|
+
|
|
934
|
+
def _until(timeout: float, deadline: datetime | None) -> float:
|
|
935
|
+
"""The monotonic instant a wait ends: ``timeout`` from now, and 30 s before ``deadline``."""
|
|
936
|
+
until = time.monotonic() + max(timeout, 0.0)
|
|
937
|
+
|
|
938
|
+
if deadline is not None:
|
|
939
|
+
until = min(until, time.monotonic() + (deadline - datetime.now(UTC)).total_seconds() - 30.0)
|
|
940
|
+
|
|
941
|
+
return until
|
|
942
|
+
|
|
943
|
+
|
|
944
|
+
def _is_action_hub(code: str) -> bool:
|
|
945
|
+
return code.startswith("action_") or code in ("self_approval_forbidden", "continuation_start_failed")
|
|
946
|
+
|
|
947
|
+
|
|
948
|
+
def _strip_none(value: Any) -> Any:
|
|
949
|
+
if isinstance(value, dict):
|
|
950
|
+
return {name: _strip_none(member) for name, member in value.items() if member is not None}
|
|
951
|
+
|
|
952
|
+
return value
|
|
953
|
+
|
|
954
|
+
|
|
955
|
+
def _segment(value: str) -> str:
|
|
956
|
+
"""Percent-encode one path segment; asset and queue names may contain spaces."""
|
|
957
|
+
return quote(value, safe="")
|