fp-cloud-cli 0.0.1b1__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.
Files changed (50) hide show
  1. fp_cli/__init__.py +10 -0
  2. fp_cli/__main__.py +4 -0
  3. fp_cli/_click_compat.py +64 -0
  4. fp_cli/_context.py +332 -0
  5. fp_cli/_version.py +1 -0
  6. fp_cli/analytics.py +432 -0
  7. fp_cli/analytics_config.py +77 -0
  8. fp_cli/analytics_registry.py +83 -0
  9. fp_cli/app.py +492 -0
  10. fp_cli/auth.py +160 -0
  11. fp_cli/client.py +1694 -0
  12. fp_cli/commands/__init__.py +0 -0
  13. fp_cli/commands/_write.py +214 -0
  14. fp_cli/commands/agent_cmds.py +407 -0
  15. fp_cli/commands/alerts_cmds.py +445 -0
  16. fp_cli/commands/audits_cmds.py +1054 -0
  17. fp_cli/commands/auth_cmds.py +512 -0
  18. fp_cli/commands/errors_cmds.py +190 -0
  19. fp_cli/commands/evals_cmds.py +161 -0
  20. fp_cli/commands/events_cmds.py +159 -0
  21. fp_cli/commands/fleet_cmds.py +416 -0
  22. fp_cli/commands/guardrails_cmds.py +148 -0
  23. fp_cli/commands/incidents_cmds.py +472 -0
  24. fp_cli/commands/keys_cmds.py +407 -0
  25. fp_cli/commands/list_cmds.py +63 -0
  26. fp_cli/commands/orgs_cmds.py +319 -0
  27. fp_cli/commands/policies_cmds.py +499 -0
  28. fp_cli/commands/queries_cmds.py +378 -0
  29. fp_cli/commands/sessions_cmds.py +151 -0
  30. fp_cli/commands/settings_cmds.py +150 -0
  31. fp_cli/commands/usage_cmds.py +35 -0
  32. fp_cli/commands/users_cmds.py +404 -0
  33. fp_cli/config.py +330 -0
  34. fp_cli/dates.py +78 -0
  35. fp_cli/enforcement.py +345 -0
  36. fp_cli/errors.py +98 -0
  37. fp_cli/models.py +891 -0
  38. fp_cli/orgs.py +30 -0
  39. fp_cli/output.py +6593 -0
  40. fp_cli/permissions.py +208 -0
  41. fp_cli/policy_check.py +290 -0
  42. fp_cli/py.typed +0 -0
  43. fp_cli/select.py +322 -0
  44. fp_cli/theme.py +53 -0
  45. fp_cloud_cli-0.0.1b1.dist-info/METADATA +335 -0
  46. fp_cloud_cli-0.0.1b1.dist-info/RECORD +50 -0
  47. fp_cloud_cli-0.0.1b1.dist-info/WHEEL +5 -0
  48. fp_cloud_cli-0.0.1b1.dist-info/entry_points.txt +2 -0
  49. fp_cloud_cli-0.0.1b1.dist-info/licenses/LICENSE +42 -0
  50. fp_cloud_cli-0.0.1b1.dist-info/top_level.txt +1 -0
fp_cli/client.py ADDED
@@ -0,0 +1,1694 @@
1
+ """Pure query layer for the FailproofAI Cloud API.
2
+
3
+ Every function takes a :class:`ClientContext` and returns plain dataclasses or
4
+ primitives. Nothing here prints or imports Typer/Rich — this is the surface a
5
+ future MCP server wraps directly. :class:`AuthMode` is defined here rather than in
6
+ ``_context`` for the same reason the dependency runs this way round: ``_context``
7
+ imports *this* module, and the transport below needs the enum at runtime to pick
8
+ bearer vs cookie. ``_context`` re-exports it.
9
+
10
+ Two auth modes, and they never mix:
11
+
12
+ * **session** — the ``ae_session`` cookie against the dashboard's ``/api/*``
13
+ routes (its ``withAuth`` reads the cookie only; it does not accept a bearer).
14
+ * **api_key** — ``Authorization: Bearer <key>`` against the server's curated
15
+ versioned API at ``/v1/*``. Every path is translated at the four request
16
+ chokepoints below (see :func:`_v1_path`), never at the ~70 call sites.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import json as _json
22
+ import uuid
23
+ from dataclasses import dataclass
24
+ from enum import Enum
25
+ from typing import Any, Callable, Dict, Iterator, List, Optional, Sequence, Union
26
+
27
+ import httpx
28
+
29
+ from .errors import (
30
+ ApiError,
31
+ AuthError,
32
+ ForbiddenError,
33
+ KeyModeUnsupportedError,
34
+ NetworkError,
35
+ NotFoundError,
36
+ )
37
+ from .models import (
38
+ AgentEvent,
39
+ Alert,
40
+ ApiKey,
41
+ Audit,
42
+ AuditFinding,
43
+ AuditRun,
44
+ DashboardUser,
45
+ Deployment,
46
+ Evaluation,
47
+ Incident,
48
+ IncidentComment,
49
+ IncidentSubscriber,
50
+ Machine,
51
+ Page,
52
+ PolicyRef,
53
+ PolicyVersion,
54
+ QueryResult,
55
+ SavedQuery,
56
+ Session,
57
+ SessionUser,
58
+ SettingRow,
59
+ )
60
+
61
+ MAX_PAGE_SIZE = 200
62
+
63
+
64
+ class AuthMode(str, Enum):
65
+ """Which credential this invocation carries — an explicit state, never inferred.
66
+
67
+ Resolved once from the flags/env/saved config (see ``_context.resolve_auth``) and
68
+ then carried on both ``AppState`` and :class:`ClientContext`. It is an enum rather
69
+ than ``if state.api_key`` because the empty-string cases have to stay
70
+ distinguishable: ``--api-key ""`` is *key mode with no credential* (an error), NOT
71
+ "fall back to whatever session happens to be saved on this machine".
72
+
73
+ ``str``-valued so the telemetry property is the enum itself — one closed set, no
74
+ second hand-written mapping to drift.
75
+ """
76
+
77
+ SESSION = "session"
78
+ API_KEY = "api_key"
79
+ NONE = "none"
80
+
81
+
82
+ @dataclass
83
+ class ClientContext:
84
+ base_url: str
85
+ token: Optional[str] = None
86
+ timeout: float = 30.0
87
+ transport: Optional[httpx.BaseTransport] = None
88
+ verify: bool = True
89
+ org: Optional[str] = None # active tenant slug -> X-AgentEye-Org header
90
+ api_key: Optional[str] = None # bearer credential; only read in AuthMode.API_KEY
91
+ # Defaults to SESSION so every existing construction site (login, the org probe,
92
+ # tests) keeps its cookie behaviour unchanged.
93
+ auth_mode: AuthMode = AuthMode.SESSION
94
+
95
+
96
+ # --- /v1 translation (API-key mode only) ------------------------------------
97
+ #
98
+ # The CLI's ~70 call sites all name the DASHBOARD's proxy path (`/api/...`). An
99
+ # API key cannot use those: `withAuth` reads the `ae_session` cookie and nothing
100
+ # else. Key mode therefore targets the server's curated versioned API directly,
101
+ # and the rewrite happens HERE — at the four request chokepoints — so no call
102
+ # site can forget it.
103
+ #
104
+ # It is deliberately NOT a blind `s|^/api|/v1|`. Two families would break
105
+ # silently under that:
106
+ #
107
+ # * `/api/evaluations/score-keys` is a RENAME invented by the proxy — the
108
+ # server route is `evaluations/score_keys` (see
109
+ # dashboard/app/api/evaluations/score-keys/route.ts). A blind swap 404s and
110
+ # the CLI reports a cheerful "Not found."
111
+ # * `/api/auth/*` and `/api/agent/*` have NO `/v1` equivalent at all. The auth
112
+ # and conversation routes are deliberately excluded from the version
113
+ # contract, and `agent/chat` + `agent/health` exist only in the dashboard —
114
+ # there is no server route to reach.
115
+ #
116
+ # Anything else is unclassified, and unclassified must be LOUD: a new call site
117
+ # that quietly passed through would produce a wrong URL, and the failure would
118
+ # arrive months later as "the CLI 404s in CI". `tests/test_v1_routing.py`
119
+ # AST-scans this file for every `/api/` literal and asserts each one lands in
120
+ # exactly one of these three buckets, then checks the resulting `/v1` paths
121
+ # against the server router's own `.route()` literals.
122
+
123
+ _API_PREFIX = "/api/"
124
+
125
+ # `/api/<family>/...` -> `/v1/<family>/...`, byte-identical below the prefix.
126
+ # Keyed on the FIRST path segment: a family is either mirrored wholesale or not
127
+ # at all, and listing families (not paths) keeps this honest without a 60-entry
128
+ # table that nobody would maintain.
129
+ _V1_MECHANICAL_FAMILIES = frozenset(
130
+ {
131
+ "access-granters",
132
+ "alerts",
133
+ "audits",
134
+ "evaluations",
135
+ "events",
136
+ "issues",
137
+ "keys",
138
+ "permission-sets",
139
+ "queries",
140
+ "sessions",
141
+ "settings",
142
+ # Organization usage / billing windows. Mechanical: the server registers
143
+ # /usage and /usage/windows inside `versioned_routes`, so both are on /v1.
144
+ "usage",
145
+ "users",
146
+ }
147
+ )
148
+
149
+ # Exact paths the dashboard proxy renames on the way through. Checked BEFORE the
150
+ # family rule, which is why this must stay exact-match.
151
+ _V1_RENAMED = {
152
+ "/api/evaluations/score-keys": "/v1/evaluations/score_keys",
153
+ }
154
+
155
+ # Families with no `/v1` route, and why — the message a user actually sees.
156
+ _V1_NO_EQUIVALENT = {
157
+ "auth": (
158
+ "the sign-in endpoints are deliberately absent from /v1 — they take a browser "
159
+ "session, not an API key"
160
+ ),
161
+ "agent": (
162
+ "the assistant is implemented by the dashboard, not the API — there is no /v1 "
163
+ "route behind it"
164
+ ),
165
+ # ROOT-ONLY on the server, and deliberately so: `/v1` is published on the
166
+ # dashboard host by the ingress, and publish/deploy/rollback are operator
167
+ # writes gated on `policies:write`. Exposing them there would put fleet
168
+ # mutation on the open internet. See the ROOT-ONLY block in
169
+ # `server/src/routes/mod.rs`.
170
+ "enforcement": (
171
+ "cloud-managed policies are an operator surface — the fleet routes are "
172
+ "deliberately absent from /v1, which is internet-facing"
173
+ ),
174
+ }
175
+
176
+
177
+ def _v1_path(path: str) -> str:
178
+ """Translate a dashboard `/api/...` path to its `/v1/...` equivalent.
179
+
180
+ Raises :class:`KeyModeUnsupportedError` (exit 2) for a family that has no `/v1`
181
+ route, and :class:`ApiError` for anything unclassified — never a silent
182
+ pass-through, which would send a request to a URL nobody chose.
183
+ """
184
+ if path in _V1_RENAMED:
185
+ return _V1_RENAMED[path]
186
+ if not path.startswith(_API_PREFIX):
187
+ raise ApiError(
188
+ f"the CLI cannot address {path!r} with an API key: it is not a dashboard "
189
+ "/api/ path. This is a bug in the CLI, not in your command.",
190
+ hint="re-run without --api-key (session mode) and please report it",
191
+ )
192
+ family = path[len(_API_PREFIX) :].split("/", 1)[0]
193
+ if family in _V1_NO_EQUIVALENT:
194
+ raise KeyModeUnsupportedError(
195
+ f"{path} has no API-key equivalent — {_V1_NO_EQUIVALENT[family]}",
196
+ hint="run this command with a signed-in session (fp login) instead",
197
+ )
198
+ if family in _V1_MECHANICAL_FAMILIES:
199
+ return "/v1/" + path[len(_API_PREFIX) :]
200
+ raise ApiError(
201
+ f"the CLI does not know how to reach {path!r} on the versioned API — the "
202
+ "key-mode route table in client.py has no entry for it.",
203
+ hint="re-run without --api-key (session mode) and please report it",
204
+ )
205
+
206
+
207
+ #: Segments that change which endpoint a path addresses rather than naming a
208
+ #: record. `..` walks up, `.` is a no-op the server may or may not collapse, and
209
+ #: an EMPTY segment turns `/api/users/{id}` with an unset id into `/api/users/`,
210
+ #: i.e. the collection — so `disable_user(ctx, "")` from an unset CI variable
211
+ #: addressed every user instead of failing.
212
+ _BAD_SEGMENTS = frozenset({"", ".", ".."})
213
+
214
+
215
+ def _validate_path(path: str) -> None:
216
+ """Refuse a path whose interpolated ids have changed what it addresses.
217
+
218
+ Every `f"/api/…/{id}"` in this module interpolates a caller-supplied value
219
+ raw — there is no `quote` anywhere — and httpx then RESOLVES the result as a
220
+ URL. So an id containing `..`, `?` or `#` silently re-points the request, and
221
+ that defeats `_v1_path`'s family guard specifically: the family is computed
222
+ from the literal prefix BEFORE httpx normalises the dot segments away, so
223
+ `disable_key(key_ctx, "../enforcement/policies/x/enable")` classified as the
224
+ mechanical `keys` family and then issued
225
+ `POST /v1/enforcement/policies/x/enable/disable` — an operator-write family
226
+ that `_V1_NO_EQUIVALENT` exists to make unreachable under an API key.
227
+
228
+ In session mode the same shapes read the wrong record without saying so:
229
+ `get_incident(ctx, "abc#frag")` requests `/api/issues/abc` (fragment dropped)
230
+ and returns a different issue as if it were the right one, and
231
+ `put_setting(ctx, "foo?admin=1", v)` injects a query parameter.
232
+
233
+ Raising rather than quoting, because none of these are a legitimate id that
234
+ merely needs escaping — they are a caller passing something that is not an
235
+ id at all, and the useful answer is to say so.
236
+ """
237
+ head = path.split("?", 1)[0].split("#", 1)[0]
238
+ if head != path:
239
+ raise ApiError(
240
+ f"the CLI refuses to request {path!r}: an id contained '?' or '#', which "
241
+ "changes which endpoint is called rather than naming a record.",
242
+ hint="check the id you passed — it is not a valid identifier",
243
+ )
244
+ for segment in path.split("/")[1:]:
245
+ if segment in _BAD_SEGMENTS:
246
+ raise ApiError(
247
+ f"the CLI refuses to request {path!r}: it contains an empty or "
248
+ "relative path segment, which addresses a different endpoint than "
249
+ "the command intends.",
250
+ hint="check the id you passed — an unset variable is the usual cause",
251
+ )
252
+
253
+
254
+ def _path(ctx: ClientContext, path: str) -> str:
255
+ """The path to actually request: `/v1/...` under an API key, `/api/...` otherwise.
256
+
257
+ The single choke point every request goes through, in both modes, which is
258
+ why the id validation lives here rather than at 45 interpolation sites.
259
+ """
260
+ _validate_path(path)
261
+ if ctx.auth_mode is AuthMode.API_KEY:
262
+ return _v1_path(path)
263
+ return path
264
+
265
+
266
+ def _client(ctx: ClientContext, *, timeout: Any = None) -> httpx.Client:
267
+ headers = {"x-request-id": uuid.uuid4().hex}
268
+ cookies = None
269
+ # Bearer XOR cookie — an `else`, never two independent `if`s. Sending both
270
+ # would hand a human's `ae_session` to `/v1` alongside the key, and every
271
+ # positive assertion ("the bearer header is set") would still pass while the
272
+ # CLI leaked a session cookie into CI. tests/test_auth_mode.py asserts the
273
+ # NEGATIVE on both sides.
274
+ if ctx.auth_mode is AuthMode.API_KEY:
275
+ if ctx.api_key:
276
+ headers["Authorization"] = f"Bearer {ctx.api_key}"
277
+ else:
278
+ if ctx.token:
279
+ cookies = {"ae_session": ctx.token}
280
+ # The dashboard resolves the active org from this header (dashboard/lib/withAuth.ts);
281
+ # without it a multi-org user is rejected. Single-org users are fine either way.
282
+ # In key mode the caller only ever puts an EXPLICIT --org/FP_ORG in `org`
283
+ # (see `_context.build_context`), never the saved one.
284
+ if ctx.org:
285
+ headers["X-AgentEye-Org"] = ctx.org
286
+ return httpx.Client(
287
+ base_url=ctx.base_url.rstrip("/"),
288
+ cookies=cookies,
289
+ headers=headers,
290
+ timeout=ctx.timeout if timeout is None else timeout,
291
+ transport=ctx.transport,
292
+ verify=ctx.verify,
293
+ )
294
+
295
+
296
+ def _csv(value: Optional[Union[str, Sequence[str]]]) -> Optional[str]:
297
+ if value is None:
298
+ return None
299
+ if isinstance(value, str):
300
+ return value or None
301
+ items = [str(v) for v in value if str(v)]
302
+ return ",".join(items) if items else None
303
+
304
+
305
+ def _bool(value: Optional[bool]) -> Optional[str]:
306
+ if value is None:
307
+ return None
308
+ return "true" if value else "false"
309
+
310
+
311
+ def _extract_error(response: httpx.Response) -> Optional[str]:
312
+ try:
313
+ data = response.json()
314
+ except Exception:
315
+ return None
316
+ if isinstance(data, dict):
317
+ msg = data.get("error") or data.get("message")
318
+ if not msg:
319
+ return None
320
+ # Fold in the server's raw `detail` (e.g. the underlying DB error for a failed
321
+ # `query run`) so an agent gets the actionable message, not just "query failed".
322
+ detail = data.get("detail")
323
+ if detail and str(detail) != str(msg):
324
+ return f"{msg}: {detail}"
325
+ return str(msg)
326
+ return None
327
+
328
+
329
+ def _required_permission(response: httpx.Response) -> Optional[str]:
330
+ """The ``required_permission`` slug the server names on a 403 (e.g. ``keys:create``), so the
331
+ CLI can tell the user exactly which grant they're missing instead of a bare ``forbidden``."""
332
+ try:
333
+ data = response.json()
334
+ except Exception:
335
+ return None
336
+ if isinstance(data, dict) and data.get("required_permission"):
337
+ return str(data["required_permission"])
338
+ return None
339
+
340
+
341
+ def _raise_for_status(response: httpx.Response, ctx: ClientContext) -> None:
342
+ key_mode = ctx.auth_mode is AuthMode.API_KEY
343
+ if response.status_code < 400:
344
+ # A 3xx to /login means the request never reached the API: some front door
345
+ # (Next.js middleware) answered it. httpx does not follow redirects, so
346
+ # without this the body is empty/HTML and the caller reports "the dashboard
347
+ # returned a malformed response" — which sends people hunting for a server
348
+ # bug. In key mode it has exactly one cause worth naming.
349
+ if 300 <= response.status_code < 400 and key_mode:
350
+ location = response.headers.get("location", "")
351
+ if "/login" in location:
352
+ raise ApiError(
353
+ "/v1 is not routed at this base URL — the request was redirected to "
354
+ "the dashboard's login page, so it landed on the web app instead of "
355
+ "the API.",
356
+ status=response.status_code,
357
+ request_id=response.headers.get("x-request-id"),
358
+ hint="point --base-url at the server itself, e.g. http://localhost:8080",
359
+ )
360
+ # Session mode is deliberately left alone for the /login case: that 3xx is
361
+ # the ordinary "your cookie is gone" case and changing its exit code is a
362
+ # separate contract change.
363
+ #
364
+ # Every OTHER 3xx is an error for every method, in both modes. httpx does
365
+ # not follow redirects, so a 3xx means the request did not reach the API
366
+ # at all — and `_request_json` turns the empty body into `{}`, which the
367
+ # mutating half of the CLI reads as success. `fp --base-url http://…
368
+ # issues ack i1` against a front door that 301s http→https exited 0
369
+ # printing "✓ acknowledged issue i1" while the POST never arrived;
370
+ # `deploy_policies` returned an empty Deployment, which an operator reads
371
+ # as "the machine now runs nothing" rather than "nothing happened".
372
+ if 300 <= response.status_code < 400:
373
+ raise ApiError(
374
+ "The request was redirected and did not reach the API, so it had no "
375
+ "effect. Nothing was changed.",
376
+ status=response.status_code,
377
+ request_id=response.headers.get("x-request-id"),
378
+ hint=(
379
+ "check --base-url: a redirect here usually means http:// where the "
380
+ "server wants https://, or a front door in front of the API"
381
+ ),
382
+ )
383
+ return
384
+ request_id = response.headers.get("x-request-id")
385
+ message = _extract_error(response)
386
+ if response.status_code == 401:
387
+ if key_mode:
388
+ raise AuthError(
389
+ "The API key was rejected. It may be revoked, mistyped, or issued by a "
390
+ "different deployment than --base-url points at."
391
+ )
392
+ raise AuthError("Session expired or not logged in. Run fp login.")
393
+ if response.status_code == 403:
394
+ needed = _required_permission(response)
395
+ if key_mode:
396
+ # Genuinely ambiguous, and the server cannot disambiguate it for us: a key
397
+ # acting for an org it was not issued for gets the SAME 403 as a key missing
398
+ # a permission, on purpose — telling the two apart would let a key holder
399
+ # enumerate which orgs exist. So name both causes rather than guess one.
400
+ what = (
401
+ f"the API key is missing the {needed} permission"
402
+ if needed
403
+ else "the API key is not allowed to do this"
404
+ )
405
+ raise ForbiddenError(
406
+ f"{what}, or it cannot act for this org — the server answers 403 for both.",
407
+ hint="check the key's grants, and the org you targeted with --org / FP_ORG",
408
+ )
409
+ if needed:
410
+ raise ForbiddenError(f"you don't have the {needed} permission")
411
+ raise ForbiddenError(message or "you don't have permission for this action")
412
+ if response.status_code == 404:
413
+ # In key mode a 404 has TWO very different causes, and the wrong reading
414
+ # sends people hunting for a server bug that isn't there:
415
+ # 1. the endpoint genuinely has no such record — the API answered, in JSON;
416
+ # 2. `/v1` is not routed at this origin at all, so something else answered.
417
+ #
418
+ # (2) is the likeliest first-run mistake: pointing --base-url at a
419
+ # dashboard whose front door does not forward /v1. It used to surface as a
420
+ # 3xx to /login, which the branch above names — but a dashboard that
421
+ # correctly declines to auth-gate /v1 returns its own 404 instead, and that
422
+ # is indistinguishable from (1) on the status code alone.
423
+ #
424
+ # The tell is the content type: our API always answers JSON, so an HTML
425
+ # body means a web app answered a request meant for the API.
426
+ if key_mode:
427
+ content_type = response.headers.get("content-type", "").lower()
428
+ if "html" in content_type:
429
+ raise ApiError(
430
+ "/v1 is not routed at this base URL — an HTML page answered, so the "
431
+ "request reached a web app rather than the API.",
432
+ status=404,
433
+ request_id=request_id,
434
+ hint="point --base-url at the server itself, e.g. http://localhost:8080",
435
+ )
436
+ raise NotFoundError(message or "Not found.")
437
+ if response.status_code == 429:
438
+ retry_after = response.headers.get("retry-after")
439
+ wait = f" Retry after {retry_after}s." if retry_after else " Please wait a moment and try again."
440
+ raise ApiError(
441
+ (message or "Rate limited — too many requests.") + wait,
442
+ status=429,
443
+ request_id=request_id,
444
+ )
445
+ raise ApiError(
446
+ message or f"Request failed with status {response.status_code}.",
447
+ status=response.status_code,
448
+ request_id=request_id,
449
+ )
450
+
451
+
452
+ def _get_json(ctx: ClientContext, path: str, params: Optional[Dict[str, Any]] = None) -> Any:
453
+ clean = {k: v for k, v in (params or {}).items() if v is not None}
454
+ url = _path(ctx, path) # chokepoint 1 of 4 for the /api -> /v1 rewrite
455
+ try:
456
+ with _client(ctx) as client:
457
+ response = client.get(url, params=clean)
458
+ except httpx.RequestError as exc:
459
+ raise NetworkError(
460
+ f"Cannot reach FailproofAI Cloud at {ctx.base_url}: {exc}"
461
+ )
462
+ _raise_for_status(response, ctx)
463
+ # A 2xx with an empty or non-JSON body is anomalous for a read (e.g. a proxy or
464
+ # captive portal returning an HTML 200). Surface it as a clean error instead of
465
+ # letting `response.json()` raise a raw JSONDecodeError traceback.
466
+ try:
467
+ return response.json()
468
+ except ValueError:
469
+ raise ApiError(
470
+ "The dashboard returned a malformed (non-JSON) response.",
471
+ status=response.status_code,
472
+ request_id=response.headers.get("x-request-id"),
473
+ )
474
+
475
+
476
+ def _request_json(
477
+ ctx: ClientContext,
478
+ method: str,
479
+ path: str,
480
+ *,
481
+ json_body: Any = None,
482
+ params: Optional[Dict[str, Any]] = None,
483
+ ) -> Any:
484
+ """Issue a write request and return the parsed JSON body (or ``{}`` if empty).
485
+
486
+ Mirrors :func:`_get_json`: maps transport failures to :class:`NetworkError` and
487
+ applies the shared 401/403/404/4xx/5xx mapping via :func:`_raise_for_status`, so
488
+ every write inherits the same exit-code contract. Tolerates an empty/204 body.
489
+ """
490
+ clean = {k: v for k, v in (params or {}).items() if v is not None} or None
491
+ url = _path(ctx, path) # chokepoint 2 of 4 for the /api -> /v1 rewrite
492
+ try:
493
+ with _client(ctx) as client:
494
+ response = client.request(method, url, json=json_body, params=clean)
495
+ except httpx.RequestError as exc:
496
+ raise NetworkError(
497
+ f"Cannot reach FailproofAI Cloud at {ctx.base_url}: {exc}"
498
+ )
499
+ _raise_for_status(response, ctx)
500
+ # A genuinely empty body (204, or a 200 with no content) is a legitimate
501
+ # "done, nothing to report" for a mutation.
502
+ if not response.content:
503
+ return {}
504
+ try:
505
+ return response.json()
506
+ except ValueError:
507
+ # A body that is PRESENT but not JSON is not that. It is a proxy error
508
+ # page, a captive portal, or a front door answering instead of the API —
509
+ # and returning `{}` made every one of those read as success on the
510
+ # mutating half of the CLI, which is the half where a false success
511
+ # matters. Reads already raise here; writes now do too.
512
+ raise ApiError(
513
+ "The dashboard returned a malformed (non-JSON) response, so the request "
514
+ "may not have been applied.",
515
+ status=response.status_code,
516
+ request_id=response.headers.get("x-request-id"),
517
+ )
518
+
519
+
520
+ def _post_json(ctx: ClientContext, path: str, json_body: Any = None, *, params: Optional[Dict[str, Any]] = None) -> Any:
521
+ return _request_json(ctx, "POST", path, json_body=json_body, params=params)
522
+
523
+
524
+ def _put_json(ctx: ClientContext, path: str, json_body: Any = None) -> Any:
525
+ return _request_json(ctx, "PUT", path, json_body=json_body)
526
+
527
+
528
+ def _patch_json(ctx: ClientContext, path: str, json_body: Any = None) -> Any:
529
+ return _request_json(ctx, "PATCH", path, json_body=json_body)
530
+
531
+
532
+ def _delete(ctx: ClientContext, path: str) -> Any:
533
+ return _request_json(ctx, "DELETE", path)
534
+
535
+
536
+ # --- Auth / identity --------------------------------------------------------
537
+
538
+
539
+ def get_session_user(ctx: ClientContext) -> SessionUser:
540
+ """GET /api/auth/session — the currently authenticated user."""
541
+ return SessionUser.from_dict(_get_json(ctx, "/api/auth/session"))
542
+
543
+
544
+ def org_is_accessible(ctx: ClientContext, slug: str) -> bool:
545
+ """Return True iff the authenticated user can act in org ``slug`` — i.e. the
546
+ org **exists** AND is granted to them (a membership, or an instance admin with
547
+ access). Probes a cheap org-scoped endpoint with ``X-AgentEye-Org: slug``;
548
+ HTTP 200 → accessible, 403/404 → the org does not exist or is not theirs.
549
+
550
+ Used to validate an explicitly-requested tenant (``--org`` / ``FP_ORG``)
551
+ before it is saved, so a non-existent or unauthorised slug is rejected up front
552
+ instead of being persisted and breaking every later command.
553
+
554
+ Raises :class:`AuthError` on 401 (dead session) and :class:`NetworkError` on a
555
+ transport failure, so a transient outage is never misreported as a bad org.
556
+ """
557
+ probe = ClientContext(
558
+ base_url=ctx.base_url,
559
+ token=ctx.token,
560
+ timeout=ctx.timeout,
561
+ verify=ctx.verify,
562
+ org=slug,
563
+ api_key=ctx.api_key,
564
+ auth_mode=ctx.auth_mode,
565
+ )
566
+ try:
567
+ with _client(probe) as client:
568
+ # Probe an auth-only endpoint (no specific data permission): it returns 200 for a
569
+ # member OR an instance admin granted the org, and 403/404 otherwise. Using a
570
+ # permission-gated route (e.g. /api/evaluations/environments) wrongly rejected an
571
+ # instance admin who has org access but no data perms.
572
+ # chokepoint 3 of 4 — this one builds its own client rather than going
573
+ # through _get_json, so it needs the rewrite applied by hand.
574
+ response = client.get(_path(probe, "/api/access-granters"))
575
+ except httpx.RequestError as exc:
576
+ raise NetworkError(
577
+ f"Cannot reach FailproofAI Cloud at {ctx.base_url}: {exc}"
578
+ )
579
+ if response.status_code == 200:
580
+ return True
581
+ if response.status_code == 401:
582
+ raise AuthError("Session expired or not logged in. Run fp login.")
583
+ # 403 / 404 (and anything else non-2xx) → the org is not accessible to this user.
584
+ return False
585
+
586
+
587
+ # --- Events -----------------------------------------------------------------
588
+
589
+
590
+ def _event_query_params(
591
+ *,
592
+ session_id: Optional[Union[str, Sequence[str]]],
593
+ agent_id: Optional[Union[str, Sequence[str]]],
594
+ event_type: Optional[Union[str, Sequence[str]]],
595
+ environment: Optional[Union[str, Sequence[str]]],
596
+ error_type: Optional[Union[str, Sequence[str]]],
597
+ errored: Optional[bool],
598
+ order: Optional[str],
599
+ search: Optional[Sequence[str]],
600
+ search_exclude: Optional[Union[str, Sequence[str]]],
601
+ ts_from: Optional[str],
602
+ ts_to: Optional[str],
603
+ cursor: Optional[Union[int, str]],
604
+ limit: Optional[int],
605
+ ) -> Dict[str, Any]:
606
+ """The shared filter/cursor/order query params for the events feeds.
607
+
608
+ ``/api/events`` (full) and ``/api/events/summary`` (light) accept an IDENTICAL query
609
+ surface and emit an interchangeable ``"<ts>|<id>"`` cursor, so both feeds build their
610
+ params here — they can never drift.
611
+ """
612
+ # session_id / agent_id are CSV multi-value on the wire (server `IN (...)`); `_csv`
613
+ # serializes a list to `a,b` and passes a bare string through unchanged (back-compat).
614
+ params: Dict[str, Any] = {
615
+ "session_id": _csv(session_id),
616
+ "agent_id": _csv(agent_id),
617
+ "event_type": _csv(event_type),
618
+ "environment": _csv(environment),
619
+ "error_type": _csv(error_type),
620
+ # the server reads `errored` only when truthy (matches the dashboard `/errors` view).
621
+ "errored": "true" if errored else None,
622
+ "order": order,
623
+ "search_exclude": _csv(search_exclude),
624
+ "ts_from": ts_from,
625
+ "ts_to": ts_to,
626
+ "cursor": cursor,
627
+ "limit": limit,
628
+ }
629
+ # `search` is free text — sent as REPEATED params (not CSV), so httpx needs a list.
630
+ terms = [s for s in (search or []) if s and s.strip()]
631
+ if terms:
632
+ params["search"] = terms
633
+ return params
634
+
635
+
636
+ def list_events(
637
+ ctx: ClientContext,
638
+ *,
639
+ session_id: Optional[Union[str, Sequence[str]]] = None,
640
+ agent_id: Optional[Union[str, Sequence[str]]] = None,
641
+ event_type: Optional[Union[str, Sequence[str]]] = None,
642
+ environment: Optional[Union[str, Sequence[str]]] = None,
643
+ error_type: Optional[Union[str, Sequence[str]]] = None,
644
+ errored: Optional[bool] = None,
645
+ order: Optional[str] = None,
646
+ search: Optional[Sequence[str]] = None,
647
+ search_exclude: Optional[Union[str, Sequence[str]]] = None,
648
+ ts_from: Optional[str] = None,
649
+ ts_to: Optional[str] = None,
650
+ cursor: Optional[Union[int, str]] = None,
651
+ limit: Optional[int] = None,
652
+ ) -> Page[AgentEvent]:
653
+ """GET /api/events — the FULL feed (includes the fat ``payload`` column).
654
+
655
+ Heavy at scale (payload is ~99.9% of the events table, read under ``FINAL``). Use only
656
+ for the bounded, payload-requesting paths (``events --full`` /
657
+ ``--fields payload``). The default list + all of ``errors`` use
658
+ :func:`list_event_summaries` instead.
659
+ """
660
+ params = _event_query_params(
661
+ session_id=session_id, agent_id=agent_id, event_type=event_type,
662
+ environment=environment, error_type=error_type, errored=errored, order=order,
663
+ search=search, search_exclude=search_exclude, ts_from=ts_from, ts_to=ts_to,
664
+ cursor=cursor, limit=limit,
665
+ )
666
+ data = _get_json(ctx, "/api/events", params)
667
+ items = [AgentEvent.from_dict(e) for e in (data if isinstance(data, dict) else {}).get("events", [])]
668
+ return Page(items=items, next_cursor=data.get("next_cursor"))
669
+
670
+
671
+ def list_event_summaries(
672
+ ctx: ClientContext,
673
+ *,
674
+ session_id: Optional[Union[str, Sequence[str]]] = None,
675
+ agent_id: Optional[Union[str, Sequence[str]]] = None,
676
+ event_type: Optional[Union[str, Sequence[str]]] = None,
677
+ environment: Optional[Union[str, Sequence[str]]] = None,
678
+ error_type: Optional[Union[str, Sequence[str]]] = None,
679
+ errored: Optional[bool] = None,
680
+ order: Optional[str] = None,
681
+ search: Optional[Sequence[str]] = None,
682
+ search_exclude: Optional[Union[str, Sequence[str]]] = None,
683
+ ts_from: Optional[str] = None,
684
+ ts_to: Optional[str] = None,
685
+ cursor: Optional[Union[int, str]] = None,
686
+ limit: Optional[int] = None,
687
+ ) -> Page[AgentEvent]:
688
+ """GET /api/events/summary — the LIGHT, payload-free feed (PR #338).
689
+
690
+ Same filters/order and an interchangeable cursor as :func:`list_events`, but the server
691
+ projects only the display columns (no ``payload``): it returns the precomputed
692
+ ``summary`` / ``is_error`` plus ``error_type`` / ``output_tokens`` / context-window
693
+ fields. This is the CLI's default read path: ordinary list/errors reads do not touch the
694
+ fat payload column. A free-text ``search`` is the deliberate exception: the response is
695
+ still payload-free, but the server scans payload in the WHERE clause to find matches.
696
+ """
697
+ params = _event_query_params(
698
+ session_id=session_id, agent_id=agent_id, event_type=event_type,
699
+ environment=environment, error_type=error_type, errored=errored, order=order,
700
+ search=search, search_exclude=search_exclude, ts_from=ts_from, ts_to=ts_to,
701
+ cursor=cursor, limit=limit,
702
+ )
703
+ data = _get_json(ctx, "/api/events/summary", params)
704
+ items = [AgentEvent.from_dict(e) for e in (data if isinstance(data, dict) else {}).get("events", [])]
705
+ return Page(items=items, next_cursor=data.get("next_cursor"))
706
+
707
+
708
+ # --- Event facets & analytics ----------------------------------------------
709
+
710
+ _FACET_PATHS = {
711
+ "agent_ids": "/api/events/agent_ids",
712
+ "event_types": "/api/events/event_types",
713
+ "models": "/api/events/models",
714
+ "tool_names": "/api/events/tool_names",
715
+ "hook_names": "/api/events/hook_names",
716
+ "error_types": "/api/events/error_types",
717
+ "trigger_events": "/api/events/trigger_events",
718
+ "environments": "/api/events/environments",
719
+ # Evaluation score keys (a distinct endpoint, not /api/events) — the source for
720
+ # the sessions-page score-filter dropdown; needs `evaluations:read`.
721
+ "score_filters": "/api/evaluations/score-keys",
722
+ }
723
+ FACET_KINDS = tuple(_FACET_PATHS.keys())
724
+
725
+
726
+ def list_facet(ctx: ClientContext, kind: str) -> List[str]:
727
+ """GET /api/events/<kind> — distinct facet values (a bare JSON array)."""
728
+ data = _get_json(ctx, _FACET_PATHS[kind])
729
+ return [str(x) for x in data] if isinstance(data, list) else []
730
+
731
+
732
+ def get_usage(ctx: ClientContext) -> Dict[str, Any]:
733
+ """GET /api/usage — the active org's current 30-day metering window."""
734
+ data = _get_json(ctx, "/api/usage")
735
+ if not isinstance(data, dict):
736
+ raise ApiError("The dashboard returned an invalid usage response.")
737
+ return data
738
+
739
+
740
+ def event_error_summary(
741
+ ctx: ClientContext,
742
+ *,
743
+ session_id: Optional[str] = None,
744
+ agent_id: Optional[str] = None,
745
+ event_type: Optional[Union[str, Sequence[str]]] = None,
746
+ error_type: Optional[Union[str, Sequence[str]]] = None,
747
+ environment: Optional[Union[str, Sequence[str]]] = None,
748
+ ts_from: Optional[str] = None,
749
+ ts_to: Optional[str] = None,
750
+ search: Optional[Sequence[str]] = None,
751
+ search_exclude: Optional[Union[str, Sequence[str]]] = None,
752
+ ) -> Dict[str, Any]:
753
+ """GET /api/events/error_summary — {total, sessions, agents, last_ts, bins}."""
754
+ params: Dict[str, Any] = {
755
+ "session_id": session_id,
756
+ "agent_id": agent_id,
757
+ "event_type": _csv(event_type),
758
+ "error_type": _csv(error_type),
759
+ "environment": _csv(environment),
760
+ "ts_from": ts_from,
761
+ "ts_to": ts_to,
762
+ "search_exclude": _csv(search_exclude),
763
+ }
764
+ terms = [s for s in (search or []) if s and s.strip()]
765
+ if terms:
766
+ params["search"] = terms
767
+ data = _get_json(ctx, "/api/events/error_summary", params)
768
+ return data if isinstance(data, dict) else {}
769
+
770
+
771
+ # --- Evaluations / sessions -------------------------------------------------
772
+
773
+
774
+ def list_evaluations(
775
+ ctx: ClientContext,
776
+ *,
777
+ session_id: Optional[str] = None,
778
+ agent_id: Optional[str] = None,
779
+ environment: Optional[Union[str, Sequence[str]]] = None,
780
+ status: Optional[str] = None,
781
+ score_filters: Optional[str] = None,
782
+ latest_per_session: Optional[bool] = None,
783
+ ts_from: Optional[str] = None,
784
+ ts_to: Optional[str] = None,
785
+ cursor: Optional[int] = None,
786
+ limit: Optional[int] = None,
787
+ ) -> Page[Evaluation]:
788
+ data = _get_json(
789
+ ctx,
790
+ "/api/evaluations",
791
+ {
792
+ "session_id": session_id,
793
+ "agent_id": agent_id,
794
+ "environment": _csv(environment),
795
+ "status": status,
796
+ "score_filters": score_filters,
797
+ "latest_per_session": _bool(latest_per_session),
798
+ "ts_from": ts_from,
799
+ "ts_to": ts_to,
800
+ "cursor": cursor,
801
+ "limit": limit,
802
+ },
803
+ )
804
+ items = [Evaluation.from_dict(e) for e in (data if isinstance(data, dict) else {}).get("evaluations", [])]
805
+ return Page(items=items, next_cursor=data.get("next_cursor"))
806
+
807
+
808
+ def list_sessions(
809
+ ctx: ClientContext,
810
+ *,
811
+ session_id: Optional[Union[str, Sequence[str]]] = None,
812
+ agent_id: Optional[Union[str, Sequence[str]]] = None,
813
+ environment: Optional[Union[str, Sequence[str]]] = None,
814
+ status: Optional[Union[str, Sequence[str]]] = None,
815
+ score_filters: Optional[str] = None,
816
+ ts_from: Optional[str] = None,
817
+ ts_to: Optional[str] = None,
818
+ cursor: Optional[str] = None,
819
+ limit: Optional[int] = None,
820
+ ) -> Page[Session]:
821
+ """GET /api/sessions — one row per agent run (the endpoint the dashboard's sessions page
822
+ uses). Every filter is CSV multi-value on the wire → server ``IN(...)`` (UNION within a
823
+ filter, AND across filters); ``status`` matches each session's LATEST evaluation status.
824
+ ``cursor`` is the opaque string keyset cursor (``"<last_event_at>|<id>"``)."""
825
+ data = _get_json(
826
+ ctx,
827
+ "/api/sessions",
828
+ {
829
+ "session_id": _csv(session_id),
830
+ "agent_id": _csv(agent_id),
831
+ "environment": _csv(environment),
832
+ "status": _csv(status),
833
+ "score_filters": score_filters,
834
+ "ts_from": ts_from,
835
+ "ts_to": ts_to,
836
+ "cursor": cursor,
837
+ "limit": limit,
838
+ },
839
+ )
840
+ items = [Session.from_dict(s) for s in (data if isinstance(data, dict) else {}).get("sessions", [])]
841
+ return Page(items=items, next_cursor=data.get("next_cursor"))
842
+
843
+
844
+ def evaluation_aggregate(
845
+ ctx: ClientContext,
846
+ *,
847
+ session_id: Optional[str] = None,
848
+ agent_id: Optional[str] = None,
849
+ environment: Optional[Union[str, Sequence[str]]] = None,
850
+ status: Optional[str] = None,
851
+ score_filters: Optional[str] = None,
852
+ latest_per_session: Optional[bool] = None,
853
+ featured_keys: Optional[Union[str, Sequence[str]]] = None,
854
+ ts_from: Optional[str] = None,
855
+ ts_to: Optional[str] = None,
856
+ ) -> Dict[str, Any]:
857
+ """GET /api/evaluations/aggregate — rolled-up status/score stats + timeline."""
858
+ data = _get_json(
859
+ ctx,
860
+ "/api/evaluations/aggregate",
861
+ {
862
+ "session_id": session_id,
863
+ "agent_id": agent_id,
864
+ "environment": _csv(environment),
865
+ "status": status,
866
+ "score_filters": score_filters,
867
+ "latest_per_session": _bool(latest_per_session),
868
+ "featured_keys": _csv(featured_keys),
869
+ "ts_from": ts_from,
870
+ "ts_to": ts_to,
871
+ },
872
+ )
873
+ return data if isinstance(data, dict) else {}
874
+
875
+
876
+ # --- API keys ---------------------------------------------------------------
877
+
878
+
879
+ def list_keys(ctx: ClientContext) -> List[ApiKey]:
880
+ """GET /api/keys — all keys for the org (metadata only; a bare JSON array)."""
881
+ data = _get_json(ctx, "/api/keys")
882
+ return [ApiKey.from_dict(k) for k in (data if isinstance(data, list) else [])]
883
+
884
+
885
+ def list_permission_sets(ctx: ClientContext) -> Dict[str, List[str]]:
886
+ """GET /api/permission-sets — the org's permission sets (built-in + custom) as a
887
+ ``{name: [permissions]}`` map. Used to expand a ``--permission-set`` for a KEY client-side
888
+ (keys store a flat permission list, so the CLI seeds from the set like the dashboard's
889
+ SetPicker). Returns ``{}`` on any non-list/odd shape."""
890
+ data = _get_json(ctx, "/api/permission-sets")
891
+ if not isinstance(data, list):
892
+ return {}
893
+ out: Dict[str, List[str]] = {}
894
+ for s in data:
895
+ if isinstance(s, dict) and s.get("name"):
896
+ out[str(s["name"])] = [str(p) for p in (s.get("permissions") or [])]
897
+ return out
898
+
899
+
900
+ def create_key(ctx: ClientContext, *, name: str, key: str, permissions: Sequence[str]) -> ApiKey:
901
+ """POST /api/keys — create a key. The caller supplies the secret (``key``);
902
+ the response carries no secret (it must be shown to the user once, by the caller)."""
903
+ data = _post_json(ctx, "/api/keys", {"name": name, "key": key, "permissions": list(permissions)})
904
+ return ApiKey.from_dict(data)
905
+
906
+
907
+ def update_key(ctx: ClientContext, key_id: str, *, permissions: Sequence[str]) -> ApiKey:
908
+ """PATCH /api/keys/{id} — replace the key's permission grants."""
909
+ data = _patch_json(ctx, f"/api/keys/{key_id}", {"permissions": list(permissions)})
910
+ return ApiKey.from_dict(data)
911
+
912
+
913
+ def disable_key(ctx: ClientContext, key_id: str) -> None:
914
+ """POST /api/keys/{id}/disable — revoke a key (irreversible)."""
915
+ _post_json(ctx, f"/api/keys/{key_id}/disable")
916
+
917
+
918
+ def regenerate_key(ctx: ClientContext, key_id: str) -> str:
919
+ """POST /api/keys/{id}/regenerate — rotate the secret; returns the NEW secret once."""
920
+ data = _post_json(ctx, f"/api/keys/{key_id}/regenerate")
921
+ key = str(data.get("key", "")) if isinstance(data, dict) else ""
922
+ if not key:
923
+ # The rotation is irreversible and the secret is shown exactly once, so
924
+ # an empty string here is an unrecoverable credential loss, not a
925
+ # cosmetic glitch: `fp keys regenerate ci-bot -y | pbcopy` captured a
926
+ # blank line and exited 0 while the old secret was already dead
927
+ # server-side. Say so instead.
928
+ raise ApiError(
929
+ "the server did not return a new secret, so the key may or may not have "
930
+ "been rotated",
931
+ hint="check `fp keys show <name>` before assuming either way",
932
+ )
933
+ return key
934
+
935
+
936
+ # --- Saved queries / SQL runner ---------------------------------------------
937
+
938
+
939
+ def list_saved_queries(ctx: ClientContext) -> List[SavedQuery]:
940
+ """GET /api/queries — saved queries for the org (response is {"queries": [...]})."""
941
+ data = _get_json(ctx, "/api/queries")
942
+ items = data.get("queries", []) if isinstance(data, dict) else (data if isinstance(data, list) else [])
943
+ return [SavedQuery.from_dict(q) for q in items]
944
+
945
+
946
+ def create_saved_query(
947
+ ctx: ClientContext,
948
+ *,
949
+ name: str,
950
+ sql_text: str,
951
+ description: str = "",
952
+ params: Optional[List[Dict[str, Any]]] = None,
953
+ ) -> SavedQuery:
954
+ """POST /api/queries — create a saved query."""
955
+ body = {"name": name, "description": description, "sql_text": sql_text, "params": params or []}
956
+ return SavedQuery.from_dict(_post_json(ctx, "/api/queries", body))
957
+
958
+
959
+ def update_saved_query(
960
+ ctx: ClientContext,
961
+ query_id: str,
962
+ *,
963
+ name: str,
964
+ sql_text: str,
965
+ description: str = "",
966
+ params: Optional[List[Dict[str, Any]]] = None,
967
+ ) -> SavedQuery:
968
+ """PUT /api/queries/{id} — full replace of a saved query."""
969
+ body = {"name": name, "description": description, "sql_text": sql_text, "params": params or []}
970
+ return SavedQuery.from_dict(_put_json(ctx, f"/api/queries/{query_id}", body))
971
+
972
+
973
+ def delete_saved_query(ctx: ClientContext, query_id: str) -> None:
974
+ """DELETE /api/queries/{id}."""
975
+ _delete(ctx, f"/api/queries/{query_id}")
976
+
977
+
978
+ def run_query(
979
+ ctx: ClientContext,
980
+ *,
981
+ sql: Optional[str] = None,
982
+ query_id: Optional[str] = None,
983
+ params: Optional[List[Any]] = None,
984
+ ) -> QueryResult:
985
+ """POST /api/queries/run — execute inline SQL or a saved query (read-only pool)."""
986
+ body: Dict[str, Any] = {"params": params or []}
987
+ if sql is not None:
988
+ body["sql"] = sql
989
+ if query_id is not None:
990
+ body["query_id"] = query_id
991
+ return QueryResult.from_dict(_post_json(ctx, "/api/queries/run", body))
992
+
993
+
994
+ def query_schema(ctx: ClientContext) -> Dict[str, Any]:
995
+ """GET /api/queries/schema — {schema, tables:[{name, columns:[{name,type}]}]}."""
996
+ data = _get_json(ctx, "/api/queries/schema")
997
+ return data if isinstance(data, dict) else {}
998
+
999
+
1000
+ # --- Users ------------------------------------------------------------------
1001
+
1002
+
1003
+ def list_users(ctx: ClientContext) -> List[DashboardUser]:
1004
+ """GET /api/users — all org members (a bare JSON array)."""
1005
+ data = _get_json(ctx, "/api/users")
1006
+ return [DashboardUser.from_dict(u) for u in (data if isinstance(data, list) else [])]
1007
+
1008
+
1009
+ def get_user(ctx: ClientContext, user_id: str) -> DashboardUser:
1010
+ """GET /api/users/{id}."""
1011
+ return DashboardUser.from_dict(_get_json(ctx, f"/api/users/{user_id}"))
1012
+
1013
+
1014
+ def _user_perm_body(permission_set, permission_added, permission_removed) -> Dict[str, Any]:
1015
+ body: Dict[str, Any] = {}
1016
+ if permission_set is not None:
1017
+ body["permission_set"] = permission_set
1018
+ if permission_added is not None:
1019
+ body["permission_added"] = list(permission_added)
1020
+ if permission_removed is not None:
1021
+ body["permission_removed"] = list(permission_removed)
1022
+ return body
1023
+
1024
+
1025
+ def create_user(
1026
+ ctx: ClientContext,
1027
+ *,
1028
+ email: str,
1029
+ permission_set: Optional[str] = None,
1030
+ permission_added: Optional[Sequence[str]] = None,
1031
+ permission_removed: Optional[Sequence[str]] = None,
1032
+ ) -> DashboardUser:
1033
+ """POST /api/users — invite/create a member."""
1034
+ body = {"email": email, **_user_perm_body(permission_set, permission_added, permission_removed)}
1035
+ return DashboardUser.from_dict(_post_json(ctx, "/api/users", body))
1036
+
1037
+
1038
+ def update_user(
1039
+ ctx: ClientContext,
1040
+ user_id: str,
1041
+ *,
1042
+ permission_set: Optional[str] = None,
1043
+ permission_added: Optional[Sequence[str]] = None,
1044
+ permission_removed: Optional[Sequence[str]] = None,
1045
+ ) -> DashboardUser:
1046
+ """PUT /api/users/{id} — change a member's grants."""
1047
+ body = _user_perm_body(permission_set, permission_added, permission_removed)
1048
+ return DashboardUser.from_dict(_put_json(ctx, f"/api/users/{user_id}", body))
1049
+
1050
+
1051
+ def disable_user(ctx: ClientContext, user_id: str) -> None:
1052
+ """DELETE /api/users/{id} — disable a member (reversible via enable)."""
1053
+ _delete(ctx, f"/api/users/{user_id}")
1054
+
1055
+
1056
+ def enable_user(ctx: ClientContext, user_id: str) -> DashboardUser:
1057
+ """POST /api/users/{id}/enable — re-enable a disabled member."""
1058
+ return DashboardUser.from_dict(_post_json(ctx, f"/api/users/{user_id}/enable"))
1059
+
1060
+
1061
+ # --- Settings ---------------------------------------------------------------
1062
+
1063
+
1064
+ def list_settings(ctx: ClientContext) -> List[SettingRow]:
1065
+ """GET /api/settings — {settings:[...]}."""
1066
+ data = _get_json(ctx, "/api/settings")
1067
+ items = data.get("settings", []) if isinstance(data, dict) else []
1068
+ return [SettingRow.from_dict(s) for s in items]
1069
+
1070
+
1071
+ def get_settings_schema(ctx: ClientContext) -> List[Dict[str, Any]]:
1072
+ """Registry metadata per setting.
1073
+
1074
+ There is no dedicated schema endpoint on the dashboard — each ``GET /api/settings``
1075
+ row carries its own ``schema`` blob, so derive the metadata from the settings list.
1076
+ """
1077
+ rows = list_settings(ctx)
1078
+ return [{"key": r.key, **(r.schema or {})} for r in rows]
1079
+
1080
+
1081
+ def put_setting(ctx: ClientContext, key: str, value: Any) -> SettingRow:
1082
+ """PUT /api/settings/{key} — body is always wrapped as {"value": ...}."""
1083
+ return SettingRow.from_dict(_put_json(ctx, f"/api/settings/{key}", {"value": value}))
1084
+
1085
+
1086
+ # --- Alerts -----------------------------------------------------------------
1087
+
1088
+
1089
+ def list_alerts(ctx: ClientContext) -> List[Alert]:
1090
+ """GET /api/alerts — alert definitions for the org (bare array)."""
1091
+ data = _get_json(ctx, "/api/alerts")
1092
+ return [Alert.from_dict(a) for a in (data if isinstance(data, list) else [])]
1093
+
1094
+
1095
+ def create_alert(ctx: ClientContext, body: Dict[str, Any]) -> Dict[str, Any]:
1096
+ """POST /api/alerts — returns {id, created_at}."""
1097
+ return _post_json(ctx, "/api/alerts", body)
1098
+
1099
+
1100
+ def update_alert(ctx: ClientContext, alert_id: str, body: Dict[str, Any]) -> Dict[str, Any]:
1101
+ """PUT /api/alerts/{id} — returns {id, updated_at}."""
1102
+ return _put_json(ctx, f"/api/alerts/{alert_id}", body)
1103
+
1104
+
1105
+ def delete_alert(ctx: ClientContext, alert_id: str) -> None:
1106
+ """DELETE /api/alerts/{id}."""
1107
+ _delete(ctx, f"/api/alerts/{alert_id}")
1108
+
1109
+
1110
+ def test_alert(ctx: ClientContext, alert_id: str, channels: Optional[List[Dict[str, Any]]] = None) -> Dict[str, Any]:
1111
+ """POST /api/alerts/{id}/test — fire a test notification; {ok, synthetic_incident_id}."""
1112
+ return _post_json(ctx, f"/api/alerts/{alert_id}/test", {"channels": channels} if channels else {})
1113
+
1114
+
1115
+ # --- Incidents --------------------------------------------------------------
1116
+
1117
+
1118
+ def list_incidents(
1119
+ ctx: ClientContext,
1120
+ *,
1121
+ state: Optional[str] = None,
1122
+ alert_id: Optional[str] = None,
1123
+ limit: Optional[int] = None,
1124
+ ) -> List[Incident]:
1125
+ """GET /api/issues (or /api/alerts/{id}/issues when alert_id given). The
1126
+ alert-scoped path honours ``state``/``limit`` too — pass them so the filters aren't silently
1127
+ dropped on that path (``_get_json`` omits None params)."""
1128
+ if alert_id:
1129
+ data = _get_json(ctx, f"/api/alerts/{alert_id}/issues", {"state": state, "limit": limit})
1130
+ else:
1131
+ data = _get_json(ctx, "/api/issues", {"state": state, "limit": limit})
1132
+ return [Incident.from_dict(i) for i in (data if isinstance(data, list) else [])]
1133
+
1134
+
1135
+ def count_incidents(ctx: ClientContext, *, state: Optional[str] = None) -> int:
1136
+ """GET /api/issues/count — {count}."""
1137
+ data = _get_json(ctx, "/api/issues/count", {"state": state})
1138
+ if not isinstance(data, dict):
1139
+ return 0
1140
+ try:
1141
+ return int(data.get("count", 0))
1142
+ except (TypeError, ValueError):
1143
+ return 0
1144
+
1145
+
1146
+ def get_incident(ctx: ClientContext, incident_id: str) -> Incident:
1147
+ """GET /api/issues/{id} — full detail (comments, subscribers, activity)."""
1148
+ return Incident.from_dict(_get_json(ctx, f"/api/issues/{incident_id}"))
1149
+
1150
+
1151
+ def ack_incident(ctx: ClientContext, incident_id: str) -> None:
1152
+ _post_json(ctx, f"/api/issues/{incident_id}/ack")
1153
+
1154
+
1155
+ def assign_incident(ctx: ClientContext, incident_id: str, assignees: Sequence[str]) -> None:
1156
+ """POST /api/issues/{id}/assign — replace the assignee list (server validates each email)."""
1157
+ _post_json(ctx, f"/api/issues/{incident_id}/assign", {"assignees": list(assignees)})
1158
+
1159
+
1160
+ def resolve_incident(ctx: ClientContext, incident_id: str) -> None:
1161
+ _post_json(ctx, f"/api/issues/{incident_id}/resolve")
1162
+
1163
+
1164
+ def list_incident_comments(ctx: ClientContext, incident_id: str) -> List[IncidentComment]:
1165
+ data = _get_json(ctx, f"/api/issues/{incident_id}/comments")
1166
+ return [IncidentComment.from_dict(c) for c in (data if isinstance(data, list) else [])]
1167
+
1168
+
1169
+ def create_incident_comment(ctx: ClientContext, incident_id: str, body: str) -> IncidentComment:
1170
+ data = _post_json(ctx, f"/api/issues/{incident_id}/comments", {"body": body})
1171
+ return IncidentComment.from_dict(data)
1172
+
1173
+
1174
+ def delete_incident_comment(ctx: ClientContext, incident_id: str, comment_id: str) -> None:
1175
+ _delete(ctx, f"/api/issues/{incident_id}/comments/{comment_id}")
1176
+
1177
+
1178
+ def list_incident_subscribers(ctx: ClientContext, incident_id: str) -> List[IncidentSubscriber]:
1179
+ data = _get_json(ctx, f"/api/issues/{incident_id}/subscribers")
1180
+ return [IncidentSubscriber.from_dict(s) for s in (data if isinstance(data, list) else [])]
1181
+
1182
+
1183
+ def subscribe_incident(ctx: ClientContext, incident_id: str, email: Optional[str] = None) -> None:
1184
+ _post_json(ctx, f"/api/issues/{incident_id}/subscribe", {"email": email} if email else {})
1185
+
1186
+
1187
+ def unsubscribe_incident(ctx: ClientContext, incident_id: str, email: Optional[str] = None) -> None:
1188
+ _post_json(ctx, f"/api/issues/{incident_id}/unsubscribe", {"email": email} if email else {})
1189
+
1190
+
1191
+ def open_incident(
1192
+ ctx: ClientContext,
1193
+ *,
1194
+ summary: str,
1195
+ alert_id: Optional[str] = None,
1196
+ severity: Optional[str] = None,
1197
+ title: Optional[str] = None,
1198
+ ) -> Dict[str, Any]:
1199
+ """POST /api/alerts/{id}/issues (linked) or /api/issues (standalone).
1200
+
1201
+ ``title`` is required by the server on the standalone path (an orphan has no
1202
+ parent alert whose name it could borrow) and optional on the linked path,
1203
+ where the server falls back to the alert's own name.
1204
+ """
1205
+ if alert_id:
1206
+ linked: Dict[str, Any] = {"summary": summary}
1207
+ if title:
1208
+ linked["title"] = title
1209
+ return _post_json(ctx, f"/api/alerts/{alert_id}/issues", linked)
1210
+ body: Dict[str, Any] = {"summary": summary}
1211
+ if title:
1212
+ body["title"] = title
1213
+ if severity:
1214
+ body["severity"] = severity
1215
+ return _post_json(ctx, "/api/issues", body)
1216
+
1217
+
1218
+ # --- Audits -----------------------------------------------------------------
1219
+
1220
+
1221
+ def list_audits(ctx: ClientContext) -> List[Audit]:
1222
+ """GET /api/audits — audit definitions for the org (bare array)."""
1223
+ data = _get_json(ctx, "/api/audits")
1224
+ return [Audit.from_dict(a) for a in (data if isinstance(data, list) else [])]
1225
+
1226
+
1227
+ def get_audit(ctx: ClientContext, audit_id: str) -> Audit:
1228
+ """GET /api/audits/{id} — one audit definition."""
1229
+ return Audit.from_dict(_get_json(ctx, f"/api/audits/{audit_id}"))
1230
+
1231
+
1232
+ def create_audit(ctx: ClientContext, body: Dict[str, Any]) -> Dict[str, Any]:
1233
+ """POST /api/audits — returns {id, created_at}."""
1234
+ return _post_json(ctx, "/api/audits", body)
1235
+
1236
+
1237
+ def update_audit(ctx: ClientContext, audit_id: str, body: Dict[str, Any]) -> Dict[str, Any]:
1238
+ """PUT /api/audits/{id} — full replace; returns {id, updated}."""
1239
+ return _put_json(ctx, f"/api/audits/{audit_id}", body)
1240
+
1241
+
1242
+ def delete_audit(ctx: ClientContext, audit_id: str) -> None:
1243
+ """DELETE /api/audits/{id}."""
1244
+ _delete(ctx, f"/api/audits/{audit_id}")
1245
+
1246
+
1247
+ def run_audit(ctx: ClientContext, audit_id: str) -> Dict[str, Any]:
1248
+ """POST /api/audits/{id}/run — queue a run now; 202 {queued: true} (409 if one is running)."""
1249
+ return _post_json(ctx, f"/api/audits/{audit_id}/run")
1250
+
1251
+
1252
+ def get_audit_context(ctx: ClientContext, audit_id: str) -> Dict[str, Any]:
1253
+ """GET /api/audits/{id}/context — the brief plus each URL's snapshot state."""
1254
+ return _get_json(ctx, f"/api/audits/{audit_id}/context")
1255
+
1256
+
1257
+ def put_audit_context(ctx: ClientContext, audit_id: str, body: Dict[str, Any]) -> Dict[str, Any]:
1258
+ """PUT /api/audits/{id}/context — FULL REPLACEMENT; ``{"text":"","urls":[]}`` clears.
1259
+
1260
+ A sub-resource rather than fields on the definition body, so a flag-only
1261
+ ``audits edit`` — which read-merges through ``_audit_to_body``'s allowlist —
1262
+ can never silently wipe it.
1263
+ """
1264
+ return _put_json(ctx, f"/api/audits/{audit_id}/context", body)
1265
+
1266
+
1267
+ def refresh_audit_context(ctx: ClientContext, audit_id: str) -> Dict[str, Any]:
1268
+ """POST /api/audits/{id}/context/refresh — re-fetch every non-blocked URL."""
1269
+ return _post_json(ctx, f"/api/audits/{audit_id}/context/refresh")
1270
+
1271
+
1272
+ def list_audit_runs(ctx: ClientContext, audit_id: str) -> List[AuditRun]:
1273
+ """GET /api/audits/{id}/runs — run history, newest first (bare array)."""
1274
+ data = _get_json(ctx, f"/api/audits/{audit_id}/runs")
1275
+ return [AuditRun.from_dict(r) for r in (data if isinstance(data, list) else [])]
1276
+
1277
+
1278
+ def list_audit_findings(
1279
+ ctx: ClientContext,
1280
+ *,
1281
+ audit_id: Optional[str] = None,
1282
+ run_id: Optional[str] = None,
1283
+ status: Optional[Union[str, Sequence[str]]] = None,
1284
+ limit: Optional[int] = None,
1285
+ offset: Optional[int] = None,
1286
+ ) -> List[AuditFinding]:
1287
+ """GET /api/audits/findings — the org-wide triage list (bare array, priority-desc).
1288
+
1289
+ ``status`` is CSV on the wire (server ``IN (...)``); omitting it leaves the server's
1290
+ default live set (open + recurring).
1291
+ """
1292
+ data = _get_json(
1293
+ ctx,
1294
+ "/api/audits/findings",
1295
+ {
1296
+ "audit_id": audit_id,
1297
+ "run_id": run_id,
1298
+ "status": _csv(status),
1299
+ "limit": limit,
1300
+ "offset": offset,
1301
+ },
1302
+ )
1303
+ return [AuditFinding.from_dict(f) for f in (data if isinstance(data, list) else [])]
1304
+
1305
+
1306
+ def get_audit_finding(ctx: ClientContext, finding_id: str) -> AuditFinding:
1307
+ """GET /api/audits/findings/{fid} — one finding."""
1308
+ return AuditFinding.from_dict(_get_json(ctx, f"/api/audits/findings/{finding_id}"))
1309
+
1310
+
1311
+ def set_finding_status(
1312
+ ctx: ClientContext,
1313
+ finding_id: str,
1314
+ *,
1315
+ action: str,
1316
+ reason: Optional[str] = None,
1317
+ assigned_to: Optional[str] = None,
1318
+ ) -> Dict[str, Any]:
1319
+ """POST /api/audits/findings/{fid}/status — triage action; returns {id, action, ok}."""
1320
+ body: Dict[str, Any] = {"action": action}
1321
+ if reason is not None:
1322
+ body["reason"] = reason
1323
+ if assigned_to is not None:
1324
+ body["assigned_to"] = assigned_to
1325
+ return _post_json(ctx, f"/api/audits/findings/{finding_id}/status", body)
1326
+
1327
+
1328
+ # --- Agent assistant --------------------------------------------------------
1329
+
1330
+
1331
+ def agent_health(ctx: ClientContext) -> Dict[str, Any]:
1332
+ """GET /api/agent/health — {enabled, llm_configured?, model?, models?, default_model?}."""
1333
+ data = _get_json(ctx, "/api/agent/health")
1334
+ return data if isinstance(data, dict) else {}
1335
+
1336
+
1337
+ def list_conversations(ctx: ClientContext) -> List[Dict[str, Any]]:
1338
+ """GET /api/agent/conversations — {conversations:[...]}."""
1339
+ data = _get_json(ctx, "/api/agent/conversations")
1340
+ return data.get("conversations", []) if isinstance(data, dict) else []
1341
+
1342
+
1343
+ def get_conversation(ctx: ClientContext, conversation_id: str) -> Dict[str, Any]:
1344
+ """GET /api/agent/conversations/{id} — {title, messages:[...]}."""
1345
+ data = _get_json(ctx, f"/api/agent/conversations/{conversation_id}")
1346
+ return data if isinstance(data, dict) else {}
1347
+
1348
+
1349
+ def rename_conversation(ctx: ClientContext, conversation_id: str, title: str) -> None:
1350
+ """PATCH /api/agent/conversations/{id}."""
1351
+ _patch_json(ctx, f"/api/agent/conversations/{conversation_id}", {"title": title})
1352
+
1353
+
1354
+ def delete_conversation(ctx: ClientContext, conversation_id: str) -> None:
1355
+ """DELETE /api/agent/conversations/{id}."""
1356
+ _delete(ctx, f"/api/agent/conversations/{conversation_id}")
1357
+
1358
+
1359
+ def create_conversation(ctx: ClientContext, title: str = "") -> Dict[str, Any]:
1360
+ """POST /api/agent/conversations — create an empty conversation (owner = your email,
1361
+ so it appears in the dashboard's assistant). Returns the created summary, incl. ``id``.
1362
+ An empty title becomes the server default ("New conversation")."""
1363
+ data = _post_json(ctx, "/api/agent/conversations", {"title": title})
1364
+ return data if isinstance(data, dict) else {}
1365
+
1366
+
1367
+ def replace_messages(
1368
+ ctx: ClientContext, conversation_id: str, messages: List[Dict[str, Any]]
1369
+ ) -> None:
1370
+ """PUT /api/agent/conversations/{id}/messages — atomically replace the whole thread.
1371
+
1372
+ ``messages`` use the shared wire shape ``{"role": ..., "content": {"text": ...}}`` so
1373
+ the persisted transcript renders identically in the CLI and the dashboard assistant.
1374
+ """
1375
+ _put_json(ctx, f"/api/agent/conversations/{conversation_id}/messages", {"messages": messages})
1376
+
1377
+
1378
+ def _stream_sse(ctx: ClientContext, path: str, body: Dict[str, Any]) -> Iterator[Dict[str, Any]]:
1379
+ """POST and yield each parsed JSON object from an SSE ``data:`` stream."""
1380
+ # Keep the connect (and write/pool) timeout so an unreachable server still fails
1381
+ # fast, but DISABLE the read timeout: an SSE answer can legitimately pause between
1382
+ # frames (a slow LLM turn or a long tool call) far longer than --timeout, and a
1383
+ # read-timeout there would kill the stream mid-answer and be mislabeled "cannot reach".
1384
+ stream_timeout = httpx.Timeout(ctx.timeout, read=None)
1385
+ url = _path(ctx, path) # chokepoint 4 of 4 for the /api -> /v1 rewrite
1386
+ try:
1387
+ with _client(ctx, timeout=stream_timeout) as client:
1388
+ with client.stream("POST", url, json=body) as response:
1389
+ if response.status_code >= 400:
1390
+ response.read()
1391
+ _raise_for_status(response, ctx)
1392
+ buf = ""
1393
+ for chunk in response.iter_text():
1394
+ buf += chunk
1395
+ while "\n\n" in buf:
1396
+ frame, buf = buf.split("\n\n", 1)
1397
+ for line in frame.splitlines():
1398
+ if line.startswith("data:"):
1399
+ payload = line[len("data:"):].strip()
1400
+ if payload:
1401
+ try:
1402
+ yield _json.loads(payload)
1403
+ except ValueError:
1404
+ pass
1405
+ except httpx.RequestError as exc:
1406
+ raise NetworkError(f"Cannot reach FailproofAI Cloud at {ctx.base_url}: {exc}")
1407
+
1408
+
1409
+ def agent_chat_oneshot(
1410
+ ctx: ClientContext,
1411
+ *,
1412
+ message: Optional[str] = None,
1413
+ messages: Optional[List[Dict[str, Any]]] = None,
1414
+ conversation_id: Optional[str] = None,
1415
+ page_context: Optional[str] = None,
1416
+ model: Optional[str] = None,
1417
+ ) -> Dict[str, Any]:
1418
+ """POST /api/agent/chat — accumulate the streamed answer.
1419
+
1420
+ Pass a single ``message`` (a standalone turn) OR a full ``messages`` thread (to
1421
+ continue a conversation); ``conversation_id`` is forwarded for correlation. Aborts
1422
+ (``interrupted``) if the assistant asks for interactive input, since the CLI can't
1423
+ hold an interactive turn.
1424
+ """
1425
+ body: Dict[str, Any] = {
1426
+ "messages": messages
1427
+ if messages is not None
1428
+ else [{"role": "user", "content": {"text": message}}]
1429
+ }
1430
+ if conversation_id:
1431
+ body["conversationId"] = conversation_id
1432
+ if page_context:
1433
+ body["pageContext"] = page_context
1434
+ if model:
1435
+ body["model"] = model
1436
+ parts: List[str] = []
1437
+ tools: List[str] = []
1438
+ interrupted = False
1439
+ error: Optional[str] = None
1440
+ for ev in _stream_sse(ctx, "/api/agent/chat", body):
1441
+ kind = ev.get("type")
1442
+ if kind == "text-delta":
1443
+ parts.append(str(ev.get("text", "")))
1444
+ elif kind == "tool-start":
1445
+ tools.append(str(ev.get("tool", "")))
1446
+ elif kind == "ask-user":
1447
+ interrupted = True
1448
+ error = str(ev.get("question") or "the assistant needs interactive input")
1449
+ break
1450
+ elif kind == "error":
1451
+ error = str(ev.get("message", "assistant error"))
1452
+ elif kind == "done":
1453
+ break
1454
+ return {"answer": "".join(parts), "tools": tools, "interrupted": interrupted, "error": error}
1455
+
1456
+
1457
+ # --- Pagination helper ------------------------------------------------------
1458
+
1459
+
1460
+ class Walk:
1461
+ """Where a `paginate()` walk stopped, for a caller that needs to say so.
1462
+
1463
+ A generator cannot return a value to a `list()` around it, and the four
1464
+ `--all` commands need one: they were hard-coding ``next_cursor = None`` and
1465
+ then emitting ``{"…": [...], "next_cursor": null}``, which positively asserts
1466
+ that the feed is exhausted. With `--limit` defaulting to 50, `fp --json
1467
+ events --session-id X --all` made ONE request, returned 50 rows out of
1468
+ 10,000 and told the caller there was nothing more to fetch — and the CLI had
1469
+ the live cursor in hand at that moment and threw it away.
1470
+
1471
+ Pass one in and read it after the walk: `truncated` says the walk stopped on
1472
+ `--limit` rather than on an exhausted feed, and `next_cursor` is where to
1473
+ resume.
1474
+ """
1475
+
1476
+ __slots__ = ("truncated", "next_cursor")
1477
+
1478
+ def __init__(self) -> None:
1479
+ self.truncated = False
1480
+ self.next_cursor: Optional[Union[int, str]] = None
1481
+
1482
+
1483
+ def paginate(
1484
+ fetch_page: Callable[..., Page],
1485
+ *,
1486
+ limit: Optional[int] = None,
1487
+ page_size: Optional[int] = None,
1488
+ start_cursor: Optional[Union[int, str]] = None,
1489
+ walk: Optional["Walk"] = None,
1490
+ ) -> Iterator[Any]:
1491
+ """Walk cursor pages, yielding items until exhausted or ``limit`` reached.
1492
+
1493
+ ``fetch_page`` must accept ``cursor`` and ``limit`` keyword arguments and
1494
+ return a :class:`Page`. Stops if the cursor fails to decrease (defensive
1495
+ against a server that returns a non-decreasing cursor).
1496
+
1497
+ ``walk`` is an optional :class:`Walk` the caller can read afterwards to tell
1498
+ a walk that ran out of data from one that ran out of budget.
1499
+ """
1500
+ if limit is not None and limit <= 0:
1501
+ return
1502
+ remaining = limit
1503
+ cursor: Optional[Union[int, str]] = start_cursor
1504
+ seen: set = set()
1505
+ while True:
1506
+ size = page_size or MAX_PAGE_SIZE
1507
+ if remaining is not None:
1508
+ size = min(size, remaining)
1509
+ size = max(1, min(size, MAX_PAGE_SIZE))
1510
+
1511
+ page = fetch_page(cursor=cursor, limit=size)
1512
+ for index, item in enumerate(page.items):
1513
+ yield item
1514
+ if remaining is not None:
1515
+ remaining -= 1
1516
+ if remaining <= 0:
1517
+ if walk is not None:
1518
+ # More on this page, or a cursor for the next one: either
1519
+ # way the feed is not exhausted.
1520
+ more_here = index + 1 < len(page.items)
1521
+ if more_here or page.next_cursor is not None:
1522
+ walk.truncated = True
1523
+ walk.next_cursor = page.next_cursor if not more_here else cursor
1524
+ return
1525
+
1526
+ next_cursor = page.next_cursor
1527
+ if next_cursor is None:
1528
+ return
1529
+ # Defensive loop guard: stop if the server hands back a cursor we've already
1530
+ # walked (no forward progress). Keyed on the string form so it works for BOTH
1531
+ # int cursors (sessions/evaluations) and string cursors (events) without comparing
1532
+ # across types — the old `next_cursor >= cursor` crashed on a str/int mix when
1533
+ # `--cursor` (a string) was combined with an int-cursor endpoint.
1534
+ key = str(next_cursor)
1535
+ if key in seen:
1536
+ return
1537
+ seen.add(key)
1538
+ cursor = next_cursor
1539
+
1540
+
1541
+ # ── Cloud-managed enforcement ────────────────────────────────────────────────
1542
+ #
1543
+ # Every path here is ROOT-ONLY on the server: deliberately absent from `/v1`,
1544
+ # because `/v1` is published on the dashboard host by the ingress and these are
1545
+ # operator WRITE paths (publish, deploy, rollback). The commands therefore refuse
1546
+ # API-key mode up front via `deny_in_key_mode` rather than translating a path
1547
+ # that would 404 — see `server/src/routes/mod.rs`, the ROOT-ONLY block.
1548
+
1549
+
1550
+ def list_policies(ctx: ClientContext) -> List[PolicyVersion]:
1551
+ """GET /api/enforcement/policies — every published policy, latest version each."""
1552
+ data = _get_json(ctx, "/api/enforcement/policies")
1553
+ items = data if isinstance(data, list) else data.get("policies", [])
1554
+ return [PolicyVersion.from_dict(p) for p in items]
1555
+
1556
+
1557
+ def publish_policy(
1558
+ ctx: ClientContext, policy_id: str, source: str, description: str = ""
1559
+ ) -> PolicyVersion:
1560
+ """POST /api/enforcement/policies — mints a NEW VERSION; never edits in place."""
1561
+ body = {"id": policy_id, "source": source, "description": description}
1562
+ return PolicyVersion.from_dict(_post_json(ctx, "/api/enforcement/policies", body) or {})
1563
+
1564
+
1565
+ def set_policy_enabled(ctx: ClientContext, policy_id: str, enabled: bool) -> Dict[str, Any]:
1566
+ """POST /api/enforcement/policies/{id}/{enable|disable}."""
1567
+ verb = "enable" if enabled else "disable"
1568
+ path = f"/api/enforcement/policies/{policy_id}/{verb}"
1569
+ return _post_json(ctx, path) or {}
1570
+
1571
+
1572
+ def delete_policy(ctx: ClientContext, policy_id: str) -> Dict[str, Any]:
1573
+ """DELETE /api/enforcement/policies/{id} — archives it; machines keep what they hold."""
1574
+ return _request_json(ctx, "DELETE", f"/api/enforcement/policies/{policy_id}") or {}
1575
+
1576
+
1577
+ def list_machines(ctx: ClientContext) -> List[Machine]:
1578
+ """GET /api/enforcement/machines — every host that has ever checked in."""
1579
+ data = _get_json(ctx, "/api/enforcement/machines")
1580
+ items = data if isinstance(data, list) else data.get("machines", [])
1581
+ return [Machine.from_dict(m) for m in items]
1582
+
1583
+
1584
+ def rename_machine(ctx: ClientContext, machine_id: str, label: str) -> Dict[str, Any]:
1585
+ """PATCH /api/enforcement/machines/{id} — a human label, not the id."""
1586
+ path = f"/api/enforcement/machines/{machine_id}"
1587
+ return _request_json(ctx, "PATCH", path, json_body={"label": label}) or {}
1588
+
1589
+
1590
+ def list_deployments(ctx: ClientContext) -> List[Deployment]:
1591
+ """GET /api/enforcement/deployments — what every machine is told to run."""
1592
+ data = _get_json(ctx, "/api/enforcement/deployments")
1593
+ items = data if isinstance(data, list) else data.get("deployments", [])
1594
+ return [Deployment.from_dict(d) for d in items]
1595
+
1596
+
1597
+ def get_deployment(ctx: ClientContext, machine_id: str) -> Optional[Deployment]:
1598
+ """One machine's deployment, or None when nothing has been deployed to it.
1599
+
1600
+ The read half of every read-modify-write. `deploy` is a FULL REPLACE, so a
1601
+ caller that skips this and sends only what it wants ADDED silently removes
1602
+ everything else.
1603
+ """
1604
+ for dep in list_deployments(ctx):
1605
+ if dep.machine_id == machine_id:
1606
+ return dep
1607
+ return None
1608
+
1609
+
1610
+ def deploy_policies(
1611
+ ctx: ClientContext, machine_id: str, policies: Sequence[PolicyRef]
1612
+ ) -> Deployment:
1613
+ """PUT /api/enforcement/deployments/{id} — REPLACES the machine's whole set."""
1614
+ path = f"/api/enforcement/deployments/{machine_id}"
1615
+ body = {"policies": [p.to_dict() for p in policies]}
1616
+ return Deployment.from_dict(_request_json(ctx, "PUT", path, json_body=body) or {})
1617
+
1618
+
1619
+ def deployment_history(ctx: ClientContext, machine_id: str) -> List[Dict[str, Any]]:
1620
+ """GET /api/enforcement/deployments/{id}/history — every generation, newest first."""
1621
+ path = f"/api/enforcement/deployments/{machine_id}/history"
1622
+ data = _get_json(ctx, path)
1623
+ return data if isinstance(data, list) else data.get("history", [])
1624
+
1625
+
1626
+ def rollback_deployment(ctx: ClientContext, machine_id: str, deployment: int) -> Deployment:
1627
+ """POST /api/enforcement/deployments/{id}/rollback — reinstate a past generation.
1628
+
1629
+ Note this mints a NEW generation carrying the old set rather than rewinding
1630
+ the counter, so the history stays append-only.
1631
+ """
1632
+ path = f"/api/enforcement/deployments/{machine_id}/rollback"
1633
+ body = {"deployment": deployment}
1634
+ return Deployment.from_dict(_post_json(ctx, path, body) or {})
1635
+
1636
+
1637
+ def enforcement_summary(
1638
+ ctx: ClientContext, hours: int = 24, machine_id: Optional[str] = None
1639
+ ) -> Dict[str, Any]:
1640
+ """GET /api/enforcement/summary — coverage from Postgres, decisions from ClickHouse."""
1641
+ params = {"hours": hours}
1642
+ if machine_id:
1643
+ params["machineId"] = machine_id
1644
+ return _get_json(ctx, "/api/enforcement/summary", params=params) or {}
1645
+
1646
+
1647
+ def decision_timeline(
1648
+ ctx: ClientContext, hours: int = 24, machine_id: Optional[str] = None
1649
+ ) -> Dict[str, Any]:
1650
+ """GET /api/enforcement/decisions/timeline — hourly deny/instruct/paused bins."""
1651
+ params = {"hours": hours}
1652
+ if machine_id:
1653
+ params["machineId"] = machine_id
1654
+ return _get_json(ctx, "/api/enforcement/decisions/timeline", params=params) or {}
1655
+
1656
+
1657
+ def compose_policy(ctx: ClientContext, intent: str) -> Dict[str, Any]:
1658
+ """POST /api/agent/compose-policy — the assistant drafts a policy source.
1659
+
1660
+ STREAMS. The route answers `text/event-stream`, not JSON: `delta` frames as
1661
+ tokens arrive, then one `done` carrying the finished source (the dashboard
1662
+ feeds those deltas into a Monaco diff). Reading it as JSON gets a parse
1663
+ error on the first frame, which is how this was written the first time.
1664
+
1665
+ The field is `intent`, not `prompt` — the server rejects anything else with
1666
+ a 400 before the model is ever called.
1667
+
1668
+ Dashboard-only, like the rest of the assistant: there is no `/v1` route
1669
+ behind it.
1670
+ """
1671
+ source = ""
1672
+ for event in _stream_sse(ctx, "/api/agent/compose-policy", {"intent": intent}):
1673
+ kind = event.get("type")
1674
+ if kind == "error":
1675
+ raise ApiError(
1676
+ str(event.get("reason") or "the policy composer hit an error"),
1677
+ hint="check `fp agent health` — the assistant may not be configured here",
1678
+ )
1679
+ if kind == "done":
1680
+ source = str(event.get("source") or "")
1681
+ return {"source": source, "usage": event.get("usage") or {}}
1682
+ # The stream ended without a `done`. Returning "" here would render as an
1683
+ # empty draft; saying so is the difference between a bug and a blank file.
1684
+ #
1685
+ # The overwhelmingly likely cause is the composer's own 30s ceiling —
1686
+ # `agent/src/server.ts` aborts the request at 30_000ms, server-side, and a
1687
+ # slower model or a longer intent simply does not finish. Naming it matters
1688
+ # because the obvious remedy (raise --timeout) does nothing: the cut is not
1689
+ # on this side.
1690
+ raise ApiError(
1691
+ "the assistant stopped before returning a policy — the composer has a "
1692
+ "30s server-side limit and this draft did not finish inside it",
1693
+ hint="try a shorter, more specific description, or run it again",
1694
+ )