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.
- fp_cli/__init__.py +10 -0
- fp_cli/__main__.py +4 -0
- fp_cli/_click_compat.py +64 -0
- fp_cli/_context.py +332 -0
- fp_cli/_version.py +1 -0
- fp_cli/analytics.py +432 -0
- fp_cli/analytics_config.py +77 -0
- fp_cli/analytics_registry.py +83 -0
- fp_cli/app.py +492 -0
- fp_cli/auth.py +160 -0
- fp_cli/client.py +1694 -0
- fp_cli/commands/__init__.py +0 -0
- fp_cli/commands/_write.py +214 -0
- fp_cli/commands/agent_cmds.py +407 -0
- fp_cli/commands/alerts_cmds.py +445 -0
- fp_cli/commands/audits_cmds.py +1054 -0
- fp_cli/commands/auth_cmds.py +512 -0
- fp_cli/commands/errors_cmds.py +190 -0
- fp_cli/commands/evals_cmds.py +161 -0
- fp_cli/commands/events_cmds.py +159 -0
- fp_cli/commands/fleet_cmds.py +416 -0
- fp_cli/commands/guardrails_cmds.py +148 -0
- fp_cli/commands/incidents_cmds.py +472 -0
- fp_cli/commands/keys_cmds.py +407 -0
- fp_cli/commands/list_cmds.py +63 -0
- fp_cli/commands/orgs_cmds.py +319 -0
- fp_cli/commands/policies_cmds.py +499 -0
- fp_cli/commands/queries_cmds.py +378 -0
- fp_cli/commands/sessions_cmds.py +151 -0
- fp_cli/commands/settings_cmds.py +150 -0
- fp_cli/commands/usage_cmds.py +35 -0
- fp_cli/commands/users_cmds.py +404 -0
- fp_cli/config.py +330 -0
- fp_cli/dates.py +78 -0
- fp_cli/enforcement.py +345 -0
- fp_cli/errors.py +98 -0
- fp_cli/models.py +891 -0
- fp_cli/orgs.py +30 -0
- fp_cli/output.py +6593 -0
- fp_cli/permissions.py +208 -0
- fp_cli/policy_check.py +290 -0
- fp_cli/py.typed +0 -0
- fp_cli/select.py +322 -0
- fp_cli/theme.py +53 -0
- fp_cloud_cli-0.0.1b1.dist-info/METADATA +335 -0
- fp_cloud_cli-0.0.1b1.dist-info/RECORD +50 -0
- fp_cloud_cli-0.0.1b1.dist-info/WHEEL +5 -0
- fp_cloud_cli-0.0.1b1.dist-info/entry_points.txt +2 -0
- fp_cloud_cli-0.0.1b1.dist-info/licenses/LICENSE +42 -0
- 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
|
+
)
|