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.
@@ -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)