readyagentsdev 0.8.2__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.
- readyagents/__init__.py +38 -0
- readyagents/__main__.py +4 -0
- readyagents/audit.py +67 -0
- readyagents/cli.py +1050 -0
- readyagents/config.py +264 -0
- readyagents/errors.py +129 -0
- readyagents/llm/__init__.py +11 -0
- readyagents/llm/anthropic_provider.py +72 -0
- readyagents/llm/base.py +57 -0
- readyagents/llm/cache.py +86 -0
- readyagents/llm/openai_compat.py +12 -0
- readyagents/llm/openai_provider.py +70 -0
- readyagents/llm/registry.py +112 -0
- readyagents/llm/resilience.py +179 -0
- readyagents/llm/tool_calls.py +286 -0
- readyagents/logging.py +162 -0
- readyagents/mcp/__init__.py +43 -0
- readyagents/mcp/builtin.py +674 -0
- readyagents/mcp/client.py +253 -0
- readyagents/mcp/http.py +585 -0
- readyagents/mcp/run_api.py +1077 -0
- readyagents/mcp/server.py +246 -0
- readyagents/notify.py +63 -0
- readyagents/packs/__init__.py +26 -0
- readyagents/packs/loader.py +157 -0
- readyagents/packs/protocol.py +55 -0
- readyagents/policy.py +127 -0
- readyagents/py.typed +1 -0
- readyagents/report.py +88 -0
- readyagents/scaffold.py +410 -0
- readyagents/secrets.py +120 -0
- readyagents/testing/__init__.py +17 -0
- readyagents/testing/eval.py +219 -0
- readyagents/testing/helpers.py +128 -0
- readyagents/testing/recorded.py +68 -0
- readyagents/tools/__init__.py +67 -0
- readyagents/workflow/__init__.py +3 -0
- readyagents/workflow/cancellation.py +88 -0
- readyagents/workflow/conditions.py +279 -0
- readyagents/workflow/engine.py +354 -0
- readyagents/workflow/nodes.py +944 -0
- readyagents/workflow/runner.py +375 -0
- readyagents/workflow/schema.py +287 -0
- readyagents/workflow/state.py +472 -0
- readyagents/workflow/structured.py +103 -0
- readyagents/workflow/templates.py +125 -0
- readyagentsdev-0.8.2.dist-info/METADATA +215 -0
- readyagentsdev-0.8.2.dist-info/RECORD +51 -0
- readyagentsdev-0.8.2.dist-info/WHEEL +4 -0
- readyagentsdev-0.8.2.dist-info/entry_points.txt +2 -0
- readyagentsdev-0.8.2.dist-info/licenses/LICENSE +201 -0
|
@@ -0,0 +1,1077 @@
|
|
|
1
|
+
"""HTTP run coordinator: start, poll, decide, and cancel workflow runs.
|
|
2
|
+
|
|
3
|
+
Request-driven. Persistence cannot be disabled through this API. HTTP never
|
|
4
|
+
uses run-id prefix lookup — only a full 32-char hex id is accepted.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import hashlib
|
|
10
|
+
import inspect
|
|
11
|
+
import json
|
|
12
|
+
import re
|
|
13
|
+
import threading
|
|
14
|
+
import time
|
|
15
|
+
from collections import deque
|
|
16
|
+
from collections.abc import Callable, Mapping, Sequence
|
|
17
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import Any
|
|
20
|
+
from uuid import uuid4
|
|
21
|
+
|
|
22
|
+
from readyagents.config import Settings, get_settings
|
|
23
|
+
from readyagents.errors import (
|
|
24
|
+
ApprovalRequired,
|
|
25
|
+
AuthorizationError,
|
|
26
|
+
ConfigError,
|
|
27
|
+
MCPError,
|
|
28
|
+
ReadyAgentsError,
|
|
29
|
+
WorkflowError,
|
|
30
|
+
)
|
|
31
|
+
from readyagents.logging import get_logger
|
|
32
|
+
from readyagents.packs.loader import collect_pack_authorizers, discover_packs
|
|
33
|
+
from readyagents.policy import redactor_from_settings, resolve_authorizer
|
|
34
|
+
from readyagents.tools import ToolRegistry
|
|
35
|
+
from readyagents.workflow.runner import (
|
|
36
|
+
confine_under,
|
|
37
|
+
load_workflow,
|
|
38
|
+
merge_inputs,
|
|
39
|
+
resume_run,
|
|
40
|
+
run_workflow_file,
|
|
41
|
+
)
|
|
42
|
+
from readyagents.workflow.state import RunState, persist_run, utc_now
|
|
43
|
+
|
|
44
|
+
log = get_logger("run_api")
|
|
45
|
+
|
|
46
|
+
_RUN_ID_RE = re.compile(r"[0-9a-f]{32}")
|
|
47
|
+
_MAX_JSON_DEPTH = 32
|
|
48
|
+
_MAX_REASON_CHARS = 512
|
|
49
|
+
_START_FIELDS = frozenset({"path", "inputs", "actor", "dry_run"})
|
|
50
|
+
_DECIDE_FIELDS = frozenset({"node_id", "decision", "actor"})
|
|
51
|
+
_CANCEL_FIELDS = frozenset({"actor", "reason"})
|
|
52
|
+
_TERMINAL = frozenset({"succeeded", "failed", "cancelled"})
|
|
53
|
+
_RATE_LIMIT = 120
|
|
54
|
+
_RATE_WINDOW = 60.0
|
|
55
|
+
_APPROVE = "approve"
|
|
56
|
+
_REJECT = "reject"
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _default_max_body() -> int:
|
|
60
|
+
try:
|
|
61
|
+
from readyagents.config import MAX_HTTP_BODY_BYTES
|
|
62
|
+
|
|
63
|
+
return int(MAX_HTTP_BODY_BYTES)
|
|
64
|
+
except Exception: # noqa: BLE001
|
|
65
|
+
return 1_048_576
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _error_type(name: str, fallback: type[ReadyAgentsError]) -> type[ReadyAgentsError]:
|
|
69
|
+
try:
|
|
70
|
+
from readyagents import errors as errmod
|
|
71
|
+
|
|
72
|
+
found = getattr(errmod, name, None)
|
|
73
|
+
if isinstance(found, type) and issubclass(found, BaseException):
|
|
74
|
+
return found
|
|
75
|
+
except Exception: # noqa: BLE001
|
|
76
|
+
pass
|
|
77
|
+
return fallback
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class _HttpRequestError(ReadyAgentsError):
|
|
81
|
+
"""Malformed or oversized HTTP request."""
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class _HttpAuthError(ReadyAgentsError):
|
|
85
|
+
"""HTTP authentication failed."""
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class _RunConflict(ReadyAgentsError):
|
|
89
|
+
"""Idempotency mismatch, stale decide, or concurrent resume."""
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class _CancellationRequested(ReadyAgentsError):
|
|
93
|
+
"""A run was cancelled at a safe point."""
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class QueueOverflow(ReadyAgentsError):
|
|
97
|
+
"""Too many queued or running runs to accept another start."""
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class ServiceUnavailable(ReadyAgentsError):
|
|
101
|
+
"""Coordinator is shutting down."""
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
HttpRequestError = _error_type("HttpRequestError", _HttpRequestError)
|
|
105
|
+
HttpAuthError = _error_type("HttpAuthError", _HttpAuthError)
|
|
106
|
+
RunConflict = _error_type("RunConflict", _RunConflict)
|
|
107
|
+
CancellationRequested = _error_type("CancellationRequested", _CancellationRequested)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _cancellation_token_cls() -> type:
|
|
111
|
+
try:
|
|
112
|
+
from readyagents.workflow.cancellation import CancellationToken as imported
|
|
113
|
+
|
|
114
|
+
return imported
|
|
115
|
+
except ImportError:
|
|
116
|
+
return _CancellationToken
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
class _CancellationToken:
|
|
120
|
+
"""Fallback token used until ``workflow.cancellation`` is merged."""
|
|
121
|
+
|
|
122
|
+
def __init__(self) -> None:
|
|
123
|
+
self._lock = threading.Lock()
|
|
124
|
+
self._requested = False
|
|
125
|
+
self.actor: str | None = None
|
|
126
|
+
self.reason: str | None = None
|
|
127
|
+
self.requested_at: str | None = None
|
|
128
|
+
|
|
129
|
+
def request(self, actor: str | None = None, reason: str | None = None) -> None:
|
|
130
|
+
with self._lock:
|
|
131
|
+
if self._requested:
|
|
132
|
+
return
|
|
133
|
+
self._requested = True
|
|
134
|
+
self.actor = actor
|
|
135
|
+
self.reason = reason
|
|
136
|
+
self.requested_at = utc_now()
|
|
137
|
+
|
|
138
|
+
def is_requested(self) -> bool:
|
|
139
|
+
with self._lock:
|
|
140
|
+
return self._requested
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
CancellationToken = _cancellation_token_cls()
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def load_run_exact(runs_dir: Path, run_id: str) -> RunState:
|
|
147
|
+
"""Load ``{runs_dir}/{run_id}.json``. Full 32-char hex id only — no prefix."""
|
|
148
|
+
if not isinstance(run_id, str) or not _RUN_ID_RE.fullmatch(run_id):
|
|
149
|
+
raise HttpRequestError(f"Invalid run id: {run_id}")
|
|
150
|
+
path = Path(runs_dir) / f"{run_id}.json"
|
|
151
|
+
if not path.is_file():
|
|
152
|
+
exc = ConfigError(f"Run not found: {run_id}")
|
|
153
|
+
exc.run_id = run_id
|
|
154
|
+
raise exc
|
|
155
|
+
try:
|
|
156
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
157
|
+
except json.JSONDecodeError as exc:
|
|
158
|
+
raise ConfigError(f"Corrupt run record {path}: {exc}") from exc
|
|
159
|
+
except OSError as exc:
|
|
160
|
+
raise ConfigError(f"Cannot read run record {path}: {exc}") from exc
|
|
161
|
+
if not isinstance(data, dict) or not data.get("run_id"):
|
|
162
|
+
raise ConfigError(f"Invalid run record: {path}")
|
|
163
|
+
return RunState.from_record(data)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _links(run_id: str) -> dict[str, str]:
|
|
167
|
+
return {
|
|
168
|
+
"self": f"/runs/{run_id}",
|
|
169
|
+
"decide": f"/runs/{run_id}/decide",
|
|
170
|
+
"cancel": f"/runs/{run_id}/cancel",
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _canonical_body(payload: Mapping[str, Any]) -> str:
|
|
175
|
+
return json.dumps(payload, sort_keys=True, separators=(",", ":"))
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _body_hash(canonical: str) -> str:
|
|
179
|
+
return hashlib.sha256(canonical.encode("utf-8")).hexdigest()
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _check_depth(value: Any, depth: int = 1) -> None:
|
|
183
|
+
if depth > _MAX_JSON_DEPTH:
|
|
184
|
+
raise HttpRequestError(f"JSON nesting exceeds {_MAX_JSON_DEPTH}")
|
|
185
|
+
if isinstance(value, dict):
|
|
186
|
+
for item in value.values():
|
|
187
|
+
_check_depth(item, depth + 1)
|
|
188
|
+
elif isinstance(value, list):
|
|
189
|
+
for item in value:
|
|
190
|
+
_check_depth(item, depth + 1)
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _unknown_fields(payload: Mapping[str, Any], allowed: frozenset[str], *, what: str) -> None:
|
|
194
|
+
extra = sorted(str(key) for key in payload if key not in allowed)
|
|
195
|
+
if extra:
|
|
196
|
+
raise HttpRequestError(f"Unknown field(s) in {what}: {', '.join(extra)}")
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _require_object(payload: Any, *, what: str) -> dict[str, Any]:
|
|
200
|
+
if not isinstance(payload, dict):
|
|
201
|
+
raise HttpRequestError(f"{what} must be a JSON object")
|
|
202
|
+
return payload
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _is_json_content_type(value: str | None) -> bool:
|
|
206
|
+
if not value:
|
|
207
|
+
return False
|
|
208
|
+
return value.split(";", 1)[0].strip().lower() == "application/json"
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def _http_err(message: str, status_code: int = 400) -> HttpRequestError:
|
|
212
|
+
exc = HttpRequestError(message)
|
|
213
|
+
exc.status_code = status_code # type: ignore[attr-defined]
|
|
214
|
+
return exc
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def _call_supported(fn: Callable[..., Any], /, *args: Any, **kwargs: Any) -> Any:
|
|
218
|
+
"""Invoke ``fn`` dropping kwargs the current signature does not accept."""
|
|
219
|
+
params = inspect.signature(fn).parameters
|
|
220
|
+
if any(p.kind is inspect.Parameter.VAR_KEYWORD for p in params.values()):
|
|
221
|
+
return fn(*args, **kwargs)
|
|
222
|
+
names = set(params)
|
|
223
|
+
if "initial_state" in kwargs and "initial_state" not in names and "resume_state" in names:
|
|
224
|
+
if kwargs.get("resume_state") is None:
|
|
225
|
+
kwargs["resume_state"] = kwargs["initial_state"]
|
|
226
|
+
filtered = {key: value for key, value in kwargs.items() if key in names}
|
|
227
|
+
return fn(*args, **filtered)
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def _envelope(
|
|
231
|
+
exc: BaseException,
|
|
232
|
+
*,
|
|
233
|
+
request_id: str | None = None,
|
|
234
|
+
run_id: str | None = None,
|
|
235
|
+
) -> tuple[int, dict[str, str], dict[str, Any]]:
|
|
236
|
+
status = _status_for(exc)
|
|
237
|
+
error_name = type(exc).__name__
|
|
238
|
+
if error_name.startswith("_"):
|
|
239
|
+
error_name = error_name[1:]
|
|
240
|
+
if isinstance(exc, QueueOverflow):
|
|
241
|
+
error_name = "HttpRequestError"
|
|
242
|
+
status = 429
|
|
243
|
+
elif isinstance(exc, ServiceUnavailable):
|
|
244
|
+
error_name = "MCPError"
|
|
245
|
+
status = 503
|
|
246
|
+
message = str(exc) if isinstance(exc, ReadyAgentsError) else "internal error"
|
|
247
|
+
body: dict[str, Any] = {"ok": False, "error": error_name, "message": message}
|
|
248
|
+
rid = run_id or getattr(exc, "run_id", None)
|
|
249
|
+
if rid:
|
|
250
|
+
body["run_id"] = rid
|
|
251
|
+
if request_id:
|
|
252
|
+
body["request_id"] = request_id
|
|
253
|
+
headers = {"Cache-Control": "no-store"}
|
|
254
|
+
if status == 429:
|
|
255
|
+
headers["Retry-After"] = "1"
|
|
256
|
+
return status, headers, body
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def _status_for(exc: BaseException) -> int:
|
|
260
|
+
code = getattr(exc, "status_code", None)
|
|
261
|
+
if isinstance(code, int) and 400 <= code < 600:
|
|
262
|
+
return code
|
|
263
|
+
if isinstance(exc, AuthorizationError):
|
|
264
|
+
return 403
|
|
265
|
+
if isinstance(exc, HttpAuthError):
|
|
266
|
+
return 401
|
|
267
|
+
if isinstance(exc, RunConflict):
|
|
268
|
+
return 409
|
|
269
|
+
if isinstance(exc, QueueOverflow):
|
|
270
|
+
return 429
|
|
271
|
+
if isinstance(exc, ServiceUnavailable):
|
|
272
|
+
return 503
|
|
273
|
+
if isinstance(exc, HttpRequestError):
|
|
274
|
+
return 400
|
|
275
|
+
if isinstance(exc, ConfigError):
|
|
276
|
+
text = str(exc).lower()
|
|
277
|
+
if "not found" in text and "workflow" not in text:
|
|
278
|
+
return 404
|
|
279
|
+
return 400
|
|
280
|
+
if isinstance(exc, (WorkflowError, CancellationRequested)):
|
|
281
|
+
return 400
|
|
282
|
+
if isinstance(exc, MCPError):
|
|
283
|
+
return 503
|
|
284
|
+
if isinstance(exc, ReadyAgentsError):
|
|
285
|
+
return 400
|
|
286
|
+
return 500
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
class _CancellingTool:
|
|
290
|
+
"""Wrap a tool so a requested cancellation becomes ``CancellationRequested``."""
|
|
291
|
+
|
|
292
|
+
def __init__(self, inner: Any, token: Any) -> None:
|
|
293
|
+
self._inner = inner
|
|
294
|
+
self._token = token
|
|
295
|
+
self.name = inner.name
|
|
296
|
+
self.description = getattr(inner, "description", inner.name)
|
|
297
|
+
self.schema = getattr(inner, "schema", {}) or {}
|
|
298
|
+
|
|
299
|
+
def run(self, **kwargs: Any) -> Any:
|
|
300
|
+
self._raise_if_cancelled()
|
|
301
|
+
try:
|
|
302
|
+
return self._inner.run(**kwargs)
|
|
303
|
+
finally:
|
|
304
|
+
self._raise_if_cancelled()
|
|
305
|
+
|
|
306
|
+
def _raise_if_cancelled(self) -> None:
|
|
307
|
+
if self._token is not None and self._token.is_requested():
|
|
308
|
+
raise CancellationRequested("run cancelled")
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
class _RateLimiter:
|
|
312
|
+
def __init__(self, limit: int = _RATE_LIMIT, window: float = _RATE_WINDOW) -> None:
|
|
313
|
+
self.limit = limit
|
|
314
|
+
self.window = window
|
|
315
|
+
self._hits: dict[str, deque[float]] = {}
|
|
316
|
+
self._lock = threading.Lock()
|
|
317
|
+
|
|
318
|
+
def allow(self, key: str) -> bool:
|
|
319
|
+
now = time.monotonic()
|
|
320
|
+
with self._lock:
|
|
321
|
+
bucket = self._hits.setdefault(key, deque())
|
|
322
|
+
while bucket and now - bucket[0] > self.window:
|
|
323
|
+
bucket.popleft()
|
|
324
|
+
if len(bucket) >= self.limit:
|
|
325
|
+
return False
|
|
326
|
+
bucket.append(now)
|
|
327
|
+
return True
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
class RunCoordinator:
|
|
331
|
+
"""In-process run queue, idempotency, and HTTP handlers for ``/runs``."""
|
|
332
|
+
|
|
333
|
+
def __init__(
|
|
334
|
+
self,
|
|
335
|
+
*,
|
|
336
|
+
settings: Settings | None = None,
|
|
337
|
+
workspace: Path | str | None = None,
|
|
338
|
+
max_concurrent_runs: int = 4,
|
|
339
|
+
max_pending_runs: int = 32,
|
|
340
|
+
extra_tools: ToolRegistry | None = None,
|
|
341
|
+
extra_packs: Sequence[Any] | None = None,
|
|
342
|
+
max_body_bytes: int = 1_048_576,
|
|
343
|
+
) -> None:
|
|
344
|
+
bound = settings or get_settings()
|
|
345
|
+
root = workspace if workspace is not None else bound.workspace_path()
|
|
346
|
+
self.workspace = Path(root).expanduser().resolve()
|
|
347
|
+
self.settings = bound.model_copy(update={"workspace": self.workspace})
|
|
348
|
+
self.max_concurrent_runs = max(1, int(max_concurrent_runs))
|
|
349
|
+
self.max_pending_runs = max(0, int(max_pending_runs))
|
|
350
|
+
self.max_body_bytes = int(max_body_bytes) if max_body_bytes else _default_max_body()
|
|
351
|
+
self._extra_tools = extra_tools
|
|
352
|
+
self._extra_packs = list(extra_packs) if extra_packs else []
|
|
353
|
+
packs = list(discover_packs())
|
|
354
|
+
packs.extend(self._extra_packs)
|
|
355
|
+
self._authorizer = resolve_authorizer(collect_pack_authorizers(packs))
|
|
356
|
+
self._redactor = redactor_from_settings(
|
|
357
|
+
enabled=bool(self.settings.redact),
|
|
358
|
+
patterns=self.settings.redact_pattern_list(),
|
|
359
|
+
literals=self.settings.redact_literal_list(),
|
|
360
|
+
)
|
|
361
|
+
self._runs_dir = self.settings.runs_dir()
|
|
362
|
+
self._lock = threading.RLock()
|
|
363
|
+
self._run_locks: dict[str, threading.RLock] = {}
|
|
364
|
+
self._tokens: dict[str, Any] = {}
|
|
365
|
+
self._in_flight_resume: set[str] = set()
|
|
366
|
+
self._active: set[str] = set()
|
|
367
|
+
self._queued = 0
|
|
368
|
+
self._running = 0
|
|
369
|
+
self._idempotency: dict[str, tuple[str, dict[str, Any]]] = {}
|
|
370
|
+
self._shutdown = False
|
|
371
|
+
self._rate = _RateLimiter()
|
|
372
|
+
self._executor = ThreadPoolExecutor(
|
|
373
|
+
max_workers=self.max_concurrent_runs,
|
|
374
|
+
thread_name_prefix="readyagents-run",
|
|
375
|
+
)
|
|
376
|
+
|
|
377
|
+
def shutdown(self, timeout: float = 10.0) -> None:
|
|
378
|
+
self._shutdown = True
|
|
379
|
+
with self._lock:
|
|
380
|
+
tokens = list(self._tokens.values())
|
|
381
|
+
for token in tokens:
|
|
382
|
+
try:
|
|
383
|
+
token.request(actor="system", reason="shutdown")
|
|
384
|
+
except Exception: # noqa: BLE001
|
|
385
|
+
pass
|
|
386
|
+
self._executor.shutdown(wait=False, cancel_futures=True)
|
|
387
|
+
deadline = time.monotonic() + max(0.0, float(timeout))
|
|
388
|
+
while time.monotonic() < deadline:
|
|
389
|
+
with self._lock:
|
|
390
|
+
if self._running <= 0 and not self._active:
|
|
391
|
+
break
|
|
392
|
+
time.sleep(0.05)
|
|
393
|
+
|
|
394
|
+
def start_run(self, payload: dict, *, idempotency_key: str | None = None) -> dict:
|
|
395
|
+
"""Validate, persist a queued run, and submit it. Raises typed errors."""
|
|
396
|
+
return self._start(payload, idempotency_key=idempotency_key)
|
|
397
|
+
|
|
398
|
+
def get_run(self, run_id: str) -> dict:
|
|
399
|
+
state = load_run_exact(self._runs_dir, run_id)
|
|
400
|
+
return self._record_payload(state)
|
|
401
|
+
|
|
402
|
+
def decide(self, run_id: str, payload: dict) -> dict:
|
|
403
|
+
return self._decide(run_id, payload)
|
|
404
|
+
|
|
405
|
+
def cancel(self, run_id: str, payload: dict | None) -> dict:
|
|
406
|
+
return self._cancel(run_id, payload)
|
|
407
|
+
|
|
408
|
+
def submit_run(
|
|
409
|
+
self,
|
|
410
|
+
body: dict,
|
|
411
|
+
*,
|
|
412
|
+
idempotency_key: str | None,
|
|
413
|
+
actor_default: str | None = None,
|
|
414
|
+
) -> tuple[int, dict]:
|
|
415
|
+
status, _headers, payload = self.handle_start(
|
|
416
|
+
body,
|
|
417
|
+
idempotency_key=idempotency_key,
|
|
418
|
+
actor_default=actor_default,
|
|
419
|
+
)
|
|
420
|
+
return status, payload
|
|
421
|
+
|
|
422
|
+
def handle_start(
|
|
423
|
+
self,
|
|
424
|
+
payload: Any,
|
|
425
|
+
*,
|
|
426
|
+
idempotency_key: str | None = None,
|
|
427
|
+
actor_default: str | None = None,
|
|
428
|
+
request_id: str | None = None,
|
|
429
|
+
raw_len: int | None = None,
|
|
430
|
+
) -> tuple[int, dict[str, str], dict[str, Any]]:
|
|
431
|
+
try:
|
|
432
|
+
if raw_len is not None and raw_len > self.max_body_bytes:
|
|
433
|
+
raise _http_err("request body too large", 413)
|
|
434
|
+
body = self._start(
|
|
435
|
+
payload,
|
|
436
|
+
idempotency_key=idempotency_key,
|
|
437
|
+
actor_default=actor_default,
|
|
438
|
+
)
|
|
439
|
+
if request_id:
|
|
440
|
+
body = dict(body)
|
|
441
|
+
body["request_id"] = request_id
|
|
442
|
+
run_id = str(body["run_id"])
|
|
443
|
+
headers = {
|
|
444
|
+
"Location": f"/runs/{run_id}",
|
|
445
|
+
"Cache-Control": "no-store",
|
|
446
|
+
}
|
|
447
|
+
return 202, headers, body
|
|
448
|
+
except Exception as exc: # noqa: BLE001
|
|
449
|
+
return self._caught(exc, request_id=request_id)
|
|
450
|
+
|
|
451
|
+
def handle_get(
|
|
452
|
+
self,
|
|
453
|
+
run_id: str,
|
|
454
|
+
*,
|
|
455
|
+
request_id: str | None = None,
|
|
456
|
+
) -> tuple[int, dict[str, str], dict[str, Any]]:
|
|
457
|
+
try:
|
|
458
|
+
body = self.get_run(run_id)
|
|
459
|
+
if request_id:
|
|
460
|
+
body = dict(body)
|
|
461
|
+
body["request_id"] = request_id
|
|
462
|
+
return 200, {"Cache-Control": "no-store"}, body
|
|
463
|
+
except Exception as exc: # noqa: BLE001
|
|
464
|
+
return self._caught(exc, request_id=request_id, run_id=run_id)
|
|
465
|
+
|
|
466
|
+
def handle_decide(
|
|
467
|
+
self,
|
|
468
|
+
run_id: str,
|
|
469
|
+
payload: Any,
|
|
470
|
+
*,
|
|
471
|
+
request_id: str | None = None,
|
|
472
|
+
raw_len: int | None = None,
|
|
473
|
+
) -> tuple[int, dict[str, str], dict[str, Any]]:
|
|
474
|
+
try:
|
|
475
|
+
if raw_len is not None and raw_len > self.max_body_bytes:
|
|
476
|
+
raise _http_err("request body too large", 413)
|
|
477
|
+
body = self._decide(run_id, payload)
|
|
478
|
+
if request_id:
|
|
479
|
+
body = dict(body)
|
|
480
|
+
body["request_id"] = request_id
|
|
481
|
+
return 202, {"Cache-Control": "no-store"}, body
|
|
482
|
+
except Exception as exc: # noqa: BLE001
|
|
483
|
+
return self._caught(exc, request_id=request_id, run_id=run_id)
|
|
484
|
+
|
|
485
|
+
def handle_cancel(
|
|
486
|
+
self,
|
|
487
|
+
run_id: str,
|
|
488
|
+
payload: Any,
|
|
489
|
+
*,
|
|
490
|
+
request_id: str | None = None,
|
|
491
|
+
raw_len: int | None = None,
|
|
492
|
+
) -> tuple[int, dict[str, str], dict[str, Any]]:
|
|
493
|
+
try:
|
|
494
|
+
if raw_len is not None and raw_len > self.max_body_bytes:
|
|
495
|
+
raise _http_err("request body too large", 413)
|
|
496
|
+
body = self._cancel(run_id, payload)
|
|
497
|
+
if request_id:
|
|
498
|
+
body = dict(body)
|
|
499
|
+
body["request_id"] = request_id
|
|
500
|
+
status = 200 if body.get("status") in _TERMINAL else 202
|
|
501
|
+
return status, {"Cache-Control": "no-store"}, body
|
|
502
|
+
except Exception as exc: # noqa: BLE001
|
|
503
|
+
return self._caught(exc, request_id=request_id, run_id=run_id)
|
|
504
|
+
|
|
505
|
+
def check_rate(self, client_host: str | None) -> None:
|
|
506
|
+
key = client_host or "unknown"
|
|
507
|
+
if not self._rate.allow(key):
|
|
508
|
+
raise _http_err("rate limit exceeded", 429)
|
|
509
|
+
|
|
510
|
+
def _caught(
|
|
511
|
+
self,
|
|
512
|
+
exc: BaseException,
|
|
513
|
+
*,
|
|
514
|
+
request_id: str | None = None,
|
|
515
|
+
run_id: str | None = None,
|
|
516
|
+
) -> tuple[int, dict[str, str], dict[str, Any]]:
|
|
517
|
+
if not isinstance(exc, ReadyAgentsError):
|
|
518
|
+
log.exception("unhandled run API error")
|
|
519
|
+
body: dict[str, Any] = {
|
|
520
|
+
"ok": False,
|
|
521
|
+
"error": "MCPError",
|
|
522
|
+
"message": "internal error",
|
|
523
|
+
}
|
|
524
|
+
if run_id:
|
|
525
|
+
body["run_id"] = run_id
|
|
526
|
+
if request_id:
|
|
527
|
+
body["request_id"] = request_id
|
|
528
|
+
return 500, {"Cache-Control": "no-store"}, body
|
|
529
|
+
return _envelope(exc, request_id=request_id, run_id=run_id)
|
|
530
|
+
|
|
531
|
+
def _record_payload(self, state: RunState) -> dict[str, Any]:
|
|
532
|
+
body = dict(state.to_record())
|
|
533
|
+
body["ok"] = True
|
|
534
|
+
body["links"] = _links(state.run_id)
|
|
535
|
+
return body
|
|
536
|
+
|
|
537
|
+
def _run_lock(self, run_id: str) -> threading.RLock:
|
|
538
|
+
with self._lock:
|
|
539
|
+
lock = self._run_locks.get(run_id)
|
|
540
|
+
if lock is None:
|
|
541
|
+
lock = threading.RLock()
|
|
542
|
+
self._run_locks[run_id] = lock
|
|
543
|
+
return lock
|
|
544
|
+
|
|
545
|
+
def _token_for(self, run_id: str) -> Any:
|
|
546
|
+
with self._lock:
|
|
547
|
+
token = self._tokens.get(run_id)
|
|
548
|
+
if token is None:
|
|
549
|
+
token = CancellationToken()
|
|
550
|
+
self._tokens[run_id] = token
|
|
551
|
+
return token
|
|
552
|
+
|
|
553
|
+
def _new_token(self, run_id: str) -> Any:
|
|
554
|
+
token = CancellationToken()
|
|
555
|
+
with self._lock:
|
|
556
|
+
self._tokens[run_id] = token
|
|
557
|
+
return token
|
|
558
|
+
|
|
559
|
+
def _release_idempotency(self, key: str) -> None:
|
|
560
|
+
with self._lock:
|
|
561
|
+
previous = self._idempotency.pop(key, None)
|
|
562
|
+
if previous is not None and previous[0] == "pending" and previous[2] is not None:
|
|
563
|
+
previous[2].set()
|
|
564
|
+
|
|
565
|
+
def _persist(self, state: RunState) -> None:
|
|
566
|
+
persist_run(state, self._runs_dir, redactor=self._redactor)
|
|
567
|
+
|
|
568
|
+
def _wrap_tools(self, token: Any) -> ToolRegistry | None:
|
|
569
|
+
if self._extra_tools is None:
|
|
570
|
+
return None
|
|
571
|
+
wrapped = ToolRegistry()
|
|
572
|
+
for tool in self._extra_tools.as_dict().values():
|
|
573
|
+
wrapped.register(_CancellingTool(tool, token))
|
|
574
|
+
return wrapped
|
|
575
|
+
|
|
576
|
+
def _start(
|
|
577
|
+
self,
|
|
578
|
+
payload: Any,
|
|
579
|
+
*,
|
|
580
|
+
idempotency_key: str | None,
|
|
581
|
+
actor_default: str | None = None,
|
|
582
|
+
) -> dict[str, Any]:
|
|
583
|
+
body = _require_object(payload, what="request body")
|
|
584
|
+
_check_depth(body)
|
|
585
|
+
_unknown_fields(body, _START_FIELDS, what="start request")
|
|
586
|
+
path_raw = body.get("path")
|
|
587
|
+
if not isinstance(path_raw, str) or not path_raw.strip():
|
|
588
|
+
raise HttpRequestError("path is required and must be a string")
|
|
589
|
+
inputs = body.get("inputs", {})
|
|
590
|
+
if not isinstance(inputs, dict):
|
|
591
|
+
raise HttpRequestError("inputs must be a JSON object")
|
|
592
|
+
actor = body.get("actor")
|
|
593
|
+
if actor is not None and not isinstance(actor, str):
|
|
594
|
+
raise HttpRequestError("actor must be a string")
|
|
595
|
+
if "dry_run" in body and not isinstance(body["dry_run"], bool):
|
|
596
|
+
raise HttpRequestError("dry_run must be a boolean")
|
|
597
|
+
dry_run = bool(body.get("dry_run", False))
|
|
598
|
+
if actor is None:
|
|
599
|
+
actor = actor_default if actor_default is not None else self.settings.actor
|
|
600
|
+
|
|
601
|
+
key = (idempotency_key or "").strip() or None
|
|
602
|
+
canonical = _canonical_body(body)
|
|
603
|
+
digest = _body_hash(canonical)
|
|
604
|
+
pending_event: threading.Event | None = None
|
|
605
|
+
if key:
|
|
606
|
+
while True:
|
|
607
|
+
waiter: threading.Event | None = None
|
|
608
|
+
with self._lock:
|
|
609
|
+
previous = self._idempotency.get(key)
|
|
610
|
+
if previous is None:
|
|
611
|
+
pending_event = threading.Event()
|
|
612
|
+
self._idempotency[key] = ("pending", digest, pending_event, None)
|
|
613
|
+
break
|
|
614
|
+
kind = previous[0]
|
|
615
|
+
prev_hash = previous[1]
|
|
616
|
+
if prev_hash != digest:
|
|
617
|
+
raise RunConflict(
|
|
618
|
+
"Idempotency-Key was reused with a different request body"
|
|
619
|
+
)
|
|
620
|
+
if kind == "ready":
|
|
621
|
+
handle = previous[3]
|
|
622
|
+
assert handle is not None
|
|
623
|
+
return dict(handle)
|
|
624
|
+
waiter = previous[2]
|
|
625
|
+
if waiter is None:
|
|
626
|
+
continue
|
|
627
|
+
waiter.wait(timeout=30.0)
|
|
628
|
+
|
|
629
|
+
with self._lock:
|
|
630
|
+
if self._shutdown:
|
|
631
|
+
if key:
|
|
632
|
+
self._release_idempotency(key)
|
|
633
|
+
raise ServiceUnavailable("run API is shutting down")
|
|
634
|
+
|
|
635
|
+
try:
|
|
636
|
+
try:
|
|
637
|
+
wf_path = confine_under(path_raw, self.workspace, what="workflow")
|
|
638
|
+
except ConfigError as exc:
|
|
639
|
+
raise HttpRequestError(str(exc)) from exc
|
|
640
|
+
if not wf_path.is_file():
|
|
641
|
+
raise ConfigError(f"Workflow file not found: {wf_path}")
|
|
642
|
+
|
|
643
|
+
workflow = load_workflow(wf_path)
|
|
644
|
+
merged = merge_inputs(workflow, inputs)
|
|
645
|
+
self._authorizer.check(actor, "run", workflow.name)
|
|
646
|
+
|
|
647
|
+
declared = (workflow.workspace or "").strip()
|
|
648
|
+
try:
|
|
649
|
+
run_workspace = (
|
|
650
|
+
confine_under(declared, self.workspace, what="workspace")
|
|
651
|
+
if declared
|
|
652
|
+
else self.workspace
|
|
653
|
+
)
|
|
654
|
+
except ConfigError as exc:
|
|
655
|
+
raise HttpRequestError(str(exc)) from exc
|
|
656
|
+
allow_http = bool(workflow.allow_http or self.settings.allow_http)
|
|
657
|
+
|
|
658
|
+
with self._lock:
|
|
659
|
+
if self._shutdown:
|
|
660
|
+
raise ServiceUnavailable("run API is shutting down")
|
|
661
|
+
if self._queued + self._running >= self.max_pending_runs:
|
|
662
|
+
raise QueueOverflow("too many pending runs")
|
|
663
|
+
self._queued += 1
|
|
664
|
+
except Exception:
|
|
665
|
+
if key:
|
|
666
|
+
self._release_idempotency(key)
|
|
667
|
+
raise
|
|
668
|
+
|
|
669
|
+
run_id = uuid4().hex
|
|
670
|
+
state = RunState.start(
|
|
671
|
+
workflow.name,
|
|
672
|
+
merged,
|
|
673
|
+
metadata={
|
|
674
|
+
"source": str(wf_path),
|
|
675
|
+
"workspace": str(run_workspace),
|
|
676
|
+
"actor": actor,
|
|
677
|
+
"dry_run": dry_run,
|
|
678
|
+
"allow_http": allow_http,
|
|
679
|
+
"submitted_via": "http",
|
|
680
|
+
},
|
|
681
|
+
run_id=run_id,
|
|
682
|
+
)
|
|
683
|
+
state.status = "queued"
|
|
684
|
+
token = self._new_token(run_id)
|
|
685
|
+
try:
|
|
686
|
+
self._persist(state)
|
|
687
|
+
except Exception:
|
|
688
|
+
with self._lock:
|
|
689
|
+
self._queued = max(0, self._queued - 1)
|
|
690
|
+
if key:
|
|
691
|
+
self._release_idempotency(key)
|
|
692
|
+
raise
|
|
693
|
+
|
|
694
|
+
handle = {
|
|
695
|
+
"ok": True,
|
|
696
|
+
"run_id": run_id,
|
|
697
|
+
"status": "queued",
|
|
698
|
+
"links": _links(run_id),
|
|
699
|
+
}
|
|
700
|
+
if key:
|
|
701
|
+
with self._lock:
|
|
702
|
+
self._idempotency[key] = ("ready", digest, None, dict(handle))
|
|
703
|
+
if pending_event is not None:
|
|
704
|
+
pending_event.set()
|
|
705
|
+
with self._lock:
|
|
706
|
+
self._active.add(run_id)
|
|
707
|
+
try:
|
|
708
|
+
self._executor.submit(
|
|
709
|
+
self._run_job,
|
|
710
|
+
run_id,
|
|
711
|
+
wf_path,
|
|
712
|
+
merged,
|
|
713
|
+
dry_run,
|
|
714
|
+
actor,
|
|
715
|
+
token,
|
|
716
|
+
)
|
|
717
|
+
except Exception:
|
|
718
|
+
with self._lock:
|
|
719
|
+
self._queued = max(0, self._queued - 1)
|
|
720
|
+
self._active.discard(run_id)
|
|
721
|
+
if key:
|
|
722
|
+
self._release_idempotency(key)
|
|
723
|
+
state.status = "failed"
|
|
724
|
+
state.errors.append("failed to submit run")
|
|
725
|
+
state.finish("failed")
|
|
726
|
+
self._persist(state)
|
|
727
|
+
raise
|
|
728
|
+
return handle
|
|
729
|
+
|
|
730
|
+
def _run_job(
|
|
731
|
+
self,
|
|
732
|
+
run_id: str,
|
|
733
|
+
path: Path,
|
|
734
|
+
inputs: dict[str, Any],
|
|
735
|
+
dry_run: bool,
|
|
736
|
+
actor: str | None,
|
|
737
|
+
token: Any,
|
|
738
|
+
) -> None:
|
|
739
|
+
with self._lock:
|
|
740
|
+
self._queued = max(0, self._queued - 1)
|
|
741
|
+
self._running += 1
|
|
742
|
+
paused = False
|
|
743
|
+
try:
|
|
744
|
+
if token.is_requested():
|
|
745
|
+
self._finish_cancelled(run_id)
|
|
746
|
+
return
|
|
747
|
+
try:
|
|
748
|
+
state = load_run_exact(self._runs_dir, run_id)
|
|
749
|
+
except ReadyAgentsError:
|
|
750
|
+
return
|
|
751
|
+
if state.status in _TERMINAL:
|
|
752
|
+
return
|
|
753
|
+
if state.status == "cancel_requested":
|
|
754
|
+
self._finish_cancelled(run_id)
|
|
755
|
+
return
|
|
756
|
+
wrapped = self._wrap_tools(token)
|
|
757
|
+
_call_supported(
|
|
758
|
+
run_workflow_file,
|
|
759
|
+
path,
|
|
760
|
+
inputs=inputs,
|
|
761
|
+
dry_run=dry_run,
|
|
762
|
+
settings=self.settings,
|
|
763
|
+
persist=True,
|
|
764
|
+
extra_tools=wrapped,
|
|
765
|
+
extra_packs=self._extra_packs or None,
|
|
766
|
+
actor=actor,
|
|
767
|
+
authorizer=self._authorizer,
|
|
768
|
+
cancellation=token,
|
|
769
|
+
initial_state=state,
|
|
770
|
+
)
|
|
771
|
+
except ApprovalRequired:
|
|
772
|
+
paused = True
|
|
773
|
+
except CancellationRequested:
|
|
774
|
+
self._finish_cancelled(run_id)
|
|
775
|
+
except ReadyAgentsError:
|
|
776
|
+
if token.is_requested():
|
|
777
|
+
self._finish_cancelled(run_id)
|
|
778
|
+
except Exception:
|
|
779
|
+
log.exception("run %s worker crashed", run_id)
|
|
780
|
+
if token.is_requested():
|
|
781
|
+
self._finish_cancelled(run_id)
|
|
782
|
+
else:
|
|
783
|
+
self._mark_failed(run_id, "internal error")
|
|
784
|
+
else:
|
|
785
|
+
if token.is_requested() and not paused:
|
|
786
|
+
self._finish_cancelled(run_id)
|
|
787
|
+
finally:
|
|
788
|
+
with self._lock:
|
|
789
|
+
self._running = max(0, self._running - 1)
|
|
790
|
+
self._active.discard(run_id)
|
|
791
|
+
|
|
792
|
+
def _decide(self, run_id: str, payload: Any) -> dict[str, Any]:
|
|
793
|
+
if self._shutdown:
|
|
794
|
+
raise ServiceUnavailable("run API is shutting down")
|
|
795
|
+
load_run_exact(self._runs_dir, run_id)
|
|
796
|
+
body = _require_object(payload, what="request body")
|
|
797
|
+
_check_depth(body)
|
|
798
|
+
_unknown_fields(body, _DECIDE_FIELDS, what="decide request")
|
|
799
|
+
node_id = body.get("node_id")
|
|
800
|
+
decision = body.get("decision")
|
|
801
|
+
if not isinstance(node_id, str) or not node_id.strip():
|
|
802
|
+
raise HttpRequestError("node_id is required and must be a string")
|
|
803
|
+
if decision not in {_APPROVE, _REJECT}:
|
|
804
|
+
raise HttpRequestError('decision must be exactly "approve" or "reject"')
|
|
805
|
+
actor = body.get("actor")
|
|
806
|
+
if actor is not None and not isinstance(actor, str):
|
|
807
|
+
raise HttpRequestError("actor must be a string")
|
|
808
|
+
actor = actor if actor is not None else self.settings.actor
|
|
809
|
+
node_id = node_id.strip()
|
|
810
|
+
|
|
811
|
+
with self._run_lock(run_id):
|
|
812
|
+
state = load_run_exact(self._runs_dir, run_id)
|
|
813
|
+
if state.status != "paused" or state.pending_node != node_id:
|
|
814
|
+
raise RunConflict(
|
|
815
|
+
f"Run {run_id} is not paused at node '{node_id}' "
|
|
816
|
+
f"(status={state.status}, pending_node={state.pending_node})"
|
|
817
|
+
)
|
|
818
|
+
with self._lock:
|
|
819
|
+
if run_id in self._in_flight_resume:
|
|
820
|
+
raise RunConflict(f"Run {run_id} already has a resume in flight")
|
|
821
|
+
self._in_flight_resume.add(run_id)
|
|
822
|
+
try:
|
|
823
|
+
self._authorizer.check(actor, "resume", run_id)
|
|
824
|
+
self._authorizer.check(actor, decision, node_id)
|
|
825
|
+
except Exception:
|
|
826
|
+
with self._lock:
|
|
827
|
+
self._in_flight_resume.discard(run_id)
|
|
828
|
+
raise
|
|
829
|
+
token = self._new_token(run_id)
|
|
830
|
+
with self._lock:
|
|
831
|
+
self._active.add(run_id)
|
|
832
|
+
try:
|
|
833
|
+
self._executor.submit(
|
|
834
|
+
self._resume_job,
|
|
835
|
+
run_id,
|
|
836
|
+
{node_id: decision},
|
|
837
|
+
actor,
|
|
838
|
+
token,
|
|
839
|
+
)
|
|
840
|
+
except Exception:
|
|
841
|
+
with self._lock:
|
|
842
|
+
self._in_flight_resume.discard(run_id)
|
|
843
|
+
self._active.discard(run_id)
|
|
844
|
+
raise
|
|
845
|
+
return {
|
|
846
|
+
"ok": True,
|
|
847
|
+
"run_id": run_id,
|
|
848
|
+
"status": "running",
|
|
849
|
+
"links": _links(run_id),
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
def _resume_job(
|
|
853
|
+
self,
|
|
854
|
+
run_id: str,
|
|
855
|
+
decisions: dict[str, str],
|
|
856
|
+
actor: str | None,
|
|
857
|
+
token: Any,
|
|
858
|
+
) -> None:
|
|
859
|
+
paused = False
|
|
860
|
+
try:
|
|
861
|
+
if token.is_requested():
|
|
862
|
+
self._finish_cancelled(run_id)
|
|
863
|
+
return
|
|
864
|
+
wrapped = self._wrap_tools(token)
|
|
865
|
+
_call_supported(
|
|
866
|
+
resume_run,
|
|
867
|
+
run_id,
|
|
868
|
+
settings=self.settings,
|
|
869
|
+
persist=True,
|
|
870
|
+
extra_tools=wrapped,
|
|
871
|
+
extra_packs=self._extra_packs or None,
|
|
872
|
+
decisions=decisions,
|
|
873
|
+
actor=actor,
|
|
874
|
+
authorizer=self._authorizer,
|
|
875
|
+
cancellation=token,
|
|
876
|
+
)
|
|
877
|
+
except ApprovalRequired:
|
|
878
|
+
paused = True
|
|
879
|
+
except CancellationRequested:
|
|
880
|
+
self._finish_cancelled(run_id)
|
|
881
|
+
except ReadyAgentsError:
|
|
882
|
+
if token.is_requested():
|
|
883
|
+
self._finish_cancelled(run_id)
|
|
884
|
+
except Exception:
|
|
885
|
+
log.exception("resume %s worker crashed", run_id)
|
|
886
|
+
if token.is_requested():
|
|
887
|
+
self._finish_cancelled(run_id)
|
|
888
|
+
else:
|
|
889
|
+
if token.is_requested() and not paused:
|
|
890
|
+
self._finish_cancelled(run_id)
|
|
891
|
+
finally:
|
|
892
|
+
with self._lock:
|
|
893
|
+
self._in_flight_resume.discard(run_id)
|
|
894
|
+
self._active.discard(run_id)
|
|
895
|
+
|
|
896
|
+
def _cancel(self, run_id: str, payload: Any) -> dict[str, Any]:
|
|
897
|
+
load_run_exact(self._runs_dir, run_id)
|
|
898
|
+
if payload is None:
|
|
899
|
+
body: dict[str, Any] = {}
|
|
900
|
+
else:
|
|
901
|
+
body = _require_object(payload, what="request body")
|
|
902
|
+
_check_depth(body)
|
|
903
|
+
_unknown_fields(body, _CANCEL_FIELDS, what="cancel request")
|
|
904
|
+
actor = body.get("actor")
|
|
905
|
+
if actor is not None and not isinstance(actor, str):
|
|
906
|
+
raise HttpRequestError("actor must be a string")
|
|
907
|
+
reason = body.get("reason")
|
|
908
|
+
if reason is not None:
|
|
909
|
+
if not isinstance(reason, str):
|
|
910
|
+
raise HttpRequestError("reason must be a string")
|
|
911
|
+
if len(reason) > _MAX_REASON_CHARS:
|
|
912
|
+
raise HttpRequestError(f"reason must be at most {_MAX_REASON_CHARS} characters")
|
|
913
|
+
if self._redactor is not None:
|
|
914
|
+
reason = self._redactor.redact_text(reason)
|
|
915
|
+
actor = actor if actor is not None else self.settings.actor
|
|
916
|
+
|
|
917
|
+
with self._run_lock(run_id):
|
|
918
|
+
state = load_run_exact(self._runs_dir, run_id)
|
|
919
|
+
if state.status in _TERMINAL:
|
|
920
|
+
return self._record_payload(state)
|
|
921
|
+
token = self._token_for(run_id)
|
|
922
|
+
token.request(actor=actor, reason=reason)
|
|
923
|
+
state.status = "cancel_requested"
|
|
924
|
+
self._persist(state)
|
|
925
|
+
with self._lock:
|
|
926
|
+
active = run_id in self._active
|
|
927
|
+
if not active:
|
|
928
|
+
self._finish_cancelled(run_id)
|
|
929
|
+
return self._record_payload(load_run_exact(self._runs_dir, run_id))
|
|
930
|
+
snapshot = self._record_payload(state)
|
|
931
|
+
snapshot["status"] = "cancel_requested"
|
|
932
|
+
return snapshot
|
|
933
|
+
|
|
934
|
+
def _finish_cancelled(self, run_id: str) -> None:
|
|
935
|
+
with self._run_lock(run_id):
|
|
936
|
+
try:
|
|
937
|
+
state = load_run_exact(self._runs_dir, run_id)
|
|
938
|
+
except ReadyAgentsError:
|
|
939
|
+
return
|
|
940
|
+
if state.status in {"succeeded", "cancelled"}:
|
|
941
|
+
return
|
|
942
|
+
state.finish("cancelled")
|
|
943
|
+
try:
|
|
944
|
+
self._persist(state)
|
|
945
|
+
except Exception: # noqa: BLE001
|
|
946
|
+
log.exception("failed to persist cancelled run %s", run_id)
|
|
947
|
+
|
|
948
|
+
def _mark_failed(self, run_id: str, message: str) -> None:
|
|
949
|
+
with self._run_lock(run_id):
|
|
950
|
+
try:
|
|
951
|
+
state = load_run_exact(self._runs_dir, run_id)
|
|
952
|
+
except ReadyAgentsError:
|
|
953
|
+
return
|
|
954
|
+
if state.status in _TERMINAL:
|
|
955
|
+
return
|
|
956
|
+
state.errors.append(message)
|
|
957
|
+
state.finish("failed")
|
|
958
|
+
try:
|
|
959
|
+
self._persist(state)
|
|
960
|
+
except Exception: # noqa: BLE001
|
|
961
|
+
log.exception("failed to persist failed run %s", run_id)
|
|
962
|
+
|
|
963
|
+
|
|
964
|
+
def build_run_routes(coordinator: RunCoordinator) -> list[Any]:
|
|
965
|
+
"""Starlette routes for POST/GET /runs and decide/cancel."""
|
|
966
|
+
from starlette.requests import Request
|
|
967
|
+
from starlette.responses import JSONResponse
|
|
968
|
+
from starlette.routing import Route
|
|
969
|
+
|
|
970
|
+
async def _read_json(request: Request) -> tuple[Any, int]:
|
|
971
|
+
raw = await request.body()
|
|
972
|
+
if len(raw) > coordinator.max_body_bytes:
|
|
973
|
+
raise _http_err("request body too large", 413)
|
|
974
|
+
if not raw:
|
|
975
|
+
return {}, len(raw)
|
|
976
|
+
try:
|
|
977
|
+
parsed = json.loads(raw)
|
|
978
|
+
except json.JSONDecodeError as exc:
|
|
979
|
+
raise HttpRequestError(f"Malformed JSON: {exc}") from exc
|
|
980
|
+
return parsed, len(raw)
|
|
981
|
+
|
|
982
|
+
def _request_id(request: Request) -> str | None:
|
|
983
|
+
return request.headers.get("x-request-id")
|
|
984
|
+
|
|
985
|
+
def _host(request: Request) -> str | None:
|
|
986
|
+
client = request.client
|
|
987
|
+
return client.host if client is not None else None
|
|
988
|
+
|
|
989
|
+
def _respond(status: int, headers: dict[str, str], body: dict[str, Any]) -> JSONResponse:
|
|
990
|
+
return JSONResponse(body, status_code=status, headers=headers)
|
|
991
|
+
|
|
992
|
+
async def post_runs(request: Request) -> JSONResponse:
|
|
993
|
+
request_id = _request_id(request)
|
|
994
|
+
try:
|
|
995
|
+
coordinator.check_rate(_host(request))
|
|
996
|
+
if not _is_json_content_type(request.headers.get("content-type")):
|
|
997
|
+
raise _http_err("Content-Type must be application/json", 415)
|
|
998
|
+
payload, raw_len = await _read_json(request)
|
|
999
|
+
key = request.headers.get("idempotency-key")
|
|
1000
|
+
return _respond(
|
|
1001
|
+
*coordinator.handle_start(
|
|
1002
|
+
payload,
|
|
1003
|
+
idempotency_key=key,
|
|
1004
|
+
request_id=request_id,
|
|
1005
|
+
raw_len=raw_len,
|
|
1006
|
+
)
|
|
1007
|
+
)
|
|
1008
|
+
except ReadyAgentsError as exc:
|
|
1009
|
+
return _respond(*coordinator._caught(exc, request_id=request_id))
|
|
1010
|
+
|
|
1011
|
+
async def get_run(request: Request) -> JSONResponse:
|
|
1012
|
+
request_id = _request_id(request)
|
|
1013
|
+
run_id = request.path_params.get("run_id", "")
|
|
1014
|
+
try:
|
|
1015
|
+
coordinator.check_rate(_host(request))
|
|
1016
|
+
return _respond(*coordinator.handle_get(run_id, request_id=request_id))
|
|
1017
|
+
except ReadyAgentsError as exc:
|
|
1018
|
+
return _respond(*coordinator._caught(exc, request_id=request_id, run_id=run_id))
|
|
1019
|
+
|
|
1020
|
+
async def post_decide(request: Request) -> JSONResponse:
|
|
1021
|
+
request_id = _request_id(request)
|
|
1022
|
+
run_id = request.path_params.get("run_id", "")
|
|
1023
|
+
try:
|
|
1024
|
+
coordinator.check_rate(_host(request))
|
|
1025
|
+
if not _is_json_content_type(request.headers.get("content-type")):
|
|
1026
|
+
raise _http_err("Content-Type must be application/json", 415)
|
|
1027
|
+
payload, raw_len = await _read_json(request)
|
|
1028
|
+
return _respond(
|
|
1029
|
+
*coordinator.handle_decide(
|
|
1030
|
+
run_id,
|
|
1031
|
+
payload,
|
|
1032
|
+
request_id=request_id,
|
|
1033
|
+
raw_len=raw_len,
|
|
1034
|
+
)
|
|
1035
|
+
)
|
|
1036
|
+
except ReadyAgentsError as exc:
|
|
1037
|
+
return _respond(*coordinator._caught(exc, request_id=request_id, run_id=run_id))
|
|
1038
|
+
|
|
1039
|
+
async def post_cancel(request: Request) -> JSONResponse:
|
|
1040
|
+
request_id = _request_id(request)
|
|
1041
|
+
run_id = request.path_params.get("run_id", "")
|
|
1042
|
+
try:
|
|
1043
|
+
coordinator.check_rate(_host(request))
|
|
1044
|
+
if not _is_json_content_type(request.headers.get("content-type")):
|
|
1045
|
+
raise _http_err("Content-Type must be application/json", 415)
|
|
1046
|
+
payload, raw_len = await _read_json(request)
|
|
1047
|
+
return _respond(
|
|
1048
|
+
*coordinator.handle_cancel(
|
|
1049
|
+
run_id,
|
|
1050
|
+
payload,
|
|
1051
|
+
request_id=request_id,
|
|
1052
|
+
raw_len=raw_len,
|
|
1053
|
+
)
|
|
1054
|
+
)
|
|
1055
|
+
except ReadyAgentsError as exc:
|
|
1056
|
+
return _respond(*coordinator._caught(exc, request_id=request_id, run_id=run_id))
|
|
1057
|
+
|
|
1058
|
+
return [
|
|
1059
|
+
Route("/runs", post_runs, methods=["POST"]),
|
|
1060
|
+
Route("/runs/{run_id}", get_run, methods=["GET"]),
|
|
1061
|
+
Route("/runs/{run_id}/decide", post_decide, methods=["POST"]),
|
|
1062
|
+
Route("/runs/{run_id}/cancel", post_cancel, methods=["POST"]),
|
|
1063
|
+
]
|
|
1064
|
+
|
|
1065
|
+
|
|
1066
|
+
__all__ = [
|
|
1067
|
+
"CancellationRequested",
|
|
1068
|
+
"CancellationToken",
|
|
1069
|
+
"HttpAuthError",
|
|
1070
|
+
"HttpRequestError",
|
|
1071
|
+
"QueueOverflow",
|
|
1072
|
+
"RunConflict",
|
|
1073
|
+
"RunCoordinator",
|
|
1074
|
+
"ServiceUnavailable",
|
|
1075
|
+
"build_run_routes",
|
|
1076
|
+
"load_run_exact",
|
|
1077
|
+
]
|