adagio-server 0.1.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.
@@ -0,0 +1,8 @@
1
+ """Adagio's outbound self-hosted runtime server."""
2
+
3
+ from importlib.metadata import PackageNotFoundError, version
4
+
5
+ try:
6
+ __version__ = version("adagio-server")
7
+ except PackageNotFoundError: # Source checkout without an installed distribution.
8
+ __version__ = "unknown"
@@ -0,0 +1 @@
1
+ """Outbound pull-agent core (register / claim / lease / run / report)."""
@@ -0,0 +1,118 @@
1
+ """QIIME ``.qza``/``.qzv`` artifact introspection.
2
+
3
+ This is the **only** domain-aware code in the agent (design §5). It is kept
4
+ verbatim in behavior from the legacy ``jobs.py`` so that when the agent relays a
5
+ CLI ``output_saved`` event to action as an :class:`ArtifactReport`, it can fill in
6
+ ``artifact_type`` (the QIIME semantic type) and ``provenance`` (the recorded
7
+ action graph). All parsing is best-effort and degrades gracefully - a file that
8
+ is not a QIIME archive simply yields ``(None, None)``.
9
+
10
+ Everything else in the agent treats artifacts as opaque URIs.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import zipfile
16
+ from pathlib import Path
17
+ from typing import Any
18
+
19
+ from fastapi.encoders import jsonable_encoder
20
+
21
+ try:
22
+ import yaml
23
+ except Exception: # noqa: BLE001 - yaml is optional; degrade to no introspection
24
+ yaml = None # type: ignore[assignment]
25
+
26
+ #: QIIME repr tags that a plain YAML loader cannot resolve.
27
+ _Q2_REPRS = ("!ref", "!no-provenance", "!metadata", "!color", "!cite")
28
+
29
+
30
+ def read_qiime_archive(file_path: Path) -> tuple[str | None, dict[str, Any] | None]:
31
+ """Return ``(artifact_type, provenance)`` for a QIIME archive, else ``(None, None)``.
32
+
33
+ ``artifact_type`` comes from ``*/metadata.yaml``'s ``type`` field; ``provenance``
34
+ from ``*/provenance/action/action.yaml``. Non-archives and parse failures return
35
+ ``(None, None)`` / partial results rather than raising.
36
+ """
37
+ if not zipfile.is_zipfile(file_path):
38
+ return None, None
39
+
40
+ try:
41
+ with zipfile.ZipFile(file_path, "r") as zf:
42
+ metadata = _read_yaml_member(zf, ["/metadata.yaml"])
43
+ provenance = _read_yaml_member(
44
+ zf,
45
+ ["/provenance/action/action.yaml", "/action/action.yaml"],
46
+ )
47
+ except Exception: # noqa: BLE001
48
+ return None, None
49
+
50
+ archive_type: str | None = None
51
+ if isinstance(metadata, dict):
52
+ type_name = metadata.get("type")
53
+ if isinstance(type_name, str):
54
+ archive_type = type_name
55
+
56
+ if isinstance(provenance, dict):
57
+ # YAML timestamps become date/datetime objects. Normalize before the
58
+ # telemetry size validator calls json.dumps, not just at HTTP upload.
59
+ return archive_type, jsonable_encoder(provenance)
60
+ return archive_type, None
61
+
62
+
63
+ def _read_yaml_member(
64
+ zf: zipfile.ZipFile, suffixes: list[str]
65
+ ) -> dict[str, Any] | None:
66
+ names = zf.namelist()
67
+ for suffix in suffixes:
68
+ match = next((name for name in names if name.endswith(suffix)), None)
69
+ if match is None:
70
+ continue
71
+ text = zf.read(match).decode("utf-8", errors="replace")
72
+ parsed = yaml_load_with_qiime_tags(text)
73
+ if isinstance(parsed, dict):
74
+ return parsed
75
+ return None
76
+
77
+
78
+ def yaml_load_with_qiime_tags(text: str) -> Any:
79
+ """Load YAML tolerating QIIME repr tags; degrade progressively on failure."""
80
+ if yaml is None:
81
+ return None
82
+
83
+ class _Loader(yaml.SafeLoader):
84
+ pass
85
+
86
+ def _unknown_tag(loader: yaml.SafeLoader, node: yaml.Node) -> Any:
87
+ if isinstance(node, yaml.ScalarNode):
88
+ return loader.construct_scalar(node)
89
+ if isinstance(node, yaml.SequenceNode):
90
+ return loader.construct_sequence(node)
91
+ if isinstance(node, yaml.MappingNode):
92
+ return loader.construct_mapping(node)
93
+ return None
94
+
95
+ _Loader.add_constructor(None, _unknown_tag)
96
+ try:
97
+ return yaml.load(text, Loader=_Loader)
98
+ except Exception: # noqa: BLE001
99
+ pass
100
+
101
+ stripped = text
102
+ for qrepr in _Q2_REPRS:
103
+ stripped = stripped.replace(qrepr, "")
104
+
105
+ try:
106
+ return yaml.safe_load(stripped)
107
+ except Exception: # noqa: BLE001
108
+ pass
109
+
110
+ try:
111
+ return yaml.load(stripped, Loader=yaml.BaseLoader)
112
+ except Exception: # noqa: BLE001
113
+ return {
114
+ "_parse_warning": (
115
+ "Unable to fully parse QIIME provenance YAML. "
116
+ "Some provenance details were omitted."
117
+ ),
118
+ }
@@ -0,0 +1,305 @@
1
+ """Typed HTTP client for the ``action`` runner endpoints (design §6).
2
+
3
+ Every call carries ``Authorization: Bearer {AGENT_TOKEN}`` and is typed with the
4
+ vendored contract models. The agent authenticates **outward** only; nothing here
5
+ ever listens inbound.
6
+
7
+ Transport: ``httpx`` (sync ``Client``, driven from ``asyncio.to_thread`` in the
8
+ supervisor so the event loop is never blocked). Bounded retries with backoff make
9
+ the client robust to transient network failures; the long-poll ``claim`` uses a
10
+ longer read timeout and treats 204 as "no work".
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import logging
16
+ import time
17
+ from typing import Any
18
+ from uuid import UUID
19
+
20
+ import httpx
21
+
22
+ from adagio_server.contracts.agent import (
23
+ AgentClaimRequest,
24
+ AgentEventBatch,
25
+ AgentInfo,
26
+ AgentRegister,
27
+ ArtifactReport,
28
+ ClaimedJob,
29
+ JobStatusReport,
30
+ LeaseRenew,
31
+ LeaseState,
32
+ LogBatch,
33
+ LogBatchAccepted,
34
+ NodeStatusReport,
35
+ RunnerEnrollRequest,
36
+ RunnerEnrollResponse,
37
+ )
38
+
39
+ logger = logging.getLogger("adagio.agent.client")
40
+
41
+ #: HTTP statuses worth retrying (transient / server-side).
42
+ _RETRYABLE_STATUS = frozenset({408, 425, 429, 500, 502, 503, 504})
43
+
44
+
45
+ class ActionClientError(RuntimeError):
46
+ """Raised when a call to action fails after exhausting retries."""
47
+
48
+
49
+ class ActionAuthError(ActionClientError):
50
+ """Raised immediately when action rejects the agent token (401/403).
51
+
52
+ Auth rejection is terminal for the sidecar: retrying can never succeed
53
+ until a NEW token is minted, and only the desktop shell can trigger that.
54
+ The supervisor loop propagates this and the process exits with
55
+ :data:`AGENT_AUTH_EXIT_CODE` so the shell can tell "re-mint the token"
56
+ apart from a crash (design §7).
57
+ """
58
+
59
+
60
+ class ActionConflictError(ActionClientError):
61
+ """Raised when Action reports that this agent no longer owns a job."""
62
+
63
+
64
+ class ActionCompatibilityError(ActionClientError):
65
+ """Registration requires a runner update, not a credential refresh."""
66
+
67
+
68
+ AGENT_COMPATIBILITY_EXIT_CODE = 42
69
+
70
+
71
+ #: Process exit code meaning "action rejected the agent token". This is a
72
+ #: contract with the desktop shell supervisor - mirrored as
73
+ #: AGENT_AUTH_EXIT_CODE in adagio-desktop/src/agent-supervisor.ts, which
74
+ #: reacts by discarding the token and re-entering 'awaiting agent token'
75
+ #: instead of restart-with-backoff.
76
+ AGENT_AUTH_EXIT_CODE = 41
77
+
78
+
79
+ class ActionClient:
80
+ """Thin, typed, retrying client over the ``action`` agent API."""
81
+
82
+ def __init__(
83
+ self,
84
+ *,
85
+ base_url: str,
86
+ token: str | None,
87
+ verify_tls: bool = True,
88
+ timeout: float = 15.0,
89
+ long_poll_timeout: float = 40.0,
90
+ max_retries: int = 4,
91
+ backoff_base: float = 0.5,
92
+ claim_wait: int | None = None,
93
+ ) -> None:
94
+ self._base_url = base_url.rstrip("/")
95
+ self._token = token
96
+ self._max_retries = max_retries
97
+ self._backoff_base = backoff_base
98
+ self._long_poll_timeout = long_poll_timeout
99
+ self._claim_wait = claim_wait
100
+ headers = {"Content-Type": "application/json"}
101
+ if token:
102
+ headers["Authorization"] = f"Bearer {token}"
103
+ self._client = httpx.Client(
104
+ headers=headers,
105
+ verify=verify_tls,
106
+ timeout=timeout,
107
+ )
108
+
109
+ @property
110
+ def claim_wait(self) -> int | None:
111
+ """Server-side hold (seconds) requested on ``claim`` (``?wait=N``)."""
112
+ return self._claim_wait
113
+
114
+ @claim_wait.setter
115
+ def claim_wait(self, value: int | None) -> None:
116
+ self._claim_wait = value
117
+
118
+ def close(self) -> None:
119
+ """Close the underlying connection pool."""
120
+ self._client.close()
121
+
122
+ def __enter__(self) -> ActionClient:
123
+ return self
124
+
125
+ def __exit__(self, *_exc: object) -> None:
126
+ self.close()
127
+
128
+ # -- internal request helper --------------------------------------------------
129
+ def _request(
130
+ self,
131
+ method: str,
132
+ path: str,
133
+ *,
134
+ json: Any | None = None,
135
+ params: dict[str, Any] | None = None,
136
+ timeout: float | None = None,
137
+ allow_204: bool = False,
138
+ ) -> httpx.Response:
139
+ url = f"{self._base_url}{path}"
140
+ last_exc: Exception | None = None
141
+ for attempt in range(self._max_retries):
142
+ try:
143
+ response = self._client.request(
144
+ method, url, json=json, params=params, timeout=timeout
145
+ )
146
+ except httpx.HTTPError as exc: # network / timeout
147
+ last_exc = exc
148
+ logger.warning(
149
+ "action call %s %s failed (attempt %d/%d): %r",
150
+ method,
151
+ path,
152
+ attempt + 1,
153
+ self._max_retries,
154
+ exc,
155
+ )
156
+ self._sleep_backoff(attempt)
157
+ continue
158
+
159
+ if response.status_code in _RETRYABLE_STATUS:
160
+ last_exc = ActionClientError(
161
+ f"{method} {path} -> {response.status_code}"
162
+ )
163
+ logger.warning(
164
+ "action call %s %s returned %d (attempt %d/%d)",
165
+ method,
166
+ path,
167
+ response.status_code,
168
+ attempt + 1,
169
+ self._max_retries,
170
+ )
171
+ self._sleep_backoff(attempt)
172
+ continue
173
+
174
+ if response.status_code in (401, 403):
175
+ raise ActionAuthError(
176
+ f"{method} {path} -> {response.status_code}: agent token rejected"
177
+ )
178
+ if response.status_code == 409:
179
+ if path == "/agent/register":
180
+ raise ActionCompatibilityError(
181
+ "Runtime server must be updated to match this Adagio deployment"
182
+ )
183
+ raise ActionConflictError(
184
+ f"{method} {path} -> 409: job ownership lost"
185
+ )
186
+ if response.status_code >= 400:
187
+ raise ActionClientError(
188
+ f"{method} {path} -> {response.status_code}: {response.text[:500]}"
189
+ )
190
+ return response
191
+
192
+ raise ActionClientError(
193
+ f"{method} {path} failed after {self._max_retries} attempts"
194
+ ) from last_exc
195
+
196
+ def _sleep_backoff(self, attempt: int) -> None:
197
+ time.sleep(self._backoff_base * (2**attempt))
198
+
199
+ # -- lifecycle & work pull ----------------------------------------------------
200
+ def enroll(self, request: RunnerEnrollRequest) -> RunnerEnrollResponse:
201
+ """Exchange a one-time enrollment token for the persistent credential."""
202
+ resp = self._request(
203
+ "POST",
204
+ "/agent/enroll",
205
+ json=request.model_dump(mode="json"),
206
+ )
207
+ return RunnerEnrollResponse.model_validate(resp.json())
208
+
209
+ def register(self, register: AgentRegister) -> AgentInfo:
210
+ """POST /agent/register -> target-scoped runner identity and timing."""
211
+ resp = self._request(
212
+ "POST", "/agent/register", json=register.model_dump(mode="json")
213
+ )
214
+ return AgentInfo.model_validate(resp.json())
215
+
216
+ def claim(self, claim: AgentClaimRequest) -> ClaimedJob | None:
217
+ """POST /agent/claim (long-poll). Returns a job or ``None`` on 204.
218
+
219
+ When ``claim_wait`` is set (the supervisor copies action's
220
+ ``claim_long_poll_seconds`` here after register), the ``?wait=N`` query
221
+ parameter asks action to hold the request until work arrives or the
222
+ window elapses; the read timeout is stretched to cover the hold.
223
+ """
224
+ params: dict[str, Any] | None = None
225
+ timeout = self._long_poll_timeout
226
+ if self._claim_wait:
227
+ params = {"wait": self._claim_wait}
228
+ timeout = max(self._long_poll_timeout, self._claim_wait + 15.0)
229
+ resp = self._request(
230
+ "POST",
231
+ "/agent/claim",
232
+ json=claim.model_dump(mode="json"),
233
+ params=params,
234
+ timeout=timeout,
235
+ allow_204=True,
236
+ )
237
+ if resp.status_code == 204 or not resp.content:
238
+ return None
239
+ return ClaimedJob.model_validate(resp.json())
240
+
241
+ def renew_lease(self, job_id: UUID, renew: LeaseRenew) -> LeaseState:
242
+ """POST /agent/jobs/{job_id}/lease -> LeaseState (with cancel_requested)."""
243
+ resp = self._request(
244
+ "POST",
245
+ f"/agent/jobs/{job_id}/lease",
246
+ json=renew.model_dump(mode="json"),
247
+ )
248
+ return LeaseState.model_validate(resp.json())
249
+
250
+ def get_job_control(self, job_id: UUID) -> LeaseState:
251
+ """GET active-job flags without renewing the lease."""
252
+ resp = self._request("GET", f"/agent/jobs/{job_id}/control")
253
+ return LeaseState.model_validate(resp.json())
254
+
255
+ # -- reporting ----------------------------------------------------------------
256
+ def report_job_status(self, job_id: UUID, report: JobStatusReport) -> None:
257
+ """POST /agent/jobs/{job_id}/status (terminal or transitional job status)."""
258
+ self._request(
259
+ "POST",
260
+ f"/agent/jobs/{job_id}/status",
261
+ json=report.model_dump(mode="json"),
262
+ allow_204=True,
263
+ )
264
+
265
+ def report_node_status(
266
+ self, job_id: UUID, report: NodeStatusReport
267
+ ) -> None:
268
+ """POST /agent/jobs/{job_id}/nodes/{node_id}/status."""
269
+ self._request(
270
+ "POST",
271
+ f"/agent/jobs/{job_id}/nodes/{report.node_id}/status",
272
+ json=report.model_dump(mode="json"),
273
+ allow_204=True,
274
+ )
275
+
276
+ def post_events(self, job_id: UUID, batch: AgentEventBatch) -> None:
277
+ """POST /agent/jobs/{job_id}/events (batched AgentEvents)."""
278
+ if not batch.events:
279
+ return
280
+ self._request(
281
+ "POST",
282
+ f"/agent/jobs/{job_id}/events",
283
+ json=batch.model_dump(mode="json"),
284
+ allow_204=True,
285
+ )
286
+
287
+ def post_logs(
288
+ self, job_id: UUID, node_id: UUID, batch: LogBatch
289
+ ) -> LogBatchAccepted:
290
+ """POST /agent/jobs/{job_id}/nodes/{node_id}/logs -> {accepted, max_seq}."""
291
+ resp = self._request(
292
+ "POST",
293
+ f"/agent/jobs/{job_id}/nodes/{node_id}/logs",
294
+ json=batch.model_dump(mode="json"),
295
+ )
296
+ return LogBatchAccepted.model_validate(resp.json())
297
+
298
+ def report_artifact(self, job_id: UUID, report: ArtifactReport) -> None:
299
+ """POST /agent/jobs/{job_id}/artifacts."""
300
+ self._request(
301
+ "POST",
302
+ f"/agent/jobs/{job_id}/artifacts",
303
+ json=report.model_dump(mode="json"),
304
+ allow_204=True,
305
+ )
@@ -0,0 +1,10 @@
1
+ """Stable host identity used by Desktop and self-hosted server enrollment."""
2
+
3
+ import platform
4
+ import socket
5
+
6
+
7
+ def host_fingerprint() -> str:
8
+ """Return the existing deterministic installation fingerprint."""
9
+ return f"{socket.gethostname()}:{platform.node()}:{platform.machine()}"
10
+
@@ -0,0 +1,52 @@
1
+ """In-process fan-out of CLI loopback events to the supervisor.
2
+
3
+ The loopback FastAPI router (``adagio_server/api/endpoints/loopback.py``) receives the
4
+ CLI's ``--connected`` events and enqueues them onto a per-job
5
+ :class:`asyncio.Queue`. The supervisor's relay task drains that queue, enriches
6
+ each event to the action contract, and posts it outward.
7
+
8
+ Keeping this as a module-level registry (rather than app state) lets both the
9
+ router and the supervisor - which live in the same process/event loop - share it
10
+ without threading FastAPI dependencies through the agent core.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import asyncio
16
+ from typing import Any
17
+ from uuid import UUID
18
+
19
+
20
+ class LoopbackRegistry:
21
+ """Registry of per-job event queues, keyed by ``job_id``."""
22
+
23
+ def __init__(self) -> None:
24
+ self._queues: dict[UUID, asyncio.Queue[dict[str, Any]]] = {}
25
+
26
+ def register(self, job_id: UUID) -> asyncio.Queue[dict[str, Any]]:
27
+ """Create (or return) the queue for ``job_id``."""
28
+ queue = self._queues.get(job_id)
29
+ if queue is None:
30
+ queue = asyncio.Queue()
31
+ self._queues[job_id] = queue
32
+ return queue
33
+
34
+ def get(self, job_id: UUID) -> asyncio.Queue[dict[str, Any]] | None:
35
+ """Return the queue for ``job_id`` if one is registered."""
36
+ return self._queues.get(job_id)
37
+
38
+ def unregister(self, job_id: UUID) -> None:
39
+ """Drop the queue for ``job_id`` (after the job completes)."""
40
+ self._queues.pop(job_id, None)
41
+
42
+ async def publish(self, job_id: UUID, event: dict[str, Any]) -> bool:
43
+ """Enqueue ``event`` for ``job_id``. Returns ``False`` if no queue exists."""
44
+ queue = self._queues.get(job_id)
45
+ if queue is None:
46
+ return False
47
+ await queue.put(event)
48
+ return True
49
+
50
+
51
+ #: Process-global registry shared by the loopback router and the supervisor.
52
+ registry = LoopbackRegistry()