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.
- adagio_server/__init__.py +8 -0
- adagio_server/agent/__init__.py +1 -0
- adagio_server/agent/artifacts.py +118 -0
- adagio_server/agent/client.py +305 -0
- adagio_server/agent/identity.py +10 -0
- adagio_server/agent/queues.py +52 -0
- adagio_server/agent/runner.py +340 -0
- adagio_server/agent/supervisor.py +846 -0
- adagio_server/api/__init__.py +0 -0
- adagio_server/api/api.py +20 -0
- adagio_server/api/endpoints/__init__.py +1 -0
- adagio_server/api/endpoints/loopback.py +83 -0
- adagio_server/cli.py +295 -0
- adagio_server/contracts/__init__.py +1 -0
- adagio_server/contracts/agent.py +12 -0
- adagio_server/core/__init__.py +1 -0
- adagio_server/core/config.py +110 -0
- adagio_server/main.py +118 -0
- adagio_server/services/__init__.py +0 -0
- adagio_server/services/timestamps.py +8 -0
- adagio_server-0.1.0.dist-info/METADATA +103 -0
- adagio_server-0.1.0.dist-info/RECORD +25 -0
- adagio_server-0.1.0.dist-info/WHEEL +4 -0
- adagio_server-0.1.0.dist-info/entry_points.txt +2 -0
- adagio_server-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -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()
|