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,216 @@
1
+ """Inbound frame dispatcher for the cloudagent daemon.
2
+
3
+ Routes WebSocket frames received from the server to the appropriate
4
+ handler/callback. Actual spawn logic (Task 8), outbound queue (Task 7),
5
+ and workspace management (Task 11) are injected via callbacks so this
6
+ module stays dependency-free beyond DaemonState and stdlib.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import asyncio
11
+ import logging
12
+ from collections.abc import Awaitable, Callable
13
+ from pathlib import Path
14
+ from typing import TYPE_CHECKING
15
+
16
+ from cloudagent_daemon.config import DaemonState
17
+
18
+ if TYPE_CHECKING:
19
+ from cloudagent_daemon.spawn_outbox import SpawnOutbox
20
+
21
+ logger = logging.getLogger(__name__)
22
+
23
+
24
+ class FrameDispatcher:
25
+ """Route inbound frames to registered callbacks.
26
+
27
+ Parameters
28
+ ----------
29
+ state:
30
+ Live DaemonState; mutated on accepted deliver frames.
31
+ state_path:
32
+ Path passed to ``state.save()`` after each accepted deliver.
33
+ ack_callback:
34
+ Called as ``ack_callback(seq, message_id, status)`` immediately
35
+ after a deliver frame passes the idempotency check.
36
+ spawn_callback:
37
+ Called with the deliver payload dict for new (non-duplicate) frames.
38
+ tool_result_pending:
39
+ Shared dict mapping ``request_id`` → ``asyncio.Future``. The
40
+ dispatcher pops and resolves futures as ``tool_result`` frames arrive.
41
+ workspace_assign_callback:
42
+ Optional; called with the assign payload dict.
43
+ workspace_unassign_callback:
44
+ Optional; called with the unassign payload dict.
45
+ bye_callback:
46
+ Optional; called with the bye payload dict to trigger shutdown.
47
+ spawn_outbox:
48
+ Optional; durable journal of accepted deliver frames. When
49
+ provided, every new (non-duplicate) frame is persisted via
50
+ :meth:`SpawnOutbox.record_received` BEFORE the server-side ack
51
+ so a daemon crash between ack and spawn doesn't silently lose
52
+ the frame. See ``spawn_outbox.py`` for details (Bug 4).
53
+ """
54
+
55
+ def __init__(
56
+ self,
57
+ *,
58
+ state: DaemonState,
59
+ state_path: Path,
60
+ ack_callback: Callable[[int, str, str], Awaitable[None]],
61
+ spawn_callback: Callable[[dict], Awaitable[None]],
62
+ tool_result_pending: dict[str, asyncio.Future],
63
+ workspace_assign_callback: Callable[[dict], Awaitable[None]] | None = None,
64
+ workspace_unassign_callback: Callable[[dict], Awaitable[None]] | None = None,
65
+ bye_callback: Callable[[dict], Awaitable[None]] | None = None,
66
+ spawn_outbox: "SpawnOutbox | None" = None,
67
+ ) -> None:
68
+ self.state = state
69
+ self.state_path = state_path
70
+ self.ack_callback = ack_callback
71
+ self.spawn_callback = spawn_callback
72
+ self.tool_result_pending = tool_result_pending
73
+ self.workspace_assign_callback = workspace_assign_callback
74
+ self.workspace_unassign_callback = workspace_unassign_callback
75
+ self.bye_callback = bye_callback
76
+ self.spawn_outbox = spawn_outbox
77
+
78
+ # ------------------------------------------------------------------
79
+ # Public entry point
80
+ # ------------------------------------------------------------------
81
+
82
+ async def dispatch(self, frame: dict) -> None:
83
+ """Dispatch *frame* to the appropriate handler."""
84
+ t = frame.get("type")
85
+ if t == "deliver":
86
+ await self._on_deliver(frame)
87
+ elif t == "tool_result":
88
+ self._on_tool_result(frame)
89
+ elif t == "assign":
90
+ await self._on_assign(frame)
91
+ elif t == "unassign":
92
+ await self._on_unassign(frame)
93
+ elif t == "bye":
94
+ await self._on_bye(frame)
95
+ elif t == "hello":
96
+ pass # WSClient already consumed this in __aenter__
97
+ else:
98
+ logger.warning("unknown frame type: %s", t)
99
+
100
+ # ------------------------------------------------------------------
101
+ # Handlers
102
+ # ------------------------------------------------------------------
103
+
104
+ async def _on_deliver(self, frame: dict) -> None:
105
+ seq = frame["seq"]
106
+ payload = frame["payload"]
107
+ msg_id = payload["message_id"]
108
+ # K1 §护栏 9 — server-stamped wake idempotency key. When set, dedupe
109
+ # by wake_id in addition to message_id so reconnect-catchup of a
110
+ # frame whose wake was already handled lands as ack-only. Frames
111
+ # without ``wake_id`` (test-chat / direct task creation) keep the
112
+ # legacy message_id-only path.
113
+ wake_id = payload.get("wake_id") if isinstance(payload, dict) else None
114
+
115
+ # Idempotency: already processed (by either key) → ack-only, skip spawn
116
+ if self.state.has_processed(msg_id):
117
+ logger.info(
118
+ "frame_dispatch: deliver duplicate (msg_id already processed) "
119
+ "agent=%s msg=%s seq=%d",
120
+ payload.get("agent_id"),
121
+ msg_id,
122
+ seq,
123
+ )
124
+ await self.ack_callback(seq, msg_id, "received")
125
+ return
126
+ if wake_id and self.state.has_processed(wake_id):
127
+ logger.info(
128
+ "frame_dispatch: deliver duplicate (wake_id already processed) "
129
+ "agent=%s msg=%s wake=%s seq=%d",
130
+ payload.get("agent_id"),
131
+ msg_id,
132
+ wake_id,
133
+ seq,
134
+ )
135
+ # Still record the message_id so further re-deliveries of the
136
+ # same row hit the cheap msg_id branch above.
137
+ self.state.add_processed(msg_id)
138
+ self.state.last_seq = max(self.state.last_seq, seq)
139
+ self.state.save(self.state_path)
140
+ await self.ack_callback(seq, msg_id, "received")
141
+ return
142
+
143
+ # 1i Bug C — Phase 1 demo visibility for the spawn pipeline.
144
+ logger.info(
145
+ "frame_dispatch: dispatching deliver agent=%s msg=%s wake=%s seq=%d body_len=%d",
146
+ payload.get("agent_id"),
147
+ msg_id,
148
+ wake_id,
149
+ seq,
150
+ len(payload.get("body") or ""),
151
+ )
152
+
153
+ # Bug 4 (2026-05-12) — persist the frame BEFORE the ack so a
154
+ # crash between ack and spawn doesn't silently lose it. The
155
+ # outbox is the authoritative replay source on next boot.
156
+ # If the outbox write fails we deliberately do NOT ack —
157
+ # server will redeliver on reconnect and we'll try again.
158
+ if self.spawn_outbox is not None:
159
+ try:
160
+ await self.spawn_outbox.record_received(payload)
161
+ except Exception:
162
+ logger.exception(
163
+ "frame_dispatch: spawn_outbox.record_received failed "
164
+ "agent=%s msg=%s — skipping ack, server will redeliver",
165
+ payload.get("agent_id"),
166
+ msg_id,
167
+ )
168
+ return
169
+
170
+ self.state.add_processed(msg_id)
171
+ if wake_id:
172
+ self.state.add_processed(wake_id)
173
+ self.state.last_seq = max(self.state.last_seq, seq)
174
+ self.state.save(self.state_path)
175
+
176
+ await self.ack_callback(seq, msg_id, "received")
177
+ await self.spawn_callback(payload)
178
+
179
+ def _on_tool_result(self, frame: dict) -> None:
180
+ payload = frame["payload"]
181
+ rid = payload["request_id"]
182
+ fut = self.tool_result_pending.pop(rid, None)
183
+ # 1k #55 — observability for the multi-tool wake bug. Without this
184
+ # we cannot tell whether a tool_result reached the dispatcher at
185
+ # all, whether it matched a pending future, or whether it was a
186
+ # late arrival (future already done).
187
+ if fut is None:
188
+ logger.warning(
189
+ "frame_dispatch: tool_result rid=%s — NO PENDING FUTURE "
190
+ "(late arrival, duplicate, or unknown rid)",
191
+ rid,
192
+ )
193
+ return
194
+ if fut.done():
195
+ logger.warning(
196
+ "frame_dispatch: tool_result rid=%s — future ALREADY DONE",
197
+ rid,
198
+ )
199
+ return
200
+ logger.info(
201
+ "frame_dispatch: tool_result rid=%s ok=%s — resolving future",
202
+ rid, payload.get("ok"),
203
+ )
204
+ fut.set_result(payload)
205
+
206
+ async def _on_assign(self, frame: dict) -> None:
207
+ if self.workspace_assign_callback:
208
+ await self.workspace_assign_callback(frame["payload"])
209
+
210
+ async def _on_unassign(self, frame: dict) -> None:
211
+ if self.workspace_unassign_callback:
212
+ await self.workspace_unassign_callback(frame["payload"])
213
+
214
+ async def _on_bye(self, frame: dict) -> None:
215
+ if self.bye_callback:
216
+ await self.bye_callback(frame["payload"])
@@ -0,0 +1,80 @@
1
+ """HTTP POST sender for daemon→server frames.
2
+
3
+ The SSE transport replaces the WebSocket's bidirectionality with an SSE
4
+ stream (server→daemon) plus per-frame-type POST endpoints (daemon→server).
5
+ ``HTTPOutbound`` owns the shared httpx.AsyncClient and is reused across all
6
+ POST calls so connection pooling, HTTP/2 multiplexing, and TLS sessions
7
+ amortise.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import logging
12
+ import ssl
13
+
14
+ import httpx
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+ # Outbound POST timeout. Tight enough that a hung server doesn't block
19
+ # the daemon's SpawnQueue serialise; loose enough that a slow but healthy
20
+ # server (cold-start container, GC pause) doesn't get spuriously aborted.
21
+ _POST_TIMEOUT_SECONDS = 10.0
22
+
23
+
24
+ class HTTPOutbound:
25
+ """Authorised POST sender backed by a single httpx.AsyncClient.
26
+
27
+ All POSTs share:
28
+ * Bearer authorisation (set as a default header).
29
+ * The same TLS verification policy (``insecure=True`` disables cert
30
+ validation — used for dev environments with self-signed certs).
31
+ * The base URL (``server_url``) so callers pass relative paths.
32
+ """
33
+
34
+ def __init__(
35
+ self,
36
+ server_url: str,
37
+ api_key: str,
38
+ *,
39
+ insecure: bool = False,
40
+ ) -> None:
41
+ self._server_url = server_url.rstrip("/")
42
+ self._api_key = api_key
43
+ self._insecure = insecure
44
+
45
+ verify: ssl.SSLContext | bool = not insecure
46
+ self._client = httpx.AsyncClient(
47
+ base_url=self._server_url,
48
+ verify=verify,
49
+ timeout=httpx.Timeout(_POST_TIMEOUT_SECONDS),
50
+ headers={"Authorization": f"Bearer {api_key}"},
51
+ trust_env=True,
52
+ )
53
+
54
+ async def post(self, path: str, json_body: dict) -> dict:
55
+ """POST ``json_body`` to ``path``; return parsed JSON or ``{}``.
56
+
57
+ Raises ``httpx.HTTPStatusError`` for any 4xx/5xx response so
58
+ the caller (Transport) can surface auth failures vs. transient.
59
+ """
60
+ r = await self._client.post(path, json=json_body)
61
+ r.raise_for_status()
62
+ # Some endpoints reply 204 No Content (e.g. heartbeat tolerates it
63
+ # in testing fakes); handle that without choking on empty bodies.
64
+ if r.status_code == 204 or not r.content:
65
+ return {}
66
+ try:
67
+ return r.json()
68
+ except ValueError:
69
+ # Non-JSON 200 body — treat as empty rather than crash. Real
70
+ # backend always returns JSON; this is defensive for proxies
71
+ # that inject HTML error pages.
72
+ logger.warning(
73
+ "http_outbound: non-JSON 2xx body for %s (%d bytes)",
74
+ path,
75
+ len(r.content),
76
+ )
77
+ return {}
78
+
79
+ async def close(self) -> None:
80
+ await self._client.aclose()
@@ -0,0 +1,98 @@
1
+ """Wave C #3 — opt-in JSONL log formatter.
2
+
3
+ Default off (preserves the grep-based ops flow over launchd.err.log).
4
+ When ``CLOUDAGENT_DAEMON_JSON_LOG=1`` is exported BEFORE the daemon
5
+ starts, every log record is rendered as a single-line JSON object —
6
+ key tokens (``msg`` / ``level`` / ``name``) still appear so existing
7
+ ``grep frame_dispatch`` -style searches keep working.
8
+
9
+ Schema (one object per line)::
10
+
11
+ {"ts": "2026-05-12T10:31:53Z", "level": "INFO",
12
+ "name": "cloudagent_daemon.main_loop",
13
+ "msg": "daemon ready: host_id=...", "pid": 51624,
14
+ "thread": "MainThread"}
15
+
16
+ If a log record carries ``extra={"event": ...}`` style fields, those
17
+ are merged into the top-level object (e.g. structured spawn events).
18
+ """
19
+ from __future__ import annotations
20
+
21
+ import json
22
+ import logging
23
+ import os
24
+ import threading
25
+ from datetime import UTC, datetime
26
+
27
+
28
+ _ENV_VAR = "CLOUDAGENT_DAEMON_JSON_LOG"
29
+
30
+
31
+ class JsonlFormatter(logging.Formatter):
32
+ """One-line JSON per record."""
33
+
34
+ def format(self, record: logging.LogRecord) -> str:
35
+ # Parity with :class:`logging.Formatter.format` — set
36
+ # ``record.message`` so downstream tooling (pytest's caplog,
37
+ # third-party Sentry hooks) that reads the attribute keeps
38
+ # working after the formatter runs.
39
+ record.message = record.getMessage()
40
+ # Build the canonical envelope first so the order is stable
41
+ # across records — easier on humans tailing the log.
42
+ out: dict = {
43
+ "ts": datetime.now(UTC).isoformat(),
44
+ "level": record.levelname,
45
+ "name": record.name,
46
+ "msg": record.message,
47
+ "pid": os.getpid(),
48
+ "thread": threading.current_thread().name,
49
+ }
50
+ if record.exc_info:
51
+ out["exc"] = self.formatException(record.exc_info)
52
+ # Merge any structured extras carried on the record. ``record.__dict__``
53
+ # contains a fixed set of LogRecord fields + whatever ``extra=``
54
+ # caller passed; we keep only the latter.
55
+ reserved = {
56
+ "name", "msg", "args", "levelname", "levelno", "pathname",
57
+ "filename", "module", "exc_info", "exc_text", "stack_info",
58
+ "lineno", "funcName", "created", "msecs", "relativeCreated",
59
+ "thread", "threadName", "processName", "process", "message",
60
+ }
61
+ for key, value in record.__dict__.items():
62
+ if key in reserved or key in out:
63
+ continue
64
+ try:
65
+ json.dumps(value)
66
+ out[key] = value
67
+ except (TypeError, ValueError):
68
+ # Non-JSON-able extras get a string repr — never drop
69
+ # them silently, an operator may need that hint.
70
+ out[key] = repr(value)
71
+ return json.dumps(out, ensure_ascii=False)
72
+
73
+
74
+ def jsonl_logging_enabled() -> bool:
75
+ """Return True when the env switch is set."""
76
+ return os.environ.get(_ENV_VAR, "").lower() in ("1", "true", "yes")
77
+
78
+
79
+ def configure_logging() -> None:
80
+ """Apply :class:`JsonlFormatter` to the root logger if env enables it.
81
+
82
+ Called from ``cli.start`` AFTER ``logging.basicConfig`` so we can
83
+ safely overwrite the default formatter on the root logger's
84
+ first (and only) handler.
85
+ """
86
+ if not jsonl_logging_enabled():
87
+ return
88
+ root = logging.getLogger()
89
+ fmt = JsonlFormatter()
90
+ for h in root.handlers:
91
+ h.setFormatter(fmt)
92
+
93
+
94
+ __all__ = [
95
+ "JsonlFormatter",
96
+ "configure_logging",
97
+ "jsonl_logging_enabled",
98
+ ]
@@ -0,0 +1,34 @@
1
+ """Login verification logic for cloudagent-daemon.
2
+
3
+ Separated from cli.py for testability.
4
+ """
5
+ from __future__ import annotations
6
+
7
+
8
+ async def verify_and_capture_host_id(
9
+ server_url: str,
10
+ api_key: str,
11
+ *,
12
+ insecure: bool = False,
13
+ ) -> str:
14
+ """Open a quick WS connection to verify key, capture host_id, then close.
15
+
16
+ Raises AuthFailed if 401/403; raises RuntimeError on other connect failures.
17
+
18
+ ``insecure=True`` skips TLS certificate verification — only use for known
19
+ self-signed dev/staging hosts (e.g. fr pre-Wave-7).
20
+ """
21
+ from .ws_client import WSClient
22
+
23
+ async with WSClient(server_url, api_key, insecure=insecure) as ws:
24
+ return ws.host_id
25
+
26
+
27
+ def normalize_server_url(url: str) -> str:
28
+ """Trim trailing /, strip /v1 suffix if present, validate http(s) scheme."""
29
+ url = url.rstrip("/")
30
+ if url.endswith("/v1"):
31
+ url = url[:-3]
32
+ if not url.startswith(("http://", "https://")):
33
+ raise ValueError(f"server URL must start with http:// or https://: {url!r}")
34
+ return url