cloudagent-daemon 0.2.1__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.
- cloudagent_daemon/__init__.py +1 -0
- cloudagent_daemon/__main__.py +4 -0
- cloudagent_daemon/claude_runner.py +1068 -0
- cloudagent_daemon/cli.py +833 -0
- cloudagent_daemon/config.py +197 -0
- cloudagent_daemon/doctor.py +534 -0
- cloudagent_daemon/frame_dispatch.py +216 -0
- cloudagent_daemon/http_outbound.py +80 -0
- cloudagent_daemon/jsonl_logging.py +98 -0
- cloudagent_daemon/login.py +34 -0
- cloudagent_daemon/main_loop.py +891 -0
- cloudagent_daemon/mcp_bridge.py +441 -0
- cloudagent_daemon/metrics.py +315 -0
- cloudagent_daemon/metrics_server.py +149 -0
- cloudagent_daemon/outbound_queue.py +289 -0
- cloudagent_daemon/paths.py +204 -0
- cloudagent_daemon/protocol.py +141 -0
- cloudagent_daemon/runtime_detect.py +135 -0
- cloudagent_daemon/service.py +355 -0
- cloudagent_daemon/spawn_outbox.py +443 -0
- cloudagent_daemon/spawn_queue.py +200 -0
- cloudagent_daemon/sse_client.py +439 -0
- cloudagent_daemon/transport.py +105 -0
- cloudagent_daemon/unix_socket.py +278 -0
- cloudagent_daemon/workspace.py +441 -0
- cloudagent_daemon/ws_client.py +431 -0
- cloudagent_daemon-0.2.1.dist-info/METADATA +322 -0
- cloudagent_daemon-0.2.1.dist-info/RECORD +31 -0
- cloudagent_daemon-0.2.1.dist-info/WHEEL +4 -0
- cloudagent_daemon-0.2.1.dist-info/entry_points.txt +2 -0
- cloudagent_daemon-0.2.1.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
"""Persistent config.json and state.json IO for the cloudagent daemon.
|
|
2
|
+
|
|
3
|
+
Both models support:
|
|
4
|
+
.load(path) — parse JSON file into model (raises FileNotFoundError if missing)
|
|
5
|
+
.save(path) — atomic write: tempfile → fsync → os.replace, mode 0o600
|
|
6
|
+
.default_path() — ~/.cloudagent/daemon/{config|state}.json
|
|
7
|
+
.load_default() — convenience wrapper
|
|
8
|
+
.save_default() — convenience wrapper
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
import os
|
|
14
|
+
import tempfile
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Self
|
|
17
|
+
|
|
18
|
+
from pydantic import BaseModel
|
|
19
|
+
|
|
20
|
+
from .paths import (
|
|
21
|
+
DEFAULT_PROFILE,
|
|
22
|
+
profile_config_path,
|
|
23
|
+
profile_state_path,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
_LRU_CAP = 1024
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _atomic_write_json(path: Path, data: dict) -> None:
|
|
30
|
+
"""Write *data* as JSON to *path* atomically with fsync and mode 0o600."""
|
|
31
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
32
|
+
fd, tmp_name = tempfile.mkstemp(dir=path.parent)
|
|
33
|
+
try:
|
|
34
|
+
with os.fdopen(fd, "w") as fh:
|
|
35
|
+
json.dump(data, fh, indent=2)
|
|
36
|
+
fh.flush()
|
|
37
|
+
os.fsync(fh.fileno())
|
|
38
|
+
except Exception:
|
|
39
|
+
os.unlink(tmp_name)
|
|
40
|
+
raise
|
|
41
|
+
os.replace(tmp_name, path)
|
|
42
|
+
os.chmod(path, 0o600)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class DaemonConfig(BaseModel):
|
|
46
|
+
"""Stored at ``~/.cloudagent/daemon/config.json`` (default profile) or
|
|
47
|
+
``~/.cloudagent/daemon/profiles/<name>/config.json`` (named profile).
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
server_url: str
|
|
51
|
+
machine_api_key: str
|
|
52
|
+
host_id: str
|
|
53
|
+
|
|
54
|
+
# ------------------------------------------------------------------
|
|
55
|
+
# Path helpers
|
|
56
|
+
# ------------------------------------------------------------------
|
|
57
|
+
|
|
58
|
+
@classmethod
|
|
59
|
+
def default_path(cls, profile: str | None = None) -> Path:
|
|
60
|
+
# Resolve only when profile is given. Tests monkeypatch this
|
|
61
|
+
# classmethod with a no-arg lambda; preserving the no-arg call
|
|
62
|
+
# path keeps that pattern working.
|
|
63
|
+
return profile_config_path(profile) if profile else profile_config_path(DEFAULT_PROFILE)
|
|
64
|
+
|
|
65
|
+
# ------------------------------------------------------------------
|
|
66
|
+
# IO
|
|
67
|
+
# ------------------------------------------------------------------
|
|
68
|
+
|
|
69
|
+
@classmethod
|
|
70
|
+
def load(cls, path: Path) -> Self:
|
|
71
|
+
if not path.exists():
|
|
72
|
+
raise FileNotFoundError(f"Config file not found: {path}")
|
|
73
|
+
return cls.model_validate(json.loads(path.read_text()))
|
|
74
|
+
|
|
75
|
+
@classmethod
|
|
76
|
+
def load_default(cls, profile: str | None = None) -> Self:
|
|
77
|
+
# Default profile → call no-arg ``default_path()`` so test stubs
|
|
78
|
+
# that monkeypatch the classmethod with a (cls)-only signature
|
|
79
|
+
# keep working byte-for-byte.
|
|
80
|
+
path = (
|
|
81
|
+
cls.default_path()
|
|
82
|
+
if not profile or profile == DEFAULT_PROFILE
|
|
83
|
+
else cls.default_path(profile)
|
|
84
|
+
)
|
|
85
|
+
return cls.load(path)
|
|
86
|
+
|
|
87
|
+
def save(self, path: Path) -> None:
|
|
88
|
+
_atomic_write_json(path, self.model_dump())
|
|
89
|
+
|
|
90
|
+
def save_default(self, profile: str | None = None) -> None:
|
|
91
|
+
path = (
|
|
92
|
+
self.default_path()
|
|
93
|
+
if not profile or profile == DEFAULT_PROFILE
|
|
94
|
+
else self.default_path(profile)
|
|
95
|
+
)
|
|
96
|
+
self.save(path)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
class DaemonState(BaseModel):
|
|
100
|
+
"""Stored at ``~/.cloudagent/daemon/state.json`` (default profile) or
|
|
101
|
+
``~/.cloudagent/daemon/profiles/<name>/state.json`` (named profile).
|
|
102
|
+
"""
|
|
103
|
+
|
|
104
|
+
last_seq: int = 0
|
|
105
|
+
processed_message_ids: list[str] = []
|
|
106
|
+
# Task #113 (2026-05-12) — last ``--workspace-root`` the user passed.
|
|
107
|
+
# Persisted so subsequent ``cloudagent-daemon start`` invocations
|
|
108
|
+
# without the flag still pick it up (avoid forcing the user to
|
|
109
|
+
# type the path on every reboot / launchctl restart).
|
|
110
|
+
workspace_root: str | None = None
|
|
111
|
+
# 2026-05-15 — host_id this seq/dedupe state belongs to. Compared on
|
|
112
|
+
# /ready (or WS hello) so switching ``--server-url`` (or otherwise
|
|
113
|
+
# landing on a fresh host_machine row) discards stale ``last_seq`` /
|
|
114
|
+
# ``processed_message_ids``. Without this, the SSE client kept
|
|
115
|
+
# sending ``Last-Event-ID=<old-cluster-seq>`` on the new cluster and
|
|
116
|
+
# every fresh frame whose seq <= the stale value got deduped server-
|
|
117
|
+
# side — chat looked silently broken.
|
|
118
|
+
host_id: str | None = None
|
|
119
|
+
|
|
120
|
+
# ------------------------------------------------------------------
|
|
121
|
+
# Path helpers
|
|
122
|
+
# ------------------------------------------------------------------
|
|
123
|
+
|
|
124
|
+
@classmethod
|
|
125
|
+
def default_path(cls, profile: str | None = None) -> Path:
|
|
126
|
+
# Test stubs may monkeypatch this with a (cls)-only lambda; the
|
|
127
|
+
# call sites in ``load_default`` / ``save_default`` invoke us
|
|
128
|
+
# without args for the default profile to preserve that.
|
|
129
|
+
return profile_state_path(profile) if profile else profile_state_path(DEFAULT_PROFILE)
|
|
130
|
+
|
|
131
|
+
# ------------------------------------------------------------------
|
|
132
|
+
# LRU helpers
|
|
133
|
+
# ------------------------------------------------------------------
|
|
134
|
+
|
|
135
|
+
def add_processed(self, msg_id: str) -> None:
|
|
136
|
+
"""Append msg_id and enforce the LRU cap of 1024 (drop oldest)."""
|
|
137
|
+
self.processed_message_ids.append(msg_id)
|
|
138
|
+
if len(self.processed_message_ids) > _LRU_CAP:
|
|
139
|
+
self.processed_message_ids = self.processed_message_ids[-_LRU_CAP:]
|
|
140
|
+
|
|
141
|
+
def has_processed(self, msg_id: str) -> bool:
|
|
142
|
+
return msg_id in self.processed_message_ids
|
|
143
|
+
|
|
144
|
+
# ------------------------------------------------------------------
|
|
145
|
+
# Host fingerprint
|
|
146
|
+
# ------------------------------------------------------------------
|
|
147
|
+
|
|
148
|
+
def adopt_host_id(self, host_id: str) -> str | None:
|
|
149
|
+
"""Adopt host_id, resetting seq/dedupe state on mismatch.
|
|
150
|
+
|
|
151
|
+
Returns the previous ``host_id`` if a reset happened, else
|
|
152
|
+
``None``. ``None`` covers three cases: empty input (caller hasn't
|
|
153
|
+
learned the id yet), first-ever adoption (state predates this
|
|
154
|
+
field — keep existing seq), or no-op (id matches current).
|
|
155
|
+
"""
|
|
156
|
+
if not host_id:
|
|
157
|
+
return None
|
|
158
|
+
if self.host_id is None:
|
|
159
|
+
self.host_id = host_id
|
|
160
|
+
return None
|
|
161
|
+
if self.host_id == host_id:
|
|
162
|
+
return None
|
|
163
|
+
prev = self.host_id
|
|
164
|
+
self.host_id = host_id
|
|
165
|
+
self.last_seq = 0
|
|
166
|
+
self.processed_message_ids = []
|
|
167
|
+
return prev
|
|
168
|
+
|
|
169
|
+
# ------------------------------------------------------------------
|
|
170
|
+
# IO
|
|
171
|
+
# ------------------------------------------------------------------
|
|
172
|
+
|
|
173
|
+
@classmethod
|
|
174
|
+
def load(cls, path: Path) -> Self:
|
|
175
|
+
if not path.exists():
|
|
176
|
+
raise FileNotFoundError(f"State file not found: {path}")
|
|
177
|
+
return cls.model_validate(json.loads(path.read_text()))
|
|
178
|
+
|
|
179
|
+
@classmethod
|
|
180
|
+
def load_default(cls, profile: str | None = None) -> Self:
|
|
181
|
+
path = (
|
|
182
|
+
cls.default_path()
|
|
183
|
+
if not profile or profile == DEFAULT_PROFILE
|
|
184
|
+
else cls.default_path(profile)
|
|
185
|
+
)
|
|
186
|
+
return cls.load(path)
|
|
187
|
+
|
|
188
|
+
def save(self, path: Path) -> None:
|
|
189
|
+
_atomic_write_json(path, self.model_dump())
|
|
190
|
+
|
|
191
|
+
def save_default(self, profile: str | None = None) -> None:
|
|
192
|
+
path = (
|
|
193
|
+
self.default_path()
|
|
194
|
+
if not profile or profile == DEFAULT_PROFILE
|
|
195
|
+
else self.default_path(profile)
|
|
196
|
+
)
|
|
197
|
+
self.save(path)
|