horus-agent 0.1.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.
- horus_agent/__init__.py +6 -0
- horus_agent/__main__.py +13 -0
- horus_agent/blobs.py +89 -0
- horus_agent/broker.py +223 -0
- horus_agent/cli.py +153 -0
- horus_agent/config.py +62 -0
- horus_agent/daemon.py +281 -0
- horus_agent/logs.py +139 -0
- horus_agent/ops.py +297 -0
- horus_agent/protocol.py +156 -0
- horus_agent/py.typed +0 -0
- horus_agent/target.py +423 -0
- horus_agent-0.1.2.dist-info/METADATA +130 -0
- horus_agent-0.1.2.dist-info/RECORD +17 -0
- horus_agent-0.1.2.dist-info/WHEEL +4 -0
- horus_agent-0.1.2.dist-info/entry_points.txt +5 -0
- horus_agent-0.1.2.dist-info/licenses/LICENSE +21 -0
horus_agent/__init__.py
ADDED
horus_agent/__main__.py
ADDED
horus_agent/blobs.py
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
#
|
|
2
|
+
# horus_agent
|
|
3
|
+
# Copyright (c) 2026 Temple Compute
|
|
4
|
+
#
|
|
5
|
+
# MIT License
|
|
6
|
+
#
|
|
7
|
+
"""
|
|
8
|
+
Streamed transfers to and from presigned staging URLs.
|
|
9
|
+
|
|
10
|
+
Used on both sides: the orchestrator and the agent each move large files
|
|
11
|
+
through object storage instead of through the broker.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
import time
|
|
15
|
+
from collections.abc import AsyncIterator
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
import httpx
|
|
19
|
+
from horus_runtime.logging import horus_logger
|
|
20
|
+
|
|
21
|
+
from horus_agent.logs import safe_url
|
|
22
|
+
|
|
23
|
+
_CHUNK = 1024 * 1024
|
|
24
|
+
MAX_SINGLE_PUT = 5 * 1024**3
|
|
25
|
+
"""S3's limit for one PUT."""
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
async def _read_chunks(path: Path) -> AsyncIterator[bytes]:
|
|
29
|
+
with path.open("rb") as fh:
|
|
30
|
+
while chunk := fh.read(_CHUNK):
|
|
31
|
+
yield chunk
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
async def upload(url: str, source: Path | bytes) -> None:
|
|
35
|
+
"""
|
|
36
|
+
PUT *source* to the presigned *url*, streaming a file from disk.
|
|
37
|
+
|
|
38
|
+
``Content-Length`` is always sent: S3 refuses a chunked presigned PUT.
|
|
39
|
+
"""
|
|
40
|
+
if isinstance(source, Path):
|
|
41
|
+
size = source.stat().st_size
|
|
42
|
+
if size > MAX_SINGLE_PUT:
|
|
43
|
+
# shortcut: one presigned PUT, S3's limit; add multipart if
|
|
44
|
+
# agents need to move larger single files.
|
|
45
|
+
raise ValueError(
|
|
46
|
+
f"{source} is {size} bytes; files over 5 GiB cannot be "
|
|
47
|
+
"moved to or from an agent yet"
|
|
48
|
+
)
|
|
49
|
+
content: bytes | AsyncIterator[bytes] = _read_chunks(source)
|
|
50
|
+
else:
|
|
51
|
+
size = len(source)
|
|
52
|
+
content = source
|
|
53
|
+
log = horus_logger.log
|
|
54
|
+
log.debug(f"Upload {size} bytes to {safe_url(url)}")
|
|
55
|
+
started = time.monotonic()
|
|
56
|
+
async with httpx.AsyncClient(timeout=None) as client:
|
|
57
|
+
response = await client.put(
|
|
58
|
+
url, content=content, headers={"Content-Length": str(size)}
|
|
59
|
+
)
|
|
60
|
+
response.raise_for_status()
|
|
61
|
+
log.debug(f"Uploaded {size} bytes in {time.monotonic() - started:.2f}s")
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
async def download_to(url: str, dest: Path) -> None:
|
|
65
|
+
"""GET the presigned *url* into *dest*, streamed, creating parents."""
|
|
66
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
67
|
+
log = horus_logger.log
|
|
68
|
+
log.debug(f"Download {safe_url(url)} to {dest}")
|
|
69
|
+
started = time.monotonic()
|
|
70
|
+
async with (
|
|
71
|
+
httpx.AsyncClient(timeout=None) as client,
|
|
72
|
+
client.stream("GET", url) as response,
|
|
73
|
+
):
|
|
74
|
+
response.raise_for_status()
|
|
75
|
+
with dest.open("wb") as fh:
|
|
76
|
+
async for chunk in response.aiter_bytes(_CHUNK):
|
|
77
|
+
fh.write(chunk)
|
|
78
|
+
log.debug(
|
|
79
|
+
f"Downloaded {dest.stat().st_size} bytes to {dest} in "
|
|
80
|
+
f"{time.monotonic() - started:.2f}s"
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
async def download(url: str) -> bytes:
|
|
85
|
+
"""GET the presigned *url* into memory."""
|
|
86
|
+
async with httpx.AsyncClient(timeout=None) as client:
|
|
87
|
+
response = await client.get(url)
|
|
88
|
+
response.raise_for_status()
|
|
89
|
+
return response.content
|
horus_agent/broker.py
ADDED
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
#
|
|
2
|
+
# horus_agent
|
|
3
|
+
# Copyright (c) 2026 Temple Compute
|
|
4
|
+
#
|
|
5
|
+
# MIT License
|
|
6
|
+
#
|
|
7
|
+
"""
|
|
8
|
+
Orchestrator-side client for the broker that relays ops to agents.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import asyncio
|
|
12
|
+
import random
|
|
13
|
+
import time
|
|
14
|
+
import weakref
|
|
15
|
+
from typing import Any
|
|
16
|
+
from uuid import uuid4
|
|
17
|
+
|
|
18
|
+
import httpx
|
|
19
|
+
from horus_runtime.core.task.exceptions import (
|
|
20
|
+
TargetLostError,
|
|
21
|
+
TaskExecutionError,
|
|
22
|
+
)
|
|
23
|
+
from horus_runtime.logging import horus_logger
|
|
24
|
+
|
|
25
|
+
from horus_agent.logs import describe_args
|
|
26
|
+
from horus_agent.protocol import (
|
|
27
|
+
IDEMPOTENT_METHODS,
|
|
28
|
+
POLL_WAIT_S,
|
|
29
|
+
AgentInfo,
|
|
30
|
+
Op,
|
|
31
|
+
OpStatus,
|
|
32
|
+
StagingUrls,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
BACKOFF_MAX_S = 30.0
|
|
36
|
+
|
|
37
|
+
_CLIENTS: weakref.WeakKeyDictionary[
|
|
38
|
+
asyncio.AbstractEventLoop, dict[tuple[str, str], "BrokerClient"]
|
|
39
|
+
] = weakref.WeakKeyDictionary()
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
_WORK_DIRS: dict[tuple[str, str], str] = {}
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def agent_work_dir(url: str, token: str, agent: str) -> str:
|
|
46
|
+
"""
|
|
47
|
+
The working directory *agent* reported, asked once per process.
|
|
48
|
+
|
|
49
|
+
Blocking: target paths are computed synchronously, before any op runs.
|
|
50
|
+
shortcut: a sync request inside the event loop, once per agent per run;
|
|
51
|
+
move it into an async setup hook if the runtime grows one.
|
|
52
|
+
|
|
53
|
+
Raises:
|
|
54
|
+
TaskExecutionError: The broker does not know the agent, or the agent
|
|
55
|
+
has not reported its working directory yet.
|
|
56
|
+
"""
|
|
57
|
+
key = (url, agent)
|
|
58
|
+
if key in _WORK_DIRS:
|
|
59
|
+
return _WORK_DIRS[key]
|
|
60
|
+
try:
|
|
61
|
+
response = httpx.get(
|
|
62
|
+
f"{url.rstrip('/')}/agents/info/{agent}",
|
|
63
|
+
headers={"Authorization": f"Bearer {token}"},
|
|
64
|
+
timeout=30.0,
|
|
65
|
+
)
|
|
66
|
+
except httpx.TransportError as exc:
|
|
67
|
+
raise TaskExecutionError(f"Broker unreachable: {exc}") from exc
|
|
68
|
+
if response.status_code >= 400:
|
|
69
|
+
raise TaskExecutionError(
|
|
70
|
+
f"Cannot look up agent {agent!r}: HTTP {response.status_code}: "
|
|
71
|
+
f"{response.text}"
|
|
72
|
+
)
|
|
73
|
+
info = AgentInfo.model_validate(response.json())
|
|
74
|
+
if not info.work_dir:
|
|
75
|
+
raise TaskExecutionError(
|
|
76
|
+
f"Agent {agent!r} has not reported its working directory yet; "
|
|
77
|
+
"start it with horus-agent run"
|
|
78
|
+
)
|
|
79
|
+
horus_logger.log.debug(f"Agent {agent!r} runs tasks in {info.work_dir}")
|
|
80
|
+
_WORK_DIRS[key] = info.work_dir
|
|
81
|
+
return info.work_dir
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def broker_client(url: str, token: str) -> "BrokerClient":
|
|
85
|
+
"""
|
|
86
|
+
The running loop's client for *url* and *token*, so every target in a
|
|
87
|
+
run shares one connection pool.
|
|
88
|
+
"""
|
|
89
|
+
clients = _CLIENTS.setdefault(asyncio.get_running_loop(), {})
|
|
90
|
+
key = (url, token)
|
|
91
|
+
if key not in clients:
|
|
92
|
+
clients[key] = BrokerClient(url, token)
|
|
93
|
+
return clients[key]
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class BrokerClient:
|
|
97
|
+
"""Submits ops to the broker and waits for their results."""
|
|
98
|
+
|
|
99
|
+
def __init__(self, url: str, token: str) -> None:
|
|
100
|
+
"""Bind to the broker at *url*, authenticating with *token*."""
|
|
101
|
+
self._http = httpx.AsyncClient(
|
|
102
|
+
base_url=url.rstrip("/") + "/",
|
|
103
|
+
headers={"Authorization": f"Bearer {token}"},
|
|
104
|
+
# A long-poll holds the response for up to POLL_WAIT_S.
|
|
105
|
+
timeout=httpx.Timeout(30.0, read=POLL_WAIT_S + 30.0),
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
async def call(
|
|
109
|
+
self,
|
|
110
|
+
agent: str,
|
|
111
|
+
method: str,
|
|
112
|
+
args: dict[str, Any],
|
|
113
|
+
*,
|
|
114
|
+
offline_timeout: float,
|
|
115
|
+
) -> Any:
|
|
116
|
+
"""
|
|
117
|
+
Run *method* on *agent* and return its result.
|
|
118
|
+
|
|
119
|
+
Raises:
|
|
120
|
+
TargetLostError: The agent has not polled for *offline_timeout*
|
|
121
|
+
seconds, or restarted while running a non-idempotent op.
|
|
122
|
+
TaskExecutionError: The op failed on the agent, or the broker
|
|
123
|
+
refused or stayed unreachable.
|
|
124
|
+
"""
|
|
125
|
+
log = horus_logger.log
|
|
126
|
+
started = time.monotonic()
|
|
127
|
+
while True:
|
|
128
|
+
op = Op(op_id=uuid4().hex, method=method, args=args)
|
|
129
|
+
tag = f"Op {op.op_id[:8]} {method} on agent {agent!r}"
|
|
130
|
+
log.debug(f"{tag}: submit {describe_args(args)}")
|
|
131
|
+
status = await self._request(
|
|
132
|
+
"POST",
|
|
133
|
+
"agents/ops/",
|
|
134
|
+
offline_timeout,
|
|
135
|
+
json={"agent": agent, "op": op.model_dump(exclude={"run_id"})},
|
|
136
|
+
)
|
|
137
|
+
while not status.done and not status.lost:
|
|
138
|
+
seen = status.agent_seen_ago
|
|
139
|
+
if seen is None or seen > offline_timeout:
|
|
140
|
+
raise TargetLostError(
|
|
141
|
+
f"Agent {agent!r} is offline (last seen: "
|
|
142
|
+
f"{'never' if seen is None else f'{seen:.0f}s ago'})"
|
|
143
|
+
)
|
|
144
|
+
log.debug(
|
|
145
|
+
f"{tag}: waiting (agent last polled {seen:.0f}s ago)"
|
|
146
|
+
)
|
|
147
|
+
status = await self._request(
|
|
148
|
+
"GET",
|
|
149
|
+
f"agents/ops/{op.op_id}",
|
|
150
|
+
offline_timeout,
|
|
151
|
+
params={"wait": POLL_WAIT_S},
|
|
152
|
+
)
|
|
153
|
+
if status.done:
|
|
154
|
+
break
|
|
155
|
+
if method not in IDEMPOTENT_METHODS:
|
|
156
|
+
raise TargetLostError(
|
|
157
|
+
f"Agent {agent!r} restarted while running {method}"
|
|
158
|
+
)
|
|
159
|
+
log.debug(f"{tag}: lost on the way; sending it again")
|
|
160
|
+
log.debug(
|
|
161
|
+
f"{tag}: {'failed' if status.error else 'done'} after "
|
|
162
|
+
f"{time.monotonic() - started:.2f}s"
|
|
163
|
+
)
|
|
164
|
+
if status.error is not None:
|
|
165
|
+
raise TaskExecutionError(
|
|
166
|
+
f"{method} on agent {agent!r} failed: "
|
|
167
|
+
f"{status.error.type}: {status.error.message}"
|
|
168
|
+
)
|
|
169
|
+
return status.result
|
|
170
|
+
|
|
171
|
+
async def staging(self, offline_timeout: float) -> StagingUrls:
|
|
172
|
+
"""Presigned URLs for a fresh staging object."""
|
|
173
|
+
response = await self._send("POST", "agents/staging/", offline_timeout)
|
|
174
|
+
return StagingUrls.model_validate(response.json())
|
|
175
|
+
|
|
176
|
+
async def _request(
|
|
177
|
+
self,
|
|
178
|
+
verb: str,
|
|
179
|
+
path: str,
|
|
180
|
+
offline_timeout: float,
|
|
181
|
+
**kwargs: Any,
|
|
182
|
+
) -> OpStatus:
|
|
183
|
+
response = await self._send(verb, path, offline_timeout, **kwargs)
|
|
184
|
+
return OpStatus.model_validate(response.json())
|
|
185
|
+
|
|
186
|
+
async def _send(
|
|
187
|
+
self,
|
|
188
|
+
verb: str,
|
|
189
|
+
path: str,
|
|
190
|
+
offline_timeout: float,
|
|
191
|
+
**kwargs: Any,
|
|
192
|
+
) -> httpx.Response:
|
|
193
|
+
"""
|
|
194
|
+
Send one request, retrying with backoff while the broker is down.
|
|
195
|
+
|
|
196
|
+
A resent ``POST agents/ops/`` carries the same ``op_id``, which the
|
|
197
|
+
broker queues only once.
|
|
198
|
+
"""
|
|
199
|
+
deadline = time.monotonic() + offline_timeout
|
|
200
|
+
delay = 1.0
|
|
201
|
+
while True:
|
|
202
|
+
try:
|
|
203
|
+
response = await self._http.request(verb, path, **kwargs)
|
|
204
|
+
except httpx.TransportError as exc:
|
|
205
|
+
failure = f"{type(exc).__name__}: {exc}"
|
|
206
|
+
else:
|
|
207
|
+
if response.status_code < 500:
|
|
208
|
+
break
|
|
209
|
+
failure = f"HTTP {response.status_code}"
|
|
210
|
+
if time.monotonic() + delay > deadline:
|
|
211
|
+
raise TaskExecutionError(f"Broker unreachable: {failure}")
|
|
212
|
+
await asyncio.sleep(delay * random.uniform(0.5, 1.0))
|
|
213
|
+
delay = min(delay * 2, BACKOFF_MAX_S)
|
|
214
|
+
if response.status_code >= 400:
|
|
215
|
+
try:
|
|
216
|
+
detail = response.json().get("detail", response.text)
|
|
217
|
+
except ValueError:
|
|
218
|
+
detail = response.text
|
|
219
|
+
raise TaskExecutionError(
|
|
220
|
+
f"Broker refused {verb} {path}: "
|
|
221
|
+
f"HTTP {response.status_code}: {detail}"
|
|
222
|
+
)
|
|
223
|
+
return response
|
horus_agent/cli.py
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
#
|
|
2
|
+
# horus_agent
|
|
3
|
+
# Copyright (c) 2026 Temple Compute
|
|
4
|
+
#
|
|
5
|
+
# MIT License
|
|
6
|
+
#
|
|
7
|
+
"""
|
|
8
|
+
``horus-agent register`` and ``horus-agent run``.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import argparse
|
|
12
|
+
import asyncio
|
|
13
|
+
import dataclasses
|
|
14
|
+
import platform
|
|
15
|
+
import signal
|
|
16
|
+
import socket
|
|
17
|
+
import sys
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
import httpx
|
|
21
|
+
|
|
22
|
+
from horus_agent import config, logs
|
|
23
|
+
from horus_agent.daemon import Agent, AgentUnauthorizedError, agent_version
|
|
24
|
+
from horus_agent.protocol import Registration
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _work_dir(value: str | None) -> Path:
|
|
28
|
+
"""*value* as an absolute path, created if missing."""
|
|
29
|
+
path = Path(value).expanduser() if value else config.default_work_dir()
|
|
30
|
+
path = path.resolve()
|
|
31
|
+
path.mkdir(parents=True, exist_ok=True)
|
|
32
|
+
return path
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _register(
|
|
36
|
+
url: str, token: str, name: str | None, work_dir: str | None
|
|
37
|
+
) -> int:
|
|
38
|
+
directory = _work_dir(work_dir)
|
|
39
|
+
body = {
|
|
40
|
+
"token": token,
|
|
41
|
+
"hostname": socket.gethostname(),
|
|
42
|
+
"os": platform.platform(),
|
|
43
|
+
"version": agent_version(),
|
|
44
|
+
"work_dir": str(directory),
|
|
45
|
+
}
|
|
46
|
+
if name is not None:
|
|
47
|
+
body["name"] = name
|
|
48
|
+
response = httpx.post(
|
|
49
|
+
f"{url.rstrip('/')}/agents/register", json=body, timeout=30.0
|
|
50
|
+
)
|
|
51
|
+
if response.status_code >= 400:
|
|
52
|
+
try:
|
|
53
|
+
detail = response.json().get("detail", response.text)
|
|
54
|
+
except ValueError:
|
|
55
|
+
detail = response.text
|
|
56
|
+
sys.stderr.write(f"Registration failed: {detail}\n")
|
|
57
|
+
return 1
|
|
58
|
+
registration = Registration.model_validate(response.json())
|
|
59
|
+
path = config.config_path()
|
|
60
|
+
config.save(
|
|
61
|
+
config.AgentConfig(
|
|
62
|
+
url=url,
|
|
63
|
+
agent_id=registration.agent_id,
|
|
64
|
+
name=registration.name,
|
|
65
|
+
token=registration.agent_token,
|
|
66
|
+
work_dir=str(directory),
|
|
67
|
+
),
|
|
68
|
+
path,
|
|
69
|
+
)
|
|
70
|
+
sys.stdout.write(
|
|
71
|
+
f"Registered as {registration.name!r} (config: {path}).\n"
|
|
72
|
+
f"Tasks run in {directory}.\n"
|
|
73
|
+
"Start it with: horus-agent run\n"
|
|
74
|
+
)
|
|
75
|
+
return 0
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
async def _run(work_dir: str | None) -> int:
|
|
79
|
+
path = config.config_path()
|
|
80
|
+
try:
|
|
81
|
+
registered = config.load(path)
|
|
82
|
+
except FileNotFoundError:
|
|
83
|
+
sys.stderr.write(
|
|
84
|
+
f"No registration at {path}. Run horus-agent register first.\n"
|
|
85
|
+
)
|
|
86
|
+
return 1
|
|
87
|
+
directory = _work_dir(work_dir or registered.work_dir or None)
|
|
88
|
+
agent = Agent(dataclasses.replace(registered, work_dir=str(directory)))
|
|
89
|
+
stop = asyncio.Event()
|
|
90
|
+
loop = asyncio.get_running_loop()
|
|
91
|
+
|
|
92
|
+
def _stop() -> None:
|
|
93
|
+
# A second signal falls through to the default: exit now.
|
|
94
|
+
for sig in (signal.SIGINT, signal.SIGTERM):
|
|
95
|
+
loop.remove_signal_handler(sig)
|
|
96
|
+
stop.set()
|
|
97
|
+
|
|
98
|
+
for sig in (signal.SIGINT, signal.SIGTERM):
|
|
99
|
+
loop.add_signal_handler(sig, _stop)
|
|
100
|
+
try:
|
|
101
|
+
await agent.serve(stop)
|
|
102
|
+
except AgentUnauthorizedError as exc:
|
|
103
|
+
sys.stderr.write(f"{exc}\n")
|
|
104
|
+
return 1
|
|
105
|
+
return 0
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def main(argv: list[str] | None = None) -> int:
|
|
109
|
+
"""Entry point of the ``horus-agent`` command."""
|
|
110
|
+
parser = argparse.ArgumentParser(
|
|
111
|
+
prog="horus-agent",
|
|
112
|
+
description="Run Horus tasks on this machine without opening "
|
|
113
|
+
"inbound connections.",
|
|
114
|
+
)
|
|
115
|
+
common = argparse.ArgumentParser(add_help=False)
|
|
116
|
+
common.add_argument(
|
|
117
|
+
"--debug",
|
|
118
|
+
action="store_true",
|
|
119
|
+
help="Log every connection, request, op and command (also: "
|
|
120
|
+
f"{logs.DEBUG_ENV}=1). Secret values are never logged.",
|
|
121
|
+
)
|
|
122
|
+
commands = parser.add_subparsers(dest="command", required=True)
|
|
123
|
+
register = commands.add_parser(
|
|
124
|
+
"register",
|
|
125
|
+
parents=[common],
|
|
126
|
+
help="Register this machine with a one-time token.",
|
|
127
|
+
)
|
|
128
|
+
register.add_argument("--url", required=True, help="Broker API URL.")
|
|
129
|
+
register.add_argument(
|
|
130
|
+
"--token", required=True, help="One-time registration token."
|
|
131
|
+
)
|
|
132
|
+
register.add_argument(
|
|
133
|
+
"--name",
|
|
134
|
+
help="Agent name, used as the target's 'agent' field, when the "
|
|
135
|
+
"token does not set one. Default: this machine's hostname.",
|
|
136
|
+
)
|
|
137
|
+
work_dir_help = (
|
|
138
|
+
"Directory where tasks run on this machine, created if missing. "
|
|
139
|
+
f"Default: {config.default_work_dir()}."
|
|
140
|
+
)
|
|
141
|
+
register.add_argument("--work-dir", help=work_dir_help)
|
|
142
|
+
run = commands.add_parser(
|
|
143
|
+
"run", parents=[common], help="Poll for work until stopped."
|
|
144
|
+
)
|
|
145
|
+
run.add_argument(
|
|
146
|
+
"--work-dir",
|
|
147
|
+
help="Use this directory instead of the one given at registration.",
|
|
148
|
+
)
|
|
149
|
+
args = parser.parse_args(argv)
|
|
150
|
+
logs.setup(args.debug)
|
|
151
|
+
if args.command == "register":
|
|
152
|
+
return _register(args.url, args.token, args.name, args.work_dir)
|
|
153
|
+
return asyncio.run(_run(args.work_dir))
|
horus_agent/config.py
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
#
|
|
2
|
+
# horus_agent
|
|
3
|
+
# Copyright (c) 2026 Temple Compute
|
|
4
|
+
#
|
|
5
|
+
# MIT License
|
|
6
|
+
#
|
|
7
|
+
"""
|
|
8
|
+
The agent's registration, kept in one owner-only JSON file.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import os
|
|
13
|
+
from dataclasses import asdict, dataclass
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
CONFIG_ENV = "HORUS_AGENT_CONFIG"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass(frozen=True)
|
|
20
|
+
class AgentConfig:
|
|
21
|
+
"""What ``horus-agent register`` stores for ``horus-agent run``."""
|
|
22
|
+
|
|
23
|
+
url: str
|
|
24
|
+
"""Broker base URL."""
|
|
25
|
+
|
|
26
|
+
agent_id: str
|
|
27
|
+
name: str
|
|
28
|
+
token: str
|
|
29
|
+
"""The agent's own credential. Never logged."""
|
|
30
|
+
work_dir: str = ""
|
|
31
|
+
"""
|
|
32
|
+
Where tasks run on this machine. Empty in configs written before it
|
|
33
|
+
existed: :func:`default_work_dir` then.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def default_work_dir() -> Path:
|
|
38
|
+
"""``~/horus-agent``: where tasks run unless ``--work-dir`` says else."""
|
|
39
|
+
return Path.home() / "horus-agent"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def config_path() -> Path:
|
|
43
|
+
"""``$HORUS_AGENT_CONFIG``, else ``$XDG_CONFIG_HOME/horus-agent``."""
|
|
44
|
+
if explicit := os.environ.get(CONFIG_ENV):
|
|
45
|
+
return Path(explicit)
|
|
46
|
+
base = os.environ.get("XDG_CONFIG_HOME") or Path.home() / ".config"
|
|
47
|
+
return Path(base) / "horus-agent" / "agent.json"
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def save(config: AgentConfig, path: Path) -> None:
|
|
51
|
+
"""Write *config* to *path*, readable by its owner only."""
|
|
52
|
+
path.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
53
|
+
fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
|
54
|
+
# O_CREAT's mode does not apply to a file that already existed.
|
|
55
|
+
os.fchmod(fd, 0o600)
|
|
56
|
+
with os.fdopen(fd, "w") as fh:
|
|
57
|
+
json.dump(asdict(config), fh)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def load(path: Path) -> AgentConfig:
|
|
61
|
+
"""Read the config written by :func:`save`."""
|
|
62
|
+
return AgentConfig(**json.loads(path.read_text()))
|