alter-runtime 0.3.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.
- alter_runtime/__init__.py +11 -0
- alter_runtime/adapters/__init__.py +19 -0
- alter_runtime/adapters/claude_jsonl_watcher.py +545 -0
- alter_runtime/adapters/git_watcher.py +457 -0
- alter_runtime/adapters/household/__init__.py +29 -0
- alter_runtime/adapters/household/_base.py +138 -0
- alter_runtime/adapters/household/compost/__init__.py +17 -0
- alter_runtime/adapters/household/compost/adapter.py +81 -0
- alter_runtime/adapters/household/compost/storage.py +75 -0
- alter_runtime/adapters/household/compost/tests/__init__.py +0 -0
- alter_runtime/adapters/household/compost/tests/test_adapter.py +62 -0
- alter_runtime/adapters/household/compost/tests/test_storage.py +23 -0
- alter_runtime/adapters/household/compost/tests/test_traits.py +38 -0
- alter_runtime/adapters/household/compost/traits.py +79 -0
- alter_runtime/adapters/household/self_hoster/__init__.py +30 -0
- alter_runtime/adapters/household/self_hoster/adapter.py +248 -0
- alter_runtime/adapters/household/self_hoster/storage.py +83 -0
- alter_runtime/adapters/household/self_hoster/tests/__init__.py +0 -0
- alter_runtime/adapters/household/self_hoster/tests/test_adapter.py +216 -0
- alter_runtime/adapters/household/self_hoster/tests/test_storage.py +25 -0
- alter_runtime/adapters/household/self_hoster/tests/test_traits.py +55 -0
- alter_runtime/adapters/household/self_hoster/traits.py +105 -0
- alter_runtime/adapters/household/tapo_ecosystem/__init__.py +22 -0
- alter_runtime/adapters/household/tapo_ecosystem/adapter.py +98 -0
- alter_runtime/adapters/household/tapo_ecosystem/storage.py +95 -0
- alter_runtime/adapters/household/tapo_ecosystem/tests/__init__.py +0 -0
- alter_runtime/adapters/household/tapo_ecosystem/tests/test_adapter.py +55 -0
- alter_runtime/adapters/household/tapo_ecosystem/tests/test_storage.py +28 -0
- alter_runtime/adapters/household/tapo_ecosystem/tests/test_traits.py +45 -0
- alter_runtime/adapters/household/tapo_ecosystem/traits.py +97 -0
- alter_runtime/adapters/household/workshop_tools/__init__.py +25 -0
- alter_runtime/adapters/household/workshop_tools/adapter.py +77 -0
- alter_runtime/adapters/household/workshop_tools/storage.py +92 -0
- alter_runtime/adapters/household/workshop_tools/tests/__init__.py +0 -0
- alter_runtime/adapters/household/workshop_tools/tests/test_adapter.py +48 -0
- alter_runtime/adapters/household/workshop_tools/tests/test_storage.py +26 -0
- alter_runtime/adapters/household/workshop_tools/tests/test_traits.py +45 -0
- alter_runtime/adapters/household/workshop_tools/traits.py +95 -0
- alter_runtime/adapters/worktree_watcher.py +378 -0
- alter_runtime/atlas/__init__.py +48 -0
- alter_runtime/atlas/base.py +102 -0
- alter_runtime/atlas/ledger.py +196 -0
- alter_runtime/atlas/observations.py +136 -0
- alter_runtime/atlas/schema.py +106 -0
- alter_runtime/cap_cache.py +392 -0
- alter_runtime/cli.py +517 -0
- alter_runtime/clients/__init__.py +0 -0
- alter_runtime/clients/token_usage_client.py +273 -0
- alter_runtime/config.py +648 -0
- alter_runtime/consent.py +425 -0
- alter_runtime/daemon.py +518 -0
- alter_runtime/floor_loop.py +335 -0
- alter_runtime/floor_preflight.py +734 -0
- alter_runtime/http_auth.py +173 -0
- alter_runtime/notifiers/__init__.py +18 -0
- alter_runtime/notifiers/desktop.py +321 -0
- alter_runtime/sdk/__init__.py +12 -0
- alter_runtime/sdk/client.py +399 -0
- alter_runtime/service_install.py +616 -0
- alter_runtime/services/__init__.py +59 -0
- alter_runtime/services/launchd/com.alter.runtime.plist.in +90 -0
- alter_runtime/services/systemd/alter-runtime.service.in +74 -0
- alter_runtime/services/systemd/cf-access-env.conf.in +29 -0
- alter_runtime/sockets/__init__.py +20 -0
- alter_runtime/sockets/dbus.py +272 -0
- alter_runtime/sockets/unix.py +702 -0
- alter_runtime/subscribers/__init__.py +58 -0
- alter_runtime/subscribers/active_sessions_cron_emitter.py +313 -0
- alter_runtime/subscribers/active_sessions_do_publisher.py +1159 -0
- alter_runtime/subscribers/active_sessions_gc.py +432 -0
- alter_runtime/subscribers/active_sessions_writer.py +446 -0
- alter_runtime/subscribers/adapters_writer.py +415 -0
- alter_runtime/subscribers/agent_frames.py +461 -0
- alter_runtime/subscribers/bus.py +188 -0
- alter_runtime/subscribers/cache_writer.py +347 -0
- alter_runtime/subscribers/ceremony_echo.py +290 -0
- alter_runtime/subscribers/do_sse.py +864 -0
- alter_runtime/subscribers/ebpf.py +506 -0
- alter_runtime/subscribers/inbox_writer.py +469 -0
- alter_runtime/subscribers/mcp_fallback.py +391 -0
- alter_runtime/subscribers/presence_writer.py +426 -0
- alter_runtime/subscribers/session_presence.py +467 -0
- alter_runtime/subscribers/sse.py +125 -0
- alter_runtime/subscribers/weave_intent_writer.py +608 -0
- alter_runtime/update_loop.py +519 -0
- alter_runtime/weave/__init__.py +21 -0
- alter_runtime/weave/resolver.py +544 -0
- alter_runtime-0.3.0.dist-info/METADATA +289 -0
- alter_runtime-0.3.0.dist-info/RECORD +92 -0
- alter_runtime-0.3.0.dist-info/WHEEL +4 -0
- alter_runtime-0.3.0.dist-info/entry_points.txt +2 -0
- alter_runtime-0.3.0.dist-info/licenses/LICENSE +190 -0
|
@@ -0,0 +1,392 @@
|
|
|
1
|
+
"""DaemonCapCache - machine-wide cap-JWT and query-result cache for the daemon.
|
|
2
|
+
|
|
3
|
+
One instance is created at daemon startup and shared across all Unix socket
|
|
4
|
+
clients. This collapses the N independent cap-mint calls (one per CC bridge
|
|
5
|
+
process) that previously ran against the server's 6/min/handle bucket into a
|
|
6
|
+
single minting identity per handle-scope set.
|
|
7
|
+
|
|
8
|
+
Design
|
|
9
|
+
------
|
|
10
|
+
|
|
11
|
+
*Cap cache* (``cap.get`` RPC):
|
|
12
|
+
Mints once per sorted-scope-set, caches the resulting JWT in-memory.
|
|
13
|
+
Refresh fires 30 s before declared ``expires_at`` (same leeway as
|
|
14
|
+
:class:`~alter_runtime.subscribers.active_sessions_do_publisher._CachedCap`).
|
|
15
|
+
Server TTL is clamped to [30, 300] s server-side; the client-side leeway
|
|
16
|
+
means steady-state mint rate is at most once per (TTL - 30 s) window.
|
|
17
|
+
On 401/403 from the caller's upstream, the caller drops the entry via
|
|
18
|
+
:meth:`invalidate_cap` and the next ``cap.get`` re-mints immediately.
|
|
19
|
+
|
|
20
|
+
*Query cache* (``query.get`` RPC):
|
|
21
|
+
Caches the JSON body of a ``GET /orgs/{slug}/queries/{path}`` response
|
|
22
|
+
for 15 s (``QUERY_CACHE_TTL``). Keyed on ``(path, frozen_params)``.
|
|
23
|
+
Stale entries are evicted on next access (read-through TTL). No push
|
|
24
|
+
invalidation in v1.
|
|
25
|
+
|
|
26
|
+
Auth
|
|
27
|
+
----
|
|
28
|
+
|
|
29
|
+
Both RPC methods are served by the existing Unix socket auth handshake
|
|
30
|
+
(``{"method": "auth", "token": "<t>"}``). No new auth scheme is added.
|
|
31
|
+
|
|
32
|
+
Thread safety
|
|
33
|
+
-------------
|
|
34
|
+
|
|
35
|
+
All operations are synchronous reads/writes to plain dicts guarded by the
|
|
36
|
+
asyncio event loop. No additional locking is required because the daemon
|
|
37
|
+
runs in a single-threaded asyncio event loop where coroutine scheduling is
|
|
38
|
+
cooperative - no concurrent access to the dicts is possible.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
from __future__ import annotations
|
|
42
|
+
|
|
43
|
+
import json
|
|
44
|
+
import logging
|
|
45
|
+
import sys
|
|
46
|
+
import time
|
|
47
|
+
from dataclasses import dataclass
|
|
48
|
+
from datetime import datetime
|
|
49
|
+
from typing import TYPE_CHECKING, Any
|
|
50
|
+
|
|
51
|
+
import httpx
|
|
52
|
+
|
|
53
|
+
if TYPE_CHECKING:
|
|
54
|
+
from alter_runtime.config import Session
|
|
55
|
+
|
|
56
|
+
__all__ = [
|
|
57
|
+
"CAP_CACHE_REFRESH_LEAD_SECONDS",
|
|
58
|
+
"CAP_CACHE_TTL_MIN",
|
|
59
|
+
"CAP_CACHE_TTL_MAX",
|
|
60
|
+
"QUERY_CACHE_TTL",
|
|
61
|
+
"DaemonCapCache",
|
|
62
|
+
]
|
|
63
|
+
|
|
64
|
+
logger = logging.getLogger("alter_runtime.cap_cache")
|
|
65
|
+
|
|
66
|
+
#: Re-mint leeway - same value as the publisher's ``CAP_REFRESH_LEAD_SECONDS``
|
|
67
|
+
#: so both components share one effective policy.
|
|
68
|
+
CAP_CACHE_REFRESH_LEAD_SECONDS: float = 30.0
|
|
69
|
+
|
|
70
|
+
#: Minimum server-declared TTL we honour. Values below this are clamped up.
|
|
71
|
+
CAP_CACHE_TTL_MIN: float = 30.0
|
|
72
|
+
|
|
73
|
+
#: Maximum server-declared TTL we honour. Values above this are clamped down.
|
|
74
|
+
CAP_CACHE_TTL_MAX: float = 300.0
|
|
75
|
+
|
|
76
|
+
#: Query result cache TTL in seconds. Fixed for v1 - no push invalidation.
|
|
77
|
+
QUERY_CACHE_TTL: float = 15.0
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
# ---------------------------------------------------------------------------
|
|
81
|
+
# Internal data classes
|
|
82
|
+
# ---------------------------------------------------------------------------
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@dataclass
|
|
86
|
+
class _CachedCap:
|
|
87
|
+
"""Cached cap-JWT keyed on a sorted-scope-set."""
|
|
88
|
+
|
|
89
|
+
capability: str
|
|
90
|
+
expires_at_unix: float
|
|
91
|
+
# Unlimited multi-use; the daemon never tracks per-use accounting here
|
|
92
|
+
# (the Worker validates TTL + scope only).
|
|
93
|
+
uses_available: int = sys.maxsize
|
|
94
|
+
use_counter: int = 0
|
|
95
|
+
|
|
96
|
+
def is_fresh(self, now: float) -> bool:
|
|
97
|
+
return self.expires_at_unix - now > CAP_CACHE_REFRESH_LEAD_SECONDS
|
|
98
|
+
|
|
99
|
+
def has_uses(self) -> bool:
|
|
100
|
+
return self.use_counter < self.uses_available
|
|
101
|
+
|
|
102
|
+
def take_use(self) -> None:
|
|
103
|
+
self.use_counter += 1
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
@dataclass
|
|
107
|
+
class _CachedQuery:
|
|
108
|
+
"""Cached query-GET response body."""
|
|
109
|
+
|
|
110
|
+
body: Any
|
|
111
|
+
cached_at: float
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
# ---------------------------------------------------------------------------
|
|
115
|
+
# Exception types
|
|
116
|
+
# ---------------------------------------------------------------------------
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
class _CapMintError(Exception):
|
|
120
|
+
"""Raised when the cap-mint endpoint refuses or returns a malformed body."""
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
class _SessionMissing(Exception):
|
|
124
|
+
"""Raised when no session is available to mint a cap."""
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
# ---------------------------------------------------------------------------
|
|
128
|
+
# Cache
|
|
129
|
+
# ---------------------------------------------------------------------------
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
class DaemonCapCache:
|
|
133
|
+
"""Machine-wide cap and query cache.
|
|
134
|
+
|
|
135
|
+
Parameters
|
|
136
|
+
----------
|
|
137
|
+
session:
|
|
138
|
+
Authenticated alter-cli :class:`~alter_runtime.config.Session`.
|
|
139
|
+
Used for the Bearer JWT when minting capability tokens. Pass
|
|
140
|
+
``None`` to construct the cache in degraded mode (all ``cap.get``
|
|
141
|
+
calls will fail with ``session_missing``).
|
|
142
|
+
http_client:
|
|
143
|
+
Optional ``httpx.AsyncClient`` override. When ``None``, the caller
|
|
144
|
+
must supply the client via the ``client`` parameter on each method
|
|
145
|
+
call (used by the Unix socket server which shares the daemon's
|
|
146
|
+
single client instance).
|
|
147
|
+
"""
|
|
148
|
+
|
|
149
|
+
def __init__(
|
|
150
|
+
self,
|
|
151
|
+
session: "Session | None",
|
|
152
|
+
*,
|
|
153
|
+
http_client: httpx.AsyncClient | None = None,
|
|
154
|
+
) -> None:
|
|
155
|
+
self._session = session
|
|
156
|
+
self._http_client = http_client
|
|
157
|
+
# Cap cache: keyed on frozenset of scope strings.
|
|
158
|
+
self._caps: dict[frozenset[str], _CachedCap] = {}
|
|
159
|
+
# Query cache: keyed on (path, frozenset of sorted param items).
|
|
160
|
+
self._queries: dict[tuple[str, frozenset[tuple[str, str]]], _CachedQuery] = {}
|
|
161
|
+
|
|
162
|
+
# ------------------------------------------------------------------
|
|
163
|
+
# Public interface (called from unix.py dispatch)
|
|
164
|
+
# ------------------------------------------------------------------
|
|
165
|
+
|
|
166
|
+
async def get_cap(
|
|
167
|
+
self,
|
|
168
|
+
scopes: list[str],
|
|
169
|
+
*,
|
|
170
|
+
client: httpx.AsyncClient | None = None,
|
|
171
|
+
) -> dict[str, Any]:
|
|
172
|
+
"""Return a fresh cap-JWT for ``scopes``.
|
|
173
|
+
|
|
174
|
+
Returns::
|
|
175
|
+
|
|
176
|
+
{
|
|
177
|
+
"ok": True,
|
|
178
|
+
"capability": "<jwt>",
|
|
179
|
+
"expires_at": "<iso8601>",
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
or::
|
|
183
|
+
|
|
184
|
+
{
|
|
185
|
+
"ok": False,
|
|
186
|
+
"error": "<reason>",
|
|
187
|
+
}
|
|
188
|
+
"""
|
|
189
|
+
http = client or self._http_client
|
|
190
|
+
if http is None:
|
|
191
|
+
return {"ok": False, "error": "no http client available"}
|
|
192
|
+
|
|
193
|
+
scope_key = frozenset(scopes)
|
|
194
|
+
try:
|
|
195
|
+
cap_jwt = await self._ensure_cap(http, scope_key)
|
|
196
|
+
except _SessionMissing:
|
|
197
|
+
return {"ok": False, "error": "session_missing"}
|
|
198
|
+
except _CapMintError as exc:
|
|
199
|
+
return {"ok": False, "error": f"cap_mint_error: {exc}"}
|
|
200
|
+
except httpx.HTTPError as exc:
|
|
201
|
+
return {"ok": False, "error": f"http_error: {exc}"}
|
|
202
|
+
|
|
203
|
+
cached = self._caps.get(scope_key)
|
|
204
|
+
expires_iso = ""
|
|
205
|
+
if cached is not None:
|
|
206
|
+
try:
|
|
207
|
+
expires_iso = datetime.utcfromtimestamp(cached.expires_at_unix).strftime(
|
|
208
|
+
"%Y-%m-%dT%H:%M:%SZ"
|
|
209
|
+
)
|
|
210
|
+
except (OSError, OverflowError, ValueError):
|
|
211
|
+
expires_iso = ""
|
|
212
|
+
|
|
213
|
+
return {"ok": True, "capability": cap_jwt, "expires_at": expires_iso}
|
|
214
|
+
|
|
215
|
+
async def get_query(
|
|
216
|
+
self,
|
|
217
|
+
path: str,
|
|
218
|
+
params: dict[str, Any] | None,
|
|
219
|
+
*,
|
|
220
|
+
client: httpx.AsyncClient | None = None,
|
|
221
|
+
) -> dict[str, Any]:
|
|
222
|
+
"""Return a cached (or freshly fetched) query result.
|
|
223
|
+
|
|
224
|
+
Returns::
|
|
225
|
+
|
|
226
|
+
{
|
|
227
|
+
"ok": True,
|
|
228
|
+
"body": <any json>,
|
|
229
|
+
"cached_at": <float epoch>,
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
or::
|
|
233
|
+
|
|
234
|
+
{
|
|
235
|
+
"ok": False,
|
|
236
|
+
"error": "<reason>",
|
|
237
|
+
}
|
|
238
|
+
"""
|
|
239
|
+
http = client or self._http_client
|
|
240
|
+
if http is None:
|
|
241
|
+
return {"ok": False, "error": "no http client available"}
|
|
242
|
+
|
|
243
|
+
# Normalise params into a stable hashable key.
|
|
244
|
+
frozen_params: frozenset[tuple[str, str]]
|
|
245
|
+
if params:
|
|
246
|
+
frozen_params = frozenset((str(k), str(v)) for k, v in sorted(params.items()))
|
|
247
|
+
else:
|
|
248
|
+
frozen_params = frozenset()
|
|
249
|
+
|
|
250
|
+
cache_key = (path, frozen_params)
|
|
251
|
+
now = time.time()
|
|
252
|
+
|
|
253
|
+
# Cache hit: return immediately if within TTL.
|
|
254
|
+
cached = self._queries.get(cache_key)
|
|
255
|
+
if cached is not None and (now - cached.cached_at) < QUERY_CACHE_TTL:
|
|
256
|
+
return {"ok": True, "body": cached.body, "cached_at": cached.cached_at}
|
|
257
|
+
|
|
258
|
+
# Cache miss or stale: fetch fresh.
|
|
259
|
+
try:
|
|
260
|
+
body = await self._fetch_query(http, path, params or {})
|
|
261
|
+
except _SessionMissing:
|
|
262
|
+
return {"ok": False, "error": "session_missing"}
|
|
263
|
+
except _CapMintError as exc:
|
|
264
|
+
return {"ok": False, "error": f"cap_mint_error: {exc}"}
|
|
265
|
+
except httpx.HTTPError as exc:
|
|
266
|
+
return {"ok": False, "error": f"http_error: {exc}"}
|
|
267
|
+
|
|
268
|
+
entry = _CachedQuery(body=body, cached_at=time.time())
|
|
269
|
+
self._queries[cache_key] = entry
|
|
270
|
+
return {"ok": True, "body": entry.body, "cached_at": entry.cached_at}
|
|
271
|
+
|
|
272
|
+
def invalidate_cap(self, scopes: list[str]) -> None:
|
|
273
|
+
"""Drop the cached cap for ``scopes`` so the next ``cap.get`` re-mints.
|
|
274
|
+
|
|
275
|
+
Called by the Unix socket server on 401/403 from the caller's upstream.
|
|
276
|
+
"""
|
|
277
|
+
self._caps.pop(frozenset(scopes), None)
|
|
278
|
+
|
|
279
|
+
def update_session(self, session: "Session | None") -> None:
|
|
280
|
+
"""Replace the session (called when the daemon reloads session.json)."""
|
|
281
|
+
self._session = session
|
|
282
|
+
|
|
283
|
+
# ------------------------------------------------------------------
|
|
284
|
+
# Internal helpers
|
|
285
|
+
# ------------------------------------------------------------------
|
|
286
|
+
|
|
287
|
+
async def _ensure_cap(
|
|
288
|
+
self,
|
|
289
|
+
http: httpx.AsyncClient,
|
|
290
|
+
scope_key: frozenset[str],
|
|
291
|
+
) -> str:
|
|
292
|
+
"""Return a fresh capability JWT, minting if cache is stale or empty."""
|
|
293
|
+
now = time.time()
|
|
294
|
+
cached = self._caps.get(scope_key)
|
|
295
|
+
if cached is not None and cached.is_fresh(now) and cached.has_uses():
|
|
296
|
+
cached.take_use()
|
|
297
|
+
return cached.capability
|
|
298
|
+
|
|
299
|
+
session = self._session
|
|
300
|
+
if session is None:
|
|
301
|
+
raise _SessionMissing()
|
|
302
|
+
|
|
303
|
+
# Mint a new cap via the handle-alter realm endpoint. Sorted scopes
|
|
304
|
+
# are sent as a list so the server can validate them all at once.
|
|
305
|
+
url = f"{session.api.rstrip('/')}/api/v1/messaging/sessions-ingest-capability"
|
|
306
|
+
headers = {
|
|
307
|
+
"Authorization": f"Bearer {session.jwt}",
|
|
308
|
+
"Accept": "application/json",
|
|
309
|
+
}
|
|
310
|
+
# The parameterless endpoint is the correct path for
|
|
311
|
+
# ``alter_events.sessions.ingest`` scoped caps (D-COORD-D2 Wave C).
|
|
312
|
+
# For generic scopes (cap.get called with other scopes) the same
|
|
313
|
+
# endpoint is used; the server rejects unknown scopes with 422 which
|
|
314
|
+
# surfaces as a _CapMintError.
|
|
315
|
+
response = await http.post(url, headers=headers)
|
|
316
|
+
|
|
317
|
+
if response.status_code in (401, 403):
|
|
318
|
+
raise _CapMintError(
|
|
319
|
+
f"cap-mint rejected (HTTP {response.status_code}): {response.text[:200]}"
|
|
320
|
+
)
|
|
321
|
+
response.raise_for_status()
|
|
322
|
+
|
|
323
|
+
try:
|
|
324
|
+
data = response.json()
|
|
325
|
+
except (ValueError, json.JSONDecodeError) as exc:
|
|
326
|
+
raise _CapMintError("cap-mint returned non-JSON body") from exc
|
|
327
|
+
|
|
328
|
+
if not isinstance(data, dict):
|
|
329
|
+
raise _CapMintError("cap-mint returned non-object body")
|
|
330
|
+
|
|
331
|
+
capability = data.get("capability")
|
|
332
|
+
expires_at = data.get("expires_at")
|
|
333
|
+
if not isinstance(capability, str) or not capability:
|
|
334
|
+
raise _CapMintError("cap-mint response missing capability")
|
|
335
|
+
if not isinstance(expires_at, str) or not expires_at:
|
|
336
|
+
raise _CapMintError("cap-mint response missing expires_at")
|
|
337
|
+
|
|
338
|
+
try:
|
|
339
|
+
expires_at_unix = datetime.fromisoformat(expires_at.replace("Z", "+00:00")).timestamp()
|
|
340
|
+
except ValueError as exc:
|
|
341
|
+
raise _CapMintError(f"cap-mint returned non-ISO expires_at: {expires_at}") from exc
|
|
342
|
+
|
|
343
|
+
# Clamp TTL to [CAP_CACHE_TTL_MIN, CAP_CACHE_TTL_MAX].
|
|
344
|
+
now2 = time.time()
|
|
345
|
+
raw_ttl = expires_at_unix - now2
|
|
346
|
+
clamped_ttl = max(CAP_CACHE_TTL_MIN, min(raw_ttl, CAP_CACHE_TTL_MAX))
|
|
347
|
+
if clamped_ttl != raw_ttl:
|
|
348
|
+
expires_at_unix = now2 + clamped_ttl
|
|
349
|
+
|
|
350
|
+
cap = _CachedCap(
|
|
351
|
+
capability=capability,
|
|
352
|
+
expires_at_unix=expires_at_unix,
|
|
353
|
+
)
|
|
354
|
+
cap.take_use()
|
|
355
|
+
self._caps[scope_key] = cap
|
|
356
|
+
return capability
|
|
357
|
+
|
|
358
|
+
async def _fetch_query(
|
|
359
|
+
self,
|
|
360
|
+
http: httpx.AsyncClient,
|
|
361
|
+
path: str,
|
|
362
|
+
params: dict[str, Any],
|
|
363
|
+
) -> Any:
|
|
364
|
+
"""Fetch ``GET /orgs/{slug}/queries/{path}`` with a fresh cap."""
|
|
365
|
+
session = self._session
|
|
366
|
+
if session is None:
|
|
367
|
+
raise _SessionMissing()
|
|
368
|
+
|
|
369
|
+
# Use the default ingest scope to cap-gate query requests, matching
|
|
370
|
+
# the server-side scope requirement. The scope key used here must
|
|
371
|
+
# match what the caller registered (frozenset of the same scope list).
|
|
372
|
+
scope_key = frozenset(["alter_events.sessions.ingest"])
|
|
373
|
+
cap_jwt = await self._ensure_cap(http, scope_key)
|
|
374
|
+
|
|
375
|
+
url = f"{session.api.rstrip('/')}/orgs/queries/{path.lstrip('/')}"
|
|
376
|
+
headers = {
|
|
377
|
+
"Authorization": f"Bearer {cap_jwt}",
|
|
378
|
+
"Accept": "application/json",
|
|
379
|
+
}
|
|
380
|
+
response = await http.get(url, params=params or None, headers=headers)
|
|
381
|
+
|
|
382
|
+
if response.status_code in (401, 403):
|
|
383
|
+
# Drop the cap entry so the next call re-mints.
|
|
384
|
+
self._caps.pop(scope_key, None)
|
|
385
|
+
raise _CapMintError(f"query cap rejected (HTTP {response.status_code})")
|
|
386
|
+
|
|
387
|
+
response.raise_for_status()
|
|
388
|
+
|
|
389
|
+
try:
|
|
390
|
+
return response.json()
|
|
391
|
+
except (ValueError, json.JSONDecodeError):
|
|
392
|
+
return response.text
|