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.
Files changed (51) hide show
  1. readyagents/__init__.py +38 -0
  2. readyagents/__main__.py +4 -0
  3. readyagents/audit.py +67 -0
  4. readyagents/cli.py +1050 -0
  5. readyagents/config.py +264 -0
  6. readyagents/errors.py +129 -0
  7. readyagents/llm/__init__.py +11 -0
  8. readyagents/llm/anthropic_provider.py +72 -0
  9. readyagents/llm/base.py +57 -0
  10. readyagents/llm/cache.py +86 -0
  11. readyagents/llm/openai_compat.py +12 -0
  12. readyagents/llm/openai_provider.py +70 -0
  13. readyagents/llm/registry.py +112 -0
  14. readyagents/llm/resilience.py +179 -0
  15. readyagents/llm/tool_calls.py +286 -0
  16. readyagents/logging.py +162 -0
  17. readyagents/mcp/__init__.py +43 -0
  18. readyagents/mcp/builtin.py +674 -0
  19. readyagents/mcp/client.py +253 -0
  20. readyagents/mcp/http.py +585 -0
  21. readyagents/mcp/run_api.py +1077 -0
  22. readyagents/mcp/server.py +246 -0
  23. readyagents/notify.py +63 -0
  24. readyagents/packs/__init__.py +26 -0
  25. readyagents/packs/loader.py +157 -0
  26. readyagents/packs/protocol.py +55 -0
  27. readyagents/policy.py +127 -0
  28. readyagents/py.typed +1 -0
  29. readyagents/report.py +88 -0
  30. readyagents/scaffold.py +410 -0
  31. readyagents/secrets.py +120 -0
  32. readyagents/testing/__init__.py +17 -0
  33. readyagents/testing/eval.py +219 -0
  34. readyagents/testing/helpers.py +128 -0
  35. readyagents/testing/recorded.py +68 -0
  36. readyagents/tools/__init__.py +67 -0
  37. readyagents/workflow/__init__.py +3 -0
  38. readyagents/workflow/cancellation.py +88 -0
  39. readyagents/workflow/conditions.py +279 -0
  40. readyagents/workflow/engine.py +354 -0
  41. readyagents/workflow/nodes.py +944 -0
  42. readyagents/workflow/runner.py +375 -0
  43. readyagents/workflow/schema.py +287 -0
  44. readyagents/workflow/state.py +472 -0
  45. readyagents/workflow/structured.py +103 -0
  46. readyagents/workflow/templates.py +125 -0
  47. readyagentsdev-0.8.2.dist-info/METADATA +215 -0
  48. readyagentsdev-0.8.2.dist-info/RECORD +51 -0
  49. readyagentsdev-0.8.2.dist-info/WHEEL +4 -0
  50. readyagentsdev-0.8.2.dist-info/entry_points.txt +2 -0
  51. 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
+ ]