amfs-http-server 0.1.2__tar.gz → 0.2.0__tar.gz
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.
- {amfs_http_server-0.1.2 → amfs_http_server-0.2.0}/PKG-INFO +1 -1
- {amfs_http_server-0.1.2 → amfs_http_server-0.2.0}/pyproject.toml +1 -1
- {amfs_http_server-0.1.2 → amfs_http_server-0.2.0}/src/amfs_http/models.py +17 -4
- {amfs_http_server-0.1.2 → amfs_http_server-0.2.0}/src/amfs_http/server.py +696 -65
- {amfs_http_server-0.1.2 → amfs_http_server-0.2.0}/.gitignore +0 -0
- {amfs_http_server-0.1.2 → amfs_http_server-0.2.0}/src/amfs_http/__init__.py +0 -0
- {amfs_http_server-0.1.2 → amfs_http_server-0.2.0}/src/amfs_http/auth.py +0 -0
- {amfs_http_server-0.1.2 → amfs_http_server-0.2.0}/src/amfs_http/pro_proxy.py +0 -0
- {amfs_http_server-0.1.2 → amfs_http_server-0.2.0}/src/amfs_http/sse.py +0 -0
- {amfs_http_server-0.1.2 → amfs_http_server-0.2.0}/src/amfs_http/tenant_middleware.py +0 -0
|
@@ -88,11 +88,24 @@ class UpdateTeamMemberRequest(BaseModel):
|
|
|
88
88
|
|
|
89
89
|
|
|
90
90
|
class RunPatternDetectionRequest(BaseModel):
|
|
91
|
-
incident_threshold: int = 2
|
|
92
|
-
stale_days: int = 30
|
|
93
|
-
hot_entity_stddev: float = 2.0
|
|
94
|
-
drift_stddev: float = 2.0
|
|
95
91
|
entity_path: str | None = None
|
|
92
|
+
agent_id: str | None = None
|
|
93
|
+
stale_days: int = 14
|
|
94
|
+
orphan_days: int = 7
|
|
95
|
+
pr_stale_days: int = 3
|
|
96
|
+
similarity_threshold: float = 0.75
|
|
97
|
+
incident_threshold: int = 2
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
# ──────────────────────────────────────────────────────────────────────
|
|
101
|
+
# Agent Snapshots
|
|
102
|
+
# ──────────────────────────────────────────────────────────────────────
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
class CreateSnapshotRequest(BaseModel):
|
|
106
|
+
name: str
|
|
107
|
+
description: str = ""
|
|
108
|
+
snapshot_data: dict[str, Any] = Field(default_factory=dict)
|
|
96
109
|
|
|
97
110
|
|
|
98
111
|
# ──────────────────────────────────────────────────────────────────────
|
|
@@ -19,23 +19,35 @@ import json
|
|
|
19
19
|
import logging
|
|
20
20
|
import os
|
|
21
21
|
import secrets
|
|
22
|
-
from datetime import datetime
|
|
22
|
+
from datetime import datetime, timezone
|
|
23
23
|
from typing import Any
|
|
24
24
|
|
|
25
25
|
import uvicorn
|
|
26
26
|
from fastapi import Depends, FastAPI, HTTPException, Query, Request
|
|
27
27
|
from fastapi.middleware.cors import CORSMiddleware
|
|
28
|
+
from fastapi.responses import JSONResponse
|
|
28
29
|
from sse_starlette.sse import EventSourceResponse
|
|
29
30
|
|
|
30
31
|
from amfs import AgentMemory, MemoryType, OutcomeType
|
|
31
32
|
from amfs.config import load_config_or_default
|
|
32
|
-
from
|
|
33
|
+
from pydantic import BaseModel, Field
|
|
34
|
+
from amfs_core.models import (
|
|
35
|
+
AMFSConfig,
|
|
36
|
+
Event,
|
|
37
|
+
EventType,
|
|
38
|
+
GraphEdge,
|
|
39
|
+
LayerConfig,
|
|
40
|
+
MemoryEntry,
|
|
41
|
+
SearchQuery,
|
|
42
|
+
)
|
|
43
|
+
from amfs_core.quality import HeuristicQualityEvaluator
|
|
33
44
|
|
|
34
45
|
from amfs_http.auth import verify_api_key
|
|
35
46
|
from amfs_http.models import (
|
|
36
47
|
AddTeamMemberRequest,
|
|
37
48
|
ContextRequest,
|
|
38
49
|
CreateAPIKeyRequest,
|
|
50
|
+
CreateSnapshotRequest,
|
|
39
51
|
CreateTeamRequest,
|
|
40
52
|
EventRequest,
|
|
41
53
|
OutcomeRequest,
|
|
@@ -224,6 +236,11 @@ async def health() -> dict[str, str]:
|
|
|
224
236
|
return {"status": "ok"}
|
|
225
237
|
|
|
226
238
|
|
|
239
|
+
@app.get("/api/v1/health")
|
|
240
|
+
async def health_v1() -> dict[str, str]:
|
|
241
|
+
return {"status": "ok"}
|
|
242
|
+
|
|
243
|
+
|
|
227
244
|
@app.get("/api/v1/auth/whoami")
|
|
228
245
|
async def whoami(
|
|
229
246
|
request: Request,
|
|
@@ -277,6 +294,43 @@ async def read_entry(
|
|
|
277
294
|
return _entry_to_response(entry)
|
|
278
295
|
|
|
279
296
|
|
|
297
|
+
@app.get("/api/v1/quality/{entity_path:path}/{key}")
|
|
298
|
+
async def entry_quality(
|
|
299
|
+
entity_path: str,
|
|
300
|
+
key: str,
|
|
301
|
+
branch: str = Query("main"),
|
|
302
|
+
_auth: str | None = Depends(verify_api_key),
|
|
303
|
+
) -> dict[str, Any]:
|
|
304
|
+
"""Compute a quality report for a stored entry on demand."""
|
|
305
|
+
mem = _get_memory()
|
|
306
|
+
entry = mem.read(entity_path, key, branch=branch)
|
|
307
|
+
if entry is None:
|
|
308
|
+
raise HTTPException(status_code=404, detail="Entry not found")
|
|
309
|
+
|
|
310
|
+
try:
|
|
311
|
+
existing_entries = mem.list(entity_path)
|
|
312
|
+
existing_keys = [e.key for e in existing_entries if e.key != key]
|
|
313
|
+
except Exception:
|
|
314
|
+
existing_keys = []
|
|
315
|
+
|
|
316
|
+
evaluator = HeuristicQualityEvaluator()
|
|
317
|
+
mt = entry.memory_type.value if hasattr(entry.memory_type, "value") else str(entry.memory_type)
|
|
318
|
+
report = evaluator.evaluate(
|
|
319
|
+
entry.value,
|
|
320
|
+
entity_path=entity_path,
|
|
321
|
+
key=key,
|
|
322
|
+
confidence=entry.confidence,
|
|
323
|
+
memory_type=mt,
|
|
324
|
+
pattern_refs=list(entry.provenance.pattern_refs),
|
|
325
|
+
existing_keys=existing_keys,
|
|
326
|
+
)
|
|
327
|
+
return {
|
|
328
|
+
"entity_path": entity_path,
|
|
329
|
+
"key": key,
|
|
330
|
+
"quality": report.model_dump(mode="json"),
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
|
|
280
334
|
@app.post("/api/v1/entries")
|
|
281
335
|
async def write_entry(
|
|
282
336
|
req: WriteRequest,
|
|
@@ -319,6 +373,26 @@ async def write_entry(
|
|
|
319
373
|
resource=f"{req.entity_path}/{req.key}",
|
|
320
374
|
ip_address=request.client.host if request.client else None,
|
|
321
375
|
)
|
|
376
|
+
|
|
377
|
+
try:
|
|
378
|
+
agent = entry.provenance.agent_id
|
|
379
|
+
ek = f"{entry.entity_path}/{entry.key}"
|
|
380
|
+
mem._adapter.upsert_graph_edge(
|
|
381
|
+
GraphEdge(
|
|
382
|
+
source_entity=agent,
|
|
383
|
+
source_type="agent",
|
|
384
|
+
relation="wrote",
|
|
385
|
+
target_entity=ek,
|
|
386
|
+
target_type="entry",
|
|
387
|
+
confidence=entry.confidence,
|
|
388
|
+
provenance={"agent_id": agent, "trigger": "write"},
|
|
389
|
+
),
|
|
390
|
+
namespace=mem.namespace,
|
|
391
|
+
branch=entry.branch or "main",
|
|
392
|
+
)
|
|
393
|
+
except Exception:
|
|
394
|
+
logger.debug("Failed to materialize wrote edge", exc_info=True)
|
|
395
|
+
|
|
322
396
|
return _entry_to_response(entry)
|
|
323
397
|
|
|
324
398
|
|
|
@@ -644,7 +718,6 @@ async def get_trace(
|
|
|
644
718
|
mem = _get_memory()
|
|
645
719
|
trace = mem._adapter.get_trace(trace_id)
|
|
646
720
|
if trace is None:
|
|
647
|
-
from fastapi.responses import JSONResponse
|
|
648
721
|
return JSONResponse({"error": "Trace not found"}, status_code=404)
|
|
649
722
|
return trace.model_dump(mode="json")
|
|
650
723
|
|
|
@@ -654,8 +727,6 @@ async def explain_trace(
|
|
|
654
727
|
trace_id: str,
|
|
655
728
|
_auth: str | None = Depends(verify_api_key),
|
|
656
729
|
) -> dict[str, Any]:
|
|
657
|
-
from fastapi.responses import JSONResponse
|
|
658
|
-
|
|
659
730
|
api_key = os.environ.get("AMFS_LLM_API_KEY", "")
|
|
660
731
|
if not api_key:
|
|
661
732
|
return JSONResponse(
|
|
@@ -848,6 +919,8 @@ async def list_agents(
|
|
|
848
919
|
entries = mem.list()
|
|
849
920
|
agent_data: dict[str, dict[str, Any]] = {}
|
|
850
921
|
for e in entries:
|
|
922
|
+
if e.entity_path.startswith("_system/"):
|
|
923
|
+
continue
|
|
851
924
|
aid = e.provenance.agent_id
|
|
852
925
|
if aid not in agent_data:
|
|
853
926
|
agent_data[aid] = {
|
|
@@ -861,13 +934,28 @@ async def list_agents(
|
|
|
861
934
|
if e.provenance.written_at > agent_data[aid]["last_active"]:
|
|
862
935
|
agent_data[aid]["last_active"] = e.provenance.written_at
|
|
863
936
|
|
|
937
|
+
agent_descriptions: dict[str, dict[str, Any]] = {}
|
|
938
|
+
try:
|
|
939
|
+
desc_entries = mem.list("_system/agents")
|
|
940
|
+
for de in desc_entries:
|
|
941
|
+
val = de.value if isinstance(de.value, dict) else {}
|
|
942
|
+
agent_descriptions[de.key] = {
|
|
943
|
+
"description": val.get("description", ""),
|
|
944
|
+
"platform": val.get("platform", ""),
|
|
945
|
+
}
|
|
946
|
+
except Exception:
|
|
947
|
+
pass
|
|
948
|
+
|
|
864
949
|
agents = []
|
|
865
950
|
for ad in sorted(agent_data.values(), key=lambda x: x["entries_written"], reverse=True):
|
|
951
|
+
desc_info = agent_descriptions.get(ad["agent_id"], {})
|
|
866
952
|
agents.append({
|
|
867
953
|
"agentId": ad["agent_id"],
|
|
868
954
|
"entriesWritten": ad["entries_written"],
|
|
869
955
|
"entitiesTouched": len(ad["entities_touched"]),
|
|
870
956
|
"lastActive": ad["last_active"].isoformat() if ad["last_active"] else None,
|
|
957
|
+
"description": desc_info.get("description", ""),
|
|
958
|
+
"platform": desc_info.get("platform", ""),
|
|
871
959
|
})
|
|
872
960
|
return {"agents": agents}
|
|
873
961
|
|
|
@@ -882,7 +970,10 @@ async def agent_memory_graph(
|
|
|
882
970
|
entries = mem.list()
|
|
883
971
|
traces = mem._adapter.list_traces(agent_id=agent_id, limit=10000)
|
|
884
972
|
|
|
885
|
-
written_by_agent = [
|
|
973
|
+
written_by_agent = [
|
|
974
|
+
e for e in entries
|
|
975
|
+
if e.provenance.agent_id == agent_id and not e.entity_path.startswith("_system/")
|
|
976
|
+
]
|
|
886
977
|
entities_written: dict[str, list[dict]] = {}
|
|
887
978
|
for e in written_by_agent:
|
|
888
979
|
ep = e.entity_path
|
|
@@ -1050,9 +1141,12 @@ async def agent_activity(
|
|
|
1050
1141
|
limit: int = Query(100),
|
|
1051
1142
|
_auth: str | None = Depends(verify_api_key),
|
|
1052
1143
|
) -> dict[str, Any]:
|
|
1053
|
-
"""Timeline of writes and
|
|
1144
|
+
"""Timeline of writes, outcomes, reads, and other events for this agent."""
|
|
1054
1145
|
mem = _get_memory()
|
|
1055
|
-
entries = [
|
|
1146
|
+
entries = [
|
|
1147
|
+
e for e in mem.list()
|
|
1148
|
+
if e.provenance.agent_id == agent_id and not e.entity_path.startswith("_system/")
|
|
1149
|
+
]
|
|
1056
1150
|
entries.sort(key=lambda e: e.provenance.written_at, reverse=True)
|
|
1057
1151
|
|
|
1058
1152
|
writes = [
|
|
@@ -1079,7 +1173,23 @@ async def agent_activity(
|
|
|
1079
1173
|
for t in traces if t.outcome_ref
|
|
1080
1174
|
]
|
|
1081
1175
|
|
|
1082
|
-
|
|
1176
|
+
events = mem._adapter.list_events(agent_id, mem.namespace, limit=limit)
|
|
1177
|
+
event_items = [
|
|
1178
|
+
{
|
|
1179
|
+
"type": evt.event_type.value if hasattr(evt.event_type, "value") else str(evt.event_type),
|
|
1180
|
+
"summary": evt.summary,
|
|
1181
|
+
"details": evt.details,
|
|
1182
|
+
"actorAgentId": evt.actor_agent_id,
|
|
1183
|
+
"timestamp": evt.created_at.isoformat(),
|
|
1184
|
+
}
|
|
1185
|
+
for evt in events
|
|
1186
|
+
]
|
|
1187
|
+
|
|
1188
|
+
timeline = sorted(
|
|
1189
|
+
writes + outcomes + event_items,
|
|
1190
|
+
key=lambda x: x["timestamp"],
|
|
1191
|
+
reverse=True,
|
|
1192
|
+
)[:limit]
|
|
1083
1193
|
return {"agentId": agent_id, "timeline": timeline}
|
|
1084
1194
|
|
|
1085
1195
|
|
|
@@ -1107,6 +1217,293 @@ async def agent_timeline(
|
|
|
1107
1217
|
}
|
|
1108
1218
|
|
|
1109
1219
|
|
|
1220
|
+
class LogEventRequest(BaseModel):
|
|
1221
|
+
agent_id: str
|
|
1222
|
+
event_type: str
|
|
1223
|
+
summary: str | None = None
|
|
1224
|
+
details: dict[str, Any] = Field(default_factory=dict)
|
|
1225
|
+
actor_agent_id: str | None = None
|
|
1226
|
+
branch: str = "main"
|
|
1227
|
+
|
|
1228
|
+
|
|
1229
|
+
@app.post("/api/v1/timeline/events")
|
|
1230
|
+
async def log_timeline_event(
|
|
1231
|
+
body: LogEventRequest,
|
|
1232
|
+
_auth: str | None = Depends(verify_api_key),
|
|
1233
|
+
) -> dict[str, Any]:
|
|
1234
|
+
"""Log a timeline event for an agent."""
|
|
1235
|
+
mem = _get_memory()
|
|
1236
|
+
try:
|
|
1237
|
+
event_type_enum = EventType(body.event_type)
|
|
1238
|
+
except ValueError:
|
|
1239
|
+
return JSONResponse(
|
|
1240
|
+
status_code=400,
|
|
1241
|
+
content={"error": f"Unknown event_type: {body.event_type}"},
|
|
1242
|
+
)
|
|
1243
|
+
event = Event(
|
|
1244
|
+
namespace=mem.namespace,
|
|
1245
|
+
agent_id=body.agent_id,
|
|
1246
|
+
branch=body.branch,
|
|
1247
|
+
event_type=event_type_enum,
|
|
1248
|
+
summary=body.summary,
|
|
1249
|
+
details=body.details,
|
|
1250
|
+
actor_agent_id=body.actor_agent_id,
|
|
1251
|
+
)
|
|
1252
|
+
saved = mem._adapter.log_event(event)
|
|
1253
|
+
return saved.model_dump(mode="json")
|
|
1254
|
+
|
|
1255
|
+
|
|
1256
|
+
class UpsertGraphEdgeRequest(BaseModel):
|
|
1257
|
+
source_entity: str
|
|
1258
|
+
source_type: str = "agent"
|
|
1259
|
+
relation: str
|
|
1260
|
+
target_entity: str
|
|
1261
|
+
target_type: str = "agent"
|
|
1262
|
+
provenance: dict[str, Any] = Field(default_factory=dict)
|
|
1263
|
+
branch: str = "main"
|
|
1264
|
+
|
|
1265
|
+
|
|
1266
|
+
@app.post("/api/v1/graph/edges")
|
|
1267
|
+
async def upsert_graph_edge_endpoint(
|
|
1268
|
+
body: UpsertGraphEdgeRequest,
|
|
1269
|
+
_auth: str | None = Depends(verify_api_key),
|
|
1270
|
+
) -> dict[str, Any]:
|
|
1271
|
+
"""Create or update a knowledge graph edge."""
|
|
1272
|
+
mem = _get_memory()
|
|
1273
|
+
edge = GraphEdge(
|
|
1274
|
+
source_entity=body.source_entity,
|
|
1275
|
+
source_type=body.source_type,
|
|
1276
|
+
relation=body.relation,
|
|
1277
|
+
target_entity=body.target_entity,
|
|
1278
|
+
target_type=body.target_type,
|
|
1279
|
+
provenance=body.provenance,
|
|
1280
|
+
)
|
|
1281
|
+
mem._adapter.upsert_graph_edge(edge, namespace=mem.namespace, branch=body.branch)
|
|
1282
|
+
return {"status": "ok"}
|
|
1283
|
+
|
|
1284
|
+
|
|
1285
|
+
# ──────────────────────────────────────────────────────────────────────
|
|
1286
|
+
# Agent Snapshots
|
|
1287
|
+
# ──────────────────────────────────────────────────────────────────────
|
|
1288
|
+
|
|
1289
|
+
|
|
1290
|
+
SNAPSHOT_ENTITY = "_system/agent-snapshots"
|
|
1291
|
+
|
|
1292
|
+
|
|
1293
|
+
@app.post("/api/v1/agents/{agent_id}/snapshots")
|
|
1294
|
+
async def create_snapshot(
|
|
1295
|
+
agent_id: str,
|
|
1296
|
+
req: CreateSnapshotRequest,
|
|
1297
|
+
_auth: str | None = Depends(verify_api_key),
|
|
1298
|
+
) -> dict[str, Any]:
|
|
1299
|
+
"""Create a named snapshot of an agent's brain state."""
|
|
1300
|
+
mem = _get_memory()
|
|
1301
|
+
snapshot_id = f"snap-{int(datetime.now(timezone.utc).timestamp() * 1000)}"
|
|
1302
|
+
snapshot_value = {
|
|
1303
|
+
"id": snapshot_id,
|
|
1304
|
+
"agent_id": agent_id,
|
|
1305
|
+
"name": req.name,
|
|
1306
|
+
"description": req.description,
|
|
1307
|
+
"created_at": datetime.now(timezone.utc).isoformat(),
|
|
1308
|
+
"stats": req.snapshot_data.get("stats", {}),
|
|
1309
|
+
"data": req.snapshot_data,
|
|
1310
|
+
}
|
|
1311
|
+
|
|
1312
|
+
original_agent = mem._tagger.agent_id
|
|
1313
|
+
mem._tagger.agent_id = agent_id
|
|
1314
|
+
try:
|
|
1315
|
+
mem.write(
|
|
1316
|
+
SNAPSHOT_ENTITY,
|
|
1317
|
+
f"{agent_id}/{snapshot_id}",
|
|
1318
|
+
snapshot_value,
|
|
1319
|
+
confidence=1.0,
|
|
1320
|
+
)
|
|
1321
|
+
finally:
|
|
1322
|
+
mem._tagger.agent_id = original_agent
|
|
1323
|
+
|
|
1324
|
+
try:
|
|
1325
|
+
mem._adapter.log_event(Event(
|
|
1326
|
+
namespace=mem.namespace,
|
|
1327
|
+
agent_id=agent_id,
|
|
1328
|
+
branch="main",
|
|
1329
|
+
event_type=EventType.SNAPSHOT_TAKEN,
|
|
1330
|
+
summary=f"Snapshot '{req.name}' taken",
|
|
1331
|
+
details={
|
|
1332
|
+
"snapshot_id": snapshot_id,
|
|
1333
|
+
"name": req.name,
|
|
1334
|
+
"description": req.description,
|
|
1335
|
+
**snapshot_value.get("stats", {}),
|
|
1336
|
+
},
|
|
1337
|
+
))
|
|
1338
|
+
except Exception:
|
|
1339
|
+
logger.debug("Failed to log snapshot event", exc_info=True)
|
|
1340
|
+
|
|
1341
|
+
return {
|
|
1342
|
+
"id": snapshot_id,
|
|
1343
|
+
"name": req.name,
|
|
1344
|
+
"agent_id": agent_id,
|
|
1345
|
+
"created_at": snapshot_value["created_at"],
|
|
1346
|
+
}
|
|
1347
|
+
|
|
1348
|
+
|
|
1349
|
+
@app.get("/api/v1/agents/{agent_id}/snapshots")
|
|
1350
|
+
async def list_snapshots(
|
|
1351
|
+
agent_id: str,
|
|
1352
|
+
_auth: str | None = Depends(verify_api_key),
|
|
1353
|
+
) -> dict[str, Any]:
|
|
1354
|
+
"""List all snapshots for an agent."""
|
|
1355
|
+
mem = _get_memory()
|
|
1356
|
+
entries = mem.list(entity_path=SNAPSHOT_ENTITY)
|
|
1357
|
+
prefix = f"{agent_id}/"
|
|
1358
|
+
snapshots = []
|
|
1359
|
+
for e in entries:
|
|
1360
|
+
if not e.key.startswith(prefix):
|
|
1361
|
+
continue
|
|
1362
|
+
val = e.value if isinstance(e.value, dict) else {}
|
|
1363
|
+
snapshots.append({
|
|
1364
|
+
"id": val.get("id", e.key.split("/")[-1]),
|
|
1365
|
+
"name": val.get("name", e.key),
|
|
1366
|
+
"description": val.get("description", ""),
|
|
1367
|
+
"agent_id": agent_id,
|
|
1368
|
+
"created_at": val.get("created_at", e.provenance.written_at.isoformat()),
|
|
1369
|
+
"stats": val.get("stats", {}),
|
|
1370
|
+
})
|
|
1371
|
+
snapshots.sort(key=lambda s: s["created_at"], reverse=True)
|
|
1372
|
+
return {"snapshots": snapshots, "count": len(snapshots)}
|
|
1373
|
+
|
|
1374
|
+
|
|
1375
|
+
@app.get("/api/v1/agents/{agent_id}/snapshots/{snapshot_id}")
|
|
1376
|
+
async def get_snapshot(
|
|
1377
|
+
agent_id: str,
|
|
1378
|
+
snapshot_id: str,
|
|
1379
|
+
_auth: str | None = Depends(verify_api_key),
|
|
1380
|
+
) -> dict[str, Any]:
|
|
1381
|
+
"""Get the full data for a specific snapshot."""
|
|
1382
|
+
mem = _get_memory()
|
|
1383
|
+
key = f"{agent_id}/{snapshot_id}"
|
|
1384
|
+
entry = mem.recall(SNAPSHOT_ENTITY, key)
|
|
1385
|
+
if entry is None:
|
|
1386
|
+
raise HTTPException(status_code=404, detail="Snapshot not found")
|
|
1387
|
+
val = entry.value if isinstance(entry.value, dict) else {}
|
|
1388
|
+
return val
|
|
1389
|
+
|
|
1390
|
+
|
|
1391
|
+
@app.delete("/api/v1/agents/{agent_id}/snapshots/{snapshot_id}")
|
|
1392
|
+
async def delete_snapshot(
|
|
1393
|
+
agent_id: str,
|
|
1394
|
+
snapshot_id: str,
|
|
1395
|
+
_auth: str | None = Depends(verify_api_key),
|
|
1396
|
+
) -> dict[str, Any]:
|
|
1397
|
+
"""Delete a snapshot."""
|
|
1398
|
+
mem = _get_memory()
|
|
1399
|
+
key = f"{agent_id}/{snapshot_id}"
|
|
1400
|
+
entry = mem.recall(SNAPSHOT_ENTITY, key)
|
|
1401
|
+
if entry is None:
|
|
1402
|
+
raise HTTPException(status_code=404, detail="Snapshot not found")
|
|
1403
|
+
mem.write(SNAPSHOT_ENTITY, key, None, confidence=0.0)
|
|
1404
|
+
return {"deleted": True, "id": snapshot_id}
|
|
1405
|
+
|
|
1406
|
+
|
|
1407
|
+
@app.post("/api/v1/agents/{agent_id}/snapshots/{snapshot_id}/recover")
|
|
1408
|
+
async def recover_snapshot(
|
|
1409
|
+
agent_id: str,
|
|
1410
|
+
snapshot_id: str,
|
|
1411
|
+
_auth: str | None = Depends(verify_api_key),
|
|
1412
|
+
) -> dict[str, Any]:
|
|
1413
|
+
"""Recover an agent's memory to the state captured in a snapshot."""
|
|
1414
|
+
mem = _get_memory()
|
|
1415
|
+
key = f"{agent_id}/{snapshot_id}"
|
|
1416
|
+
entry = mem.recall(SNAPSHOT_ENTITY, key)
|
|
1417
|
+
if entry is None:
|
|
1418
|
+
raise HTTPException(status_code=404, detail="Snapshot not found")
|
|
1419
|
+
|
|
1420
|
+
val = entry.value if isinstance(entry.value, dict) else {}
|
|
1421
|
+
created_at = val.get("created_at")
|
|
1422
|
+
if not created_at:
|
|
1423
|
+
raise HTTPException(status_code=400, detail="Snapshot missing timestamp")
|
|
1424
|
+
|
|
1425
|
+
timestamp = datetime.fromisoformat(created_at)
|
|
1426
|
+
count = mem._adapter.rollback_to_timestamp(
|
|
1427
|
+
agent_id, "main", timestamp, mem.namespace,
|
|
1428
|
+
)
|
|
1429
|
+
|
|
1430
|
+
try:
|
|
1431
|
+
mem._adapter.log_event(Event(
|
|
1432
|
+
namespace=mem.namespace,
|
|
1433
|
+
agent_id=agent_id,
|
|
1434
|
+
branch="main",
|
|
1435
|
+
event_type=EventType.SNAPSHOT_RECOVERED,
|
|
1436
|
+
summary=f"Recovered from snapshot '{val.get('name', snapshot_id)}'",
|
|
1437
|
+
details={
|
|
1438
|
+
"snapshot_id": snapshot_id,
|
|
1439
|
+
"name": val.get("name", ""),
|
|
1440
|
+
"recovered_to": created_at,
|
|
1441
|
+
"entries_restored": count,
|
|
1442
|
+
},
|
|
1443
|
+
))
|
|
1444
|
+
except Exception:
|
|
1445
|
+
logger.debug("Failed to log recovery event", exc_info=True)
|
|
1446
|
+
|
|
1447
|
+
return {
|
|
1448
|
+
"recovered": True,
|
|
1449
|
+
"snapshot_id": snapshot_id,
|
|
1450
|
+
"entries_restored": count,
|
|
1451
|
+
"recovered_to": created_at,
|
|
1452
|
+
}
|
|
1453
|
+
|
|
1454
|
+
|
|
1455
|
+
# ──────────────────────────────────────────────────────────────────────
|
|
1456
|
+
# Agent-scoped branches & pull requests
|
|
1457
|
+
# ──────────────────────────────────────────────────────────────────────
|
|
1458
|
+
|
|
1459
|
+
|
|
1460
|
+
@app.get("/api/v1/agents/{agent_id}/branches")
|
|
1461
|
+
async def agent_branches(
|
|
1462
|
+
agent_id: str,
|
|
1463
|
+
_auth: str | None = Depends(verify_api_key),
|
|
1464
|
+
) -> dict[str, Any]:
|
|
1465
|
+
"""Branches created or merged by this agent."""
|
|
1466
|
+
mem = _get_memory()
|
|
1467
|
+
try:
|
|
1468
|
+
all_branches = mem.list_branches() # type: ignore[attr-defined]
|
|
1469
|
+
except (AttributeError, Exception):
|
|
1470
|
+
return {"agentId": agent_id, "branches": [], "count": 0}
|
|
1471
|
+
scoped = [
|
|
1472
|
+
b for b in all_branches
|
|
1473
|
+
if getattr(b, "created_by", None) == agent_id
|
|
1474
|
+
or getattr(b, "merged_by", None) == agent_id
|
|
1475
|
+
]
|
|
1476
|
+
return {
|
|
1477
|
+
"agentId": agent_id,
|
|
1478
|
+
"branches": [b.model_dump(mode="json") if hasattr(b, "model_dump") else b for b in scoped],
|
|
1479
|
+
"count": len(scoped),
|
|
1480
|
+
}
|
|
1481
|
+
|
|
1482
|
+
|
|
1483
|
+
@app.get("/api/v1/agents/{agent_id}/pull-requests")
|
|
1484
|
+
async def agent_pull_requests(
|
|
1485
|
+
agent_id: str,
|
|
1486
|
+
_auth: str | None = Depends(verify_api_key),
|
|
1487
|
+
) -> dict[str, Any]:
|
|
1488
|
+
"""Pull requests created, merged, or closed by this agent."""
|
|
1489
|
+
mem = _get_memory()
|
|
1490
|
+
try:
|
|
1491
|
+
all_prs = mem.list_pull_requests() # type: ignore[attr-defined]
|
|
1492
|
+
except (AttributeError, Exception):
|
|
1493
|
+
return {"agentId": agent_id, "pullRequests": [], "count": 0}
|
|
1494
|
+
scoped = [
|
|
1495
|
+
pr for pr in all_prs
|
|
1496
|
+
if getattr(pr, "created_by", None) == agent_id
|
|
1497
|
+
or getattr(pr, "merged_by", None) == agent_id
|
|
1498
|
+
or getattr(pr, "closed_by", None) == agent_id
|
|
1499
|
+
]
|
|
1500
|
+
return {
|
|
1501
|
+
"agentId": agent_id,
|
|
1502
|
+
"pullRequests": [pr.model_dump(mode="json") if hasattr(pr, "model_dump") else pr for pr in scoped],
|
|
1503
|
+
"count": len(scoped),
|
|
1504
|
+
}
|
|
1505
|
+
|
|
1506
|
+
|
|
1110
1507
|
# ──────────────────────────────────────────────────────────────────────
|
|
1111
1508
|
# Pro Branching Plugin (amfs_branching — proprietary)
|
|
1112
1509
|
# ──────────────────────────────────────────────────────────────────────
|
|
@@ -1119,6 +1516,18 @@ except ImportError:
|
|
|
1119
1516
|
pass
|
|
1120
1517
|
|
|
1121
1518
|
|
|
1519
|
+
# ──────────────────────────────────────────────────────────────────────
|
|
1520
|
+
# Control Plane Plugin (amfs_control_plane — proprietary SaaS billing)
|
|
1521
|
+
# ──────────────────────────────────────────────────────────────────────
|
|
1522
|
+
|
|
1523
|
+
try:
|
|
1524
|
+
from amfs_control_plane import mount_control_plane # type: ignore[import-not-found]
|
|
1525
|
+
mount_control_plane(app)
|
|
1526
|
+
logger.info("Control-plane routes mounted (auth, billing, Stripe webhook)")
|
|
1527
|
+
except ImportError:
|
|
1528
|
+
pass
|
|
1529
|
+
|
|
1530
|
+
|
|
1122
1531
|
# ──────────────────────────────────────────────────────────────────────
|
|
1123
1532
|
# Admin — API Keys
|
|
1124
1533
|
# ──────────────────────────────────────────────────────────────────────
|
|
@@ -1133,6 +1542,18 @@ def _get_db_pool():
|
|
|
1133
1542
|
return None
|
|
1134
1543
|
|
|
1135
1544
|
|
|
1545
|
+
def _get_namespace() -> str:
|
|
1546
|
+
"""Return the namespace for the current adapter.
|
|
1547
|
+
|
|
1548
|
+
All admin queries MUST use this to scope data to the correct tenant.
|
|
1549
|
+
"""
|
|
1550
|
+
mem = _get_memory()
|
|
1551
|
+
adapter = mem._adapter
|
|
1552
|
+
if hasattr(adapter, "_namespace"):
|
|
1553
|
+
return adapter._namespace
|
|
1554
|
+
return "default"
|
|
1555
|
+
|
|
1556
|
+
|
|
1136
1557
|
def _audit_log(
|
|
1137
1558
|
action: str,
|
|
1138
1559
|
*,
|
|
@@ -1145,14 +1566,15 @@ def _audit_log(
|
|
|
1145
1566
|
pool = _get_db_pool()
|
|
1146
1567
|
if pool is None:
|
|
1147
1568
|
return
|
|
1569
|
+
ns = _get_namespace()
|
|
1148
1570
|
try:
|
|
1149
1571
|
with pool.connection() as conn:
|
|
1150
1572
|
with conn.cursor() as cur:
|
|
1151
1573
|
cur.execute(
|
|
1152
1574
|
"""INSERT INTO amfs_audit_log
|
|
1153
|
-
(actor_type, actor_name, action, resource, ip_address)
|
|
1154
|
-
VALUES (%s, %s, %s, %s, %s)""",
|
|
1155
|
-
(actor_type, actor_name, action, resource, ip_address),
|
|
1575
|
+
(namespace, actor_type, actor_name, action, resource, ip_address)
|
|
1576
|
+
VALUES (%s, %s, %s, %s, %s, %s)""",
|
|
1577
|
+
(ns, actor_type, actor_name, action, resource, ip_address),
|
|
1156
1578
|
)
|
|
1157
1579
|
except Exception:
|
|
1158
1580
|
logger.debug("Failed to write audit log", exc_info=True)
|
|
@@ -1165,12 +1587,16 @@ async def list_api_keys(
|
|
|
1165
1587
|
pool = _get_db_pool()
|
|
1166
1588
|
if pool is None:
|
|
1167
1589
|
return {"keys": []}
|
|
1590
|
+
ns = _get_namespace()
|
|
1168
1591
|
with pool.connection() as conn:
|
|
1169
1592
|
with conn.cursor() as cur:
|
|
1170
1593
|
cur.execute(
|
|
1171
1594
|
"""SELECT id, name, prefix, key_type, active, scopes,
|
|
1172
1595
|
rate_limit_rpm, last_used, created_at, expires_at
|
|
1173
|
-
FROM amfs_api_keys
|
|
1596
|
+
FROM amfs_api_keys
|
|
1597
|
+
WHERE namespace = %s
|
|
1598
|
+
ORDER BY created_at DESC""",
|
|
1599
|
+
(ns,),
|
|
1174
1600
|
)
|
|
1175
1601
|
rows = cur.fetchall()
|
|
1176
1602
|
keys = []
|
|
@@ -1206,15 +1632,17 @@ async def create_api_key(
|
|
|
1206
1632
|
raw_key = f"amfs_{secrets.token_urlsafe(32)}"
|
|
1207
1633
|
prefix = raw_key[:12]
|
|
1208
1634
|
key_hash = hashlib.sha256(raw_key.encode()).hexdigest()
|
|
1635
|
+
ns = _get_namespace()
|
|
1209
1636
|
|
|
1210
1637
|
with pool.connection() as conn:
|
|
1211
1638
|
with conn.cursor() as cur:
|
|
1212
1639
|
cur.execute(
|
|
1213
1640
|
"""INSERT INTO amfs_api_keys
|
|
1214
|
-
(name, key_hash, prefix, key_type, scopes, rate_limit_rpm, expires_at)
|
|
1215
|
-
VALUES (%s, %s, %s, %s, %s::jsonb, %s, %s)
|
|
1641
|
+
(namespace, name, key_hash, prefix, key_type, scopes, rate_limit_rpm, expires_at)
|
|
1642
|
+
VALUES (%s, %s, %s, %s, %s, %s::jsonb, %s, %s)
|
|
1216
1643
|
RETURNING id, created_at""",
|
|
1217
1644
|
(
|
|
1645
|
+
ns,
|
|
1218
1646
|
req.name,
|
|
1219
1647
|
key_hash,
|
|
1220
1648
|
prefix,
|
|
@@ -1251,11 +1679,14 @@ async def revoke_api_key(
|
|
|
1251
1679
|
pool = _get_db_pool()
|
|
1252
1680
|
if pool is None:
|
|
1253
1681
|
return {"error": "API key management requires a Postgres backend"}
|
|
1682
|
+
ns = _get_namespace()
|
|
1254
1683
|
with pool.connection() as conn:
|
|
1255
1684
|
with conn.cursor() as cur:
|
|
1256
1685
|
cur.execute(
|
|
1257
|
-
"UPDATE amfs_api_keys SET active = FALSE
|
|
1258
|
-
|
|
1686
|
+
"""UPDATE amfs_api_keys SET active = FALSE
|
|
1687
|
+
WHERE id = %s::uuid AND namespace = %s
|
|
1688
|
+
RETURNING id, name""",
|
|
1689
|
+
(key_id, ns),
|
|
1259
1690
|
)
|
|
1260
1691
|
row = cur.fetchone()
|
|
1261
1692
|
if row is None:
|
|
@@ -1283,8 +1714,9 @@ async def list_audit_log(
|
|
|
1283
1714
|
if pool is None:
|
|
1284
1715
|
return {"entries": []}
|
|
1285
1716
|
|
|
1286
|
-
|
|
1287
|
-
|
|
1717
|
+
ns = _get_namespace()
|
|
1718
|
+
conditions = ["namespace = %s"]
|
|
1719
|
+
params: list[Any] = [ns]
|
|
1288
1720
|
|
|
1289
1721
|
if action is not None and action != "all":
|
|
1290
1722
|
conditions.append("action = %s")
|
|
@@ -1370,6 +1802,7 @@ async def list_teams(
|
|
|
1370
1802
|
pool = _get_db_pool()
|
|
1371
1803
|
if pool is None:
|
|
1372
1804
|
return {"teams": []}
|
|
1805
|
+
ns = _get_namespace()
|
|
1373
1806
|
with pool.connection() as conn:
|
|
1374
1807
|
with conn.cursor() as cur:
|
|
1375
1808
|
cur.execute(
|
|
@@ -1377,9 +1810,12 @@ async def list_teams(
|
|
|
1377
1810
|
t.created_at, t.updated_at,
|
|
1378
1811
|
COUNT(m.id) AS member_count
|
|
1379
1812
|
FROM amfs_teams t
|
|
1380
|
-
LEFT JOIN amfs_team_members m
|
|
1813
|
+
LEFT JOIN amfs_team_members m
|
|
1814
|
+
ON m.team_id = t.id AND m.removed_at IS NULL
|
|
1815
|
+
WHERE t.namespace = %s
|
|
1381
1816
|
GROUP BY t.id
|
|
1382
|
-
ORDER BY t.created_at DESC"""
|
|
1817
|
+
ORDER BY t.created_at DESC""",
|
|
1818
|
+
(ns,),
|
|
1383
1819
|
)
|
|
1384
1820
|
rows = cur.fetchall()
|
|
1385
1821
|
return {
|
|
@@ -1407,13 +1843,14 @@ async def create_team(
|
|
|
1407
1843
|
pool = _get_db_pool()
|
|
1408
1844
|
if pool is None:
|
|
1409
1845
|
return {"error": "Team management requires a Postgres backend"}
|
|
1846
|
+
ns = _get_namespace()
|
|
1410
1847
|
with pool.connection() as conn:
|
|
1411
1848
|
with conn.cursor() as cur:
|
|
1412
1849
|
cur.execute(
|
|
1413
|
-
"""INSERT INTO amfs_teams (name, slug, description)
|
|
1414
|
-
VALUES (%s, %s, %s)
|
|
1850
|
+
"""INSERT INTO amfs_teams (namespace, name, slug, description)
|
|
1851
|
+
VALUES (%s, %s, %s, %s)
|
|
1415
1852
|
RETURNING id, created_at, updated_at""",
|
|
1416
|
-
(req.name, req.slug, req.description),
|
|
1853
|
+
(ns, req.name, req.slug, req.description),
|
|
1417
1854
|
)
|
|
1418
1855
|
row = cur.fetchone()
|
|
1419
1856
|
_audit_log(
|
|
@@ -1455,13 +1892,14 @@ async def update_team(
|
|
|
1455
1892
|
return {"error": "No fields to update"}
|
|
1456
1893
|
|
|
1457
1894
|
updates.append("updated_at = NOW()")
|
|
1458
|
-
|
|
1895
|
+
ns = _get_namespace()
|
|
1896
|
+
params.extend([team_id, ns])
|
|
1459
1897
|
|
|
1460
1898
|
with pool.connection() as conn:
|
|
1461
1899
|
with conn.cursor() as cur:
|
|
1462
1900
|
cur.execute(
|
|
1463
1901
|
f"""UPDATE amfs_teams SET {', '.join(updates)}
|
|
1464
|
-
WHERE id = %s::uuid
|
|
1902
|
+
WHERE id = %s::uuid AND namespace = %s
|
|
1465
1903
|
RETURNING id, name, slug, description, created_at, updated_at""",
|
|
1466
1904
|
params,
|
|
1467
1905
|
)
|
|
@@ -1493,11 +1931,14 @@ async def delete_team(
|
|
|
1493
1931
|
pool = _get_db_pool()
|
|
1494
1932
|
if pool is None:
|
|
1495
1933
|
return {"error": "Team management requires a Postgres backend"}
|
|
1934
|
+
ns = _get_namespace()
|
|
1496
1935
|
with pool.connection() as conn:
|
|
1497
1936
|
with conn.cursor() as cur:
|
|
1498
1937
|
cur.execute(
|
|
1499
|
-
"DELETE FROM amfs_teams
|
|
1500
|
-
|
|
1938
|
+
"""DELETE FROM amfs_teams
|
|
1939
|
+
WHERE id = %s::uuid AND namespace = %s
|
|
1940
|
+
RETURNING id, slug""",
|
|
1941
|
+
(team_id, ns),
|
|
1501
1942
|
)
|
|
1502
1943
|
row = cur.fetchone()
|
|
1503
1944
|
if row is None:
|
|
@@ -1518,21 +1959,36 @@ async def delete_team(
|
|
|
1518
1959
|
@app.get("/api/v1/admin/teams/{team_id}/members")
|
|
1519
1960
|
async def list_team_members(
|
|
1520
1961
|
team_id: str,
|
|
1962
|
+
include_removed: bool = Query(False),
|
|
1521
1963
|
_auth: str | None = Depends(verify_api_key),
|
|
1522
1964
|
) -> dict[str, Any]:
|
|
1523
1965
|
pool = _get_db_pool()
|
|
1524
1966
|
if pool is None:
|
|
1525
1967
|
return {"members": []}
|
|
1968
|
+
ns = _get_namespace()
|
|
1526
1969
|
with pool.connection() as conn:
|
|
1527
1970
|
with conn.cursor() as cur:
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
-
|
|
1971
|
+
if include_removed:
|
|
1972
|
+
cur.execute(
|
|
1973
|
+
"""SELECT id, email, display_name, role,
|
|
1974
|
+
invited_at, accepted_at, created_at,
|
|
1975
|
+
removed_at, removed_by
|
|
1976
|
+
FROM amfs_team_members
|
|
1977
|
+
WHERE team_id = %s::uuid AND namespace = %s
|
|
1978
|
+
ORDER BY created_at""",
|
|
1979
|
+
(team_id, ns),
|
|
1980
|
+
)
|
|
1981
|
+
else:
|
|
1982
|
+
cur.execute(
|
|
1983
|
+
"""SELECT id, email, display_name, role,
|
|
1984
|
+
invited_at, accepted_at, created_at,
|
|
1985
|
+
removed_at, removed_by
|
|
1986
|
+
FROM amfs_team_members
|
|
1987
|
+
WHERE team_id = %s::uuid AND namespace = %s
|
|
1988
|
+
AND removed_at IS NULL
|
|
1989
|
+
ORDER BY created_at""",
|
|
1990
|
+
(team_id, ns),
|
|
1991
|
+
)
|
|
1536
1992
|
rows = cur.fetchall()
|
|
1537
1993
|
return {
|
|
1538
1994
|
"members": [
|
|
@@ -1544,6 +2000,8 @@ async def list_team_members(
|
|
|
1544
2000
|
"invitedAt": row["invited_at"].isoformat(),
|
|
1545
2001
|
"acceptedAt": row["accepted_at"].isoformat() if row["accepted_at"] else None,
|
|
1546
2002
|
"createdAt": row["created_at"].isoformat(),
|
|
2003
|
+
"removedAt": row["removed_at"].isoformat() if row.get("removed_at") else None,
|
|
2004
|
+
"removedBy": row.get("removed_by"),
|
|
1547
2005
|
}
|
|
1548
2006
|
for row in rows
|
|
1549
2007
|
]
|
|
@@ -1560,15 +2018,36 @@ async def add_team_member(
|
|
|
1560
2018
|
pool = _get_db_pool()
|
|
1561
2019
|
if pool is None:
|
|
1562
2020
|
return {"error": "Team management requires a Postgres backend"}
|
|
2021
|
+
ns = _get_namespace()
|
|
1563
2022
|
with pool.connection() as conn:
|
|
1564
2023
|
with conn.cursor() as cur:
|
|
2024
|
+
# If this email was previously removed from this team, reinstate instead of duplicating
|
|
1565
2025
|
cur.execute(
|
|
1566
|
-
"""
|
|
1567
|
-
|
|
1568
|
-
|
|
1569
|
-
(team_id, req.email,
|
|
2026
|
+
"""SELECT id FROM amfs_team_members
|
|
2027
|
+
WHERE team_id = %s::uuid AND email = %s AND namespace = %s
|
|
2028
|
+
AND removed_at IS NOT NULL""",
|
|
2029
|
+
(team_id, req.email, ns),
|
|
1570
2030
|
)
|
|
1571
|
-
|
|
2031
|
+
existing_removed = cur.fetchone()
|
|
2032
|
+
if existing_removed:
|
|
2033
|
+
cur.execute(
|
|
2034
|
+
"""UPDATE amfs_team_members
|
|
2035
|
+
SET removed_at = NULL, removed_by = NULL,
|
|
2036
|
+
role = %s, display_name = %s
|
|
2037
|
+
WHERE id = %s::uuid
|
|
2038
|
+
RETURNING id, invited_at, created_at""",
|
|
2039
|
+
(req.role, req.display_name, existing_removed["id"]),
|
|
2040
|
+
)
|
|
2041
|
+
row = cur.fetchone()
|
|
2042
|
+
else:
|
|
2043
|
+
cur.execute(
|
|
2044
|
+
"""INSERT INTO amfs_team_members
|
|
2045
|
+
(namespace, team_id, email, display_name, role)
|
|
2046
|
+
VALUES (%s, %s::uuid, %s, %s, %s)
|
|
2047
|
+
RETURNING id, invited_at, created_at""",
|
|
2048
|
+
(ns, team_id, req.email, req.display_name, req.role),
|
|
2049
|
+
)
|
|
2050
|
+
row = cur.fetchone()
|
|
1572
2051
|
_audit_log(
|
|
1573
2052
|
"team.member.add",
|
|
1574
2053
|
resource=f"{team_id}/{req.email}",
|
|
@@ -1609,13 +2088,14 @@ async def update_team_member(
|
|
|
1609
2088
|
if not updates:
|
|
1610
2089
|
return {"error": "No fields to update"}
|
|
1611
2090
|
|
|
1612
|
-
|
|
2091
|
+
ns = _get_namespace()
|
|
2092
|
+
params.extend([member_id, team_id, ns])
|
|
1613
2093
|
|
|
1614
2094
|
with pool.connection() as conn:
|
|
1615
2095
|
with conn.cursor() as cur:
|
|
1616
2096
|
cur.execute(
|
|
1617
2097
|
f"""UPDATE amfs_team_members SET {', '.join(updates)}
|
|
1618
|
-
WHERE id = %s::uuid AND team_id = %s::uuid
|
|
2098
|
+
WHERE id = %s::uuid AND team_id = %s::uuid AND namespace = %s
|
|
1619
2099
|
RETURNING id, email, display_name, role, invited_at, accepted_at, created_at""",
|
|
1620
2100
|
params,
|
|
1621
2101
|
)
|
|
@@ -1649,13 +2129,17 @@ async def remove_team_member(
|
|
|
1649
2129
|
pool = _get_db_pool()
|
|
1650
2130
|
if pool is None:
|
|
1651
2131
|
return {"error": "Team management requires a Postgres backend"}
|
|
2132
|
+
removed_by = request.headers.get("X-AMFS-Dashboard-Actor", "api")
|
|
2133
|
+
ns = _get_namespace()
|
|
1652
2134
|
with pool.connection() as conn:
|
|
1653
2135
|
with conn.cursor() as cur:
|
|
1654
2136
|
cur.execute(
|
|
1655
|
-
"""
|
|
2137
|
+
"""UPDATE amfs_team_members
|
|
2138
|
+
SET removed_at = NOW(), removed_by = %s
|
|
1656
2139
|
WHERE id = %s::uuid AND team_id = %s::uuid
|
|
2140
|
+
AND namespace = %s AND removed_at IS NULL
|
|
1657
2141
|
RETURNING id, email""",
|
|
1658
|
-
(member_id, team_id),
|
|
2142
|
+
(removed_by, member_id, team_id, ns),
|
|
1659
2143
|
)
|
|
1660
2144
|
row = cur.fetchone()
|
|
1661
2145
|
if row is None:
|
|
@@ -1668,6 +2152,107 @@ async def remove_team_member(
|
|
|
1668
2152
|
return {"deleted": str(row["id"])}
|
|
1669
2153
|
|
|
1670
2154
|
|
|
2155
|
+
@app.post("/api/v1/admin/teams/{team_id}/members/{member_id}/reinstate")
|
|
2156
|
+
async def reinstate_team_member(
|
|
2157
|
+
team_id: str,
|
|
2158
|
+
member_id: str,
|
|
2159
|
+
request: Request,
|
|
2160
|
+
_auth: str | None = Depends(verify_api_key),
|
|
2161
|
+
) -> dict[str, Any]:
|
|
2162
|
+
"""Re-activate a previously removed team member."""
|
|
2163
|
+
pool = _get_db_pool()
|
|
2164
|
+
if pool is None:
|
|
2165
|
+
return {"error": "Team management requires a Postgres backend"}
|
|
2166
|
+
ns = _get_namespace()
|
|
2167
|
+
with pool.connection() as conn:
|
|
2168
|
+
with conn.cursor() as cur:
|
|
2169
|
+
cur.execute(
|
|
2170
|
+
"""UPDATE amfs_team_members
|
|
2171
|
+
SET removed_at = NULL, removed_by = NULL
|
|
2172
|
+
WHERE id = %s::uuid AND team_id = %s::uuid
|
|
2173
|
+
AND namespace = %s AND removed_at IS NOT NULL
|
|
2174
|
+
RETURNING id, email, display_name, role,
|
|
2175
|
+
invited_at, accepted_at, created_at""",
|
|
2176
|
+
(member_id, team_id, ns),
|
|
2177
|
+
)
|
|
2178
|
+
row = cur.fetchone()
|
|
2179
|
+
if row is None:
|
|
2180
|
+
return {"error": "Removed member not found"}
|
|
2181
|
+
_audit_log(
|
|
2182
|
+
"team.member.reinstate",
|
|
2183
|
+
resource=f"{team_id}/{row['email']}",
|
|
2184
|
+
ip_address=request.client.host if request.client else None,
|
|
2185
|
+
)
|
|
2186
|
+
return {
|
|
2187
|
+
"id": str(row["id"]),
|
|
2188
|
+
"email": row["email"],
|
|
2189
|
+
"displayName": row["display_name"],
|
|
2190
|
+
"role": row["role"],
|
|
2191
|
+
"invitedAt": row["invited_at"].isoformat(),
|
|
2192
|
+
"acceptedAt": row["accepted_at"].isoformat() if row["accepted_at"] else None,
|
|
2193
|
+
"createdAt": row["created_at"].isoformat(),
|
|
2194
|
+
"reinstated": True,
|
|
2195
|
+
}
|
|
2196
|
+
|
|
2197
|
+
|
|
2198
|
+
@app.get("/api/v1/admin/members/check-email")
|
|
2199
|
+
async def check_member_email(
|
|
2200
|
+
email: str = Query(...),
|
|
2201
|
+
_auth: str | None = Depends(verify_api_key),
|
|
2202
|
+
) -> dict[str, Any]:
|
|
2203
|
+
"""Check if an email is associated with any active or removed team memberships.
|
|
2204
|
+
|
|
2205
|
+
Called by the dashboard OAuth flow to determine if a returning user
|
|
2206
|
+
should be blocked (removed) or allowed through.
|
|
2207
|
+
|
|
2208
|
+
Returns status: "active", "removed", or "not_found".
|
|
2209
|
+
"""
|
|
2210
|
+
pool = _get_db_pool()
|
|
2211
|
+
if pool is None:
|
|
2212
|
+
return {"email": email, "status": "unknown", "memberships": []}
|
|
2213
|
+
ns = _get_namespace()
|
|
2214
|
+
with pool.connection() as conn:
|
|
2215
|
+
with conn.cursor() as cur:
|
|
2216
|
+
cur.execute(
|
|
2217
|
+
"""SELECT m.id, m.team_id, t.name AS team_name, m.role,
|
|
2218
|
+
m.removed_at, m.removed_by,
|
|
2219
|
+
m.created_at, m.accepted_at
|
|
2220
|
+
FROM amfs_team_members m
|
|
2221
|
+
JOIN amfs_teams t ON t.id = m.team_id
|
|
2222
|
+
WHERE m.email = %s AND m.namespace = %s
|
|
2223
|
+
ORDER BY m.removed_at NULLS FIRST""",
|
|
2224
|
+
(email, ns),
|
|
2225
|
+
)
|
|
2226
|
+
rows = cur.fetchall()
|
|
2227
|
+
if not rows:
|
|
2228
|
+
return {"email": email, "status": "not_found", "memberships": []}
|
|
2229
|
+
active = [r for r in rows if r["removed_at"] is None]
|
|
2230
|
+
removed = [r for r in rows if r["removed_at"] is not None]
|
|
2231
|
+
if active:
|
|
2232
|
+
status = "active"
|
|
2233
|
+
elif removed:
|
|
2234
|
+
status = "removed"
|
|
2235
|
+
else:
|
|
2236
|
+
status = "not_found"
|
|
2237
|
+
return {
|
|
2238
|
+
"email": email,
|
|
2239
|
+
"status": status,
|
|
2240
|
+
"memberships": [
|
|
2241
|
+
{
|
|
2242
|
+
"id": str(r["id"]),
|
|
2243
|
+
"teamId": str(r["team_id"]),
|
|
2244
|
+
"teamName": r["team_name"],
|
|
2245
|
+
"role": r["role"],
|
|
2246
|
+
"removedAt": r["removed_at"].isoformat() if r["removed_at"] else None,
|
|
2247
|
+
"removedBy": r["removed_by"],
|
|
2248
|
+
"createdAt": r["created_at"].isoformat(),
|
|
2249
|
+
"acceptedAt": r["accepted_at"].isoformat() if r["accepted_at"] else None,
|
|
2250
|
+
}
|
|
2251
|
+
for r in rows
|
|
2252
|
+
],
|
|
2253
|
+
}
|
|
2254
|
+
|
|
2255
|
+
|
|
1671
2256
|
# ──────────────────────────────────────────────────────────────────────
|
|
1672
2257
|
# Admin — Pattern Detection (Pro)
|
|
1673
2258
|
# ──────────────────────────────────────────────────────────────────────
|
|
@@ -1676,36 +2261,50 @@ async def remove_team_member(
|
|
|
1676
2261
|
@app.get("/api/v1/admin/patterns")
|
|
1677
2262
|
async def list_detected_patterns(
|
|
1678
2263
|
pattern_type: str | None = Query(None),
|
|
2264
|
+
category: str | None = Query(None),
|
|
1679
2265
|
severity: str | None = Query(None),
|
|
1680
2266
|
resolved: bool | None = Query(None),
|
|
2267
|
+
agent_id: str | None = Query(None),
|
|
1681
2268
|
limit: int = Query(100),
|
|
1682
2269
|
_auth: str | None = Depends(verify_api_key),
|
|
1683
2270
|
) -> dict[str, Any]:
|
|
1684
2271
|
"""List previously detected patterns from the database."""
|
|
2272
|
+
from amfs_patterns.detector import PATTERN_CATEGORIES, PATTERN_METADATA
|
|
2273
|
+
|
|
1685
2274
|
pool = _get_db_pool()
|
|
1686
2275
|
if pool is None:
|
|
1687
|
-
return {"patterns": []}
|
|
2276
|
+
return {"patterns": [], "categories": PATTERN_CATEGORIES, "metadata": PATTERN_METADATA}
|
|
1688
2277
|
|
|
1689
|
-
|
|
1690
|
-
|
|
2278
|
+
ns = _get_namespace()
|
|
2279
|
+
conditions = ["namespace = %s"]
|
|
2280
|
+
params: list[Any] = [ns]
|
|
1691
2281
|
|
|
1692
2282
|
if pattern_type is not None:
|
|
1693
2283
|
conditions.append("pattern_type = %s")
|
|
1694
2284
|
params.append(pattern_type)
|
|
2285
|
+
if category is not None:
|
|
2286
|
+
conditions.append("category = %s")
|
|
2287
|
+
params.append(category)
|
|
1695
2288
|
if severity is not None:
|
|
1696
2289
|
conditions.append("severity = %s")
|
|
1697
2290
|
params.append(severity)
|
|
1698
2291
|
if resolved is not None:
|
|
1699
2292
|
conditions.append("resolved = %s")
|
|
1700
2293
|
params.append(resolved)
|
|
2294
|
+
if agent_id is not None:
|
|
2295
|
+
conditions.append("(details->>'agent' = %s OR details->>'agent_a' = %s OR details->>'agent_b' = %s)")
|
|
2296
|
+
params.extend([agent_id, agent_id, agent_id])
|
|
1701
2297
|
|
|
1702
2298
|
where = " AND ".join(conditions)
|
|
1703
2299
|
sql = f"""
|
|
1704
2300
|
SELECT id, pattern_type, severity, entity_path, description,
|
|
1705
|
-
details, resolved, detected_at, resolved_at
|
|
2301
|
+
details, resolved, detected_at, resolved_at,
|
|
2302
|
+
COALESCE(category, 'collaboration') as category
|
|
1706
2303
|
FROM amfs_detected_patterns
|
|
1707
2304
|
WHERE {where}
|
|
1708
|
-
ORDER BY
|
|
2305
|
+
ORDER BY
|
|
2306
|
+
CASE severity WHEN 'critical' THEN 0 WHEN 'warning' THEN 1 ELSE 2 END,
|
|
2307
|
+
detected_at DESC
|
|
1709
2308
|
LIMIT %s
|
|
1710
2309
|
"""
|
|
1711
2310
|
params.append(limit)
|
|
@@ -1721,6 +2320,7 @@ async def list_detected_patterns(
|
|
|
1721
2320
|
"id": str(row["id"]),
|
|
1722
2321
|
"patternType": row["pattern_type"],
|
|
1723
2322
|
"severity": row["severity"],
|
|
2323
|
+
"category": row["category"],
|
|
1724
2324
|
"entityPath": row["entity_path"],
|
|
1725
2325
|
"description": row["description"],
|
|
1726
2326
|
"details": row["details"] or {},
|
|
@@ -1729,7 +2329,9 @@ async def list_detected_patterns(
|
|
|
1729
2329
|
"resolvedAt": row["resolved_at"].isoformat() if row["resolved_at"] else None,
|
|
1730
2330
|
}
|
|
1731
2331
|
for row in rows
|
|
1732
|
-
]
|
|
2332
|
+
],
|
|
2333
|
+
"categories": PATTERN_CATEGORIES,
|
|
2334
|
+
"metadata": PATTERN_METADATA,
|
|
1733
2335
|
}
|
|
1734
2336
|
|
|
1735
2337
|
|
|
@@ -1739,9 +2341,10 @@ async def run_pattern_scan(
|
|
|
1739
2341
|
request: Request,
|
|
1740
2342
|
_auth: str | None = Depends(verify_api_key),
|
|
1741
2343
|
) -> dict[str, Any]:
|
|
1742
|
-
"""Run the pattern detector and persist results."""
|
|
2344
|
+
"""Run the collaboration-aware pattern detector and persist results."""
|
|
1743
2345
|
try:
|
|
1744
2346
|
from amfs_patterns import PatternDetector
|
|
2347
|
+
from amfs_patterns.detector import PATTERN_CATEGORIES, PATTERN_METADATA
|
|
1745
2348
|
except ImportError:
|
|
1746
2349
|
return {"error": "amfs-patterns package not installed"}
|
|
1747
2350
|
|
|
@@ -1749,28 +2352,50 @@ async def run_pattern_scan(
|
|
|
1749
2352
|
entries = mem.list(req.entity_path)
|
|
1750
2353
|
outcomes = mem._adapter.list_outcomes(entity_path=req.entity_path, limit=10000)
|
|
1751
2354
|
|
|
2355
|
+
if req.agent_id:
|
|
2356
|
+
entries = [e for e in entries if e.provenance.agent_id == req.agent_id]
|
|
2357
|
+
|
|
2358
|
+
branches: list[Any] = []
|
|
2359
|
+
pull_requests: list[Any] = []
|
|
2360
|
+
try:
|
|
2361
|
+
branches = mem.list_branches() # type: ignore[attr-defined]
|
|
2362
|
+
except (AttributeError, Exception):
|
|
2363
|
+
pass
|
|
2364
|
+
try:
|
|
2365
|
+
pull_requests = mem.list_pull_requests() # type: ignore[attr-defined]
|
|
2366
|
+
except (AttributeError, Exception):
|
|
2367
|
+
pass
|
|
2368
|
+
|
|
1752
2369
|
detector = PatternDetector(
|
|
1753
|
-
incident_threshold=req.incident_threshold,
|
|
1754
2370
|
stale_days=req.stale_days,
|
|
1755
|
-
|
|
1756
|
-
|
|
2371
|
+
orphan_days=req.orphan_days,
|
|
2372
|
+
pr_stale_days=req.pr_stale_days,
|
|
2373
|
+
similarity_threshold=req.similarity_threshold,
|
|
2374
|
+
incident_threshold=req.incident_threshold,
|
|
2375
|
+
)
|
|
2376
|
+
report = detector.analyze(
|
|
2377
|
+
entries,
|
|
2378
|
+
outcome_data=outcomes,
|
|
2379
|
+
branches=branches,
|
|
2380
|
+
pull_requests=pull_requests,
|
|
1757
2381
|
)
|
|
1758
|
-
report = detector.analyze(entries, outcome_data=outcomes)
|
|
1759
2382
|
|
|
1760
2383
|
pool = _get_db_pool()
|
|
1761
2384
|
persisted = 0
|
|
1762
2385
|
if pool is not None:
|
|
1763
2386
|
with pool.connection() as conn:
|
|
1764
2387
|
with conn.cursor() as cur:
|
|
2388
|
+
cur.execute("DELETE FROM amfs_detected_patterns WHERE resolved = FALSE")
|
|
1765
2389
|
for p in report.patterns:
|
|
1766
2390
|
cur.execute(
|
|
1767
2391
|
"""INSERT INTO amfs_detected_patterns
|
|
1768
|
-
(pattern_type, severity,
|
|
1769
|
-
details, detected_at)
|
|
1770
|
-
VALUES (%s, %s, %s, %s, %s::jsonb, %s)""",
|
|
2392
|
+
(pattern_type, severity, category, entity_path,
|
|
2393
|
+
description, details, detected_at)
|
|
2394
|
+
VALUES (%s, %s, %s, %s, %s, %s::jsonb, %s)""",
|
|
1771
2395
|
(
|
|
1772
2396
|
p.pattern_type,
|
|
1773
2397
|
p.severity,
|
|
2398
|
+
p.category,
|
|
1774
2399
|
p.entity_path,
|
|
1775
2400
|
p.description,
|
|
1776
2401
|
json.dumps(p.details, default=str),
|
|
@@ -1795,6 +2420,7 @@ async def run_pattern_scan(
|
|
|
1795
2420
|
{
|
|
1796
2421
|
"patternType": p.pattern_type,
|
|
1797
2422
|
"severity": p.severity,
|
|
2423
|
+
"category": p.category,
|
|
1798
2424
|
"entityPath": p.entity_path,
|
|
1799
2425
|
"description": p.description,
|
|
1800
2426
|
"details": p.details,
|
|
@@ -1802,6 +2428,8 @@ async def run_pattern_scan(
|
|
|
1802
2428
|
}
|
|
1803
2429
|
for p in report.patterns
|
|
1804
2430
|
],
|
|
2431
|
+
"categories": PATTERN_CATEGORIES,
|
|
2432
|
+
"metadata": PATTERN_METADATA,
|
|
1805
2433
|
}
|
|
1806
2434
|
|
|
1807
2435
|
|
|
@@ -1851,8 +2479,6 @@ async def graph_neighbors(
|
|
|
1851
2479
|
_auth: str | None = Depends(verify_api_key),
|
|
1852
2480
|
) -> dict[str, Any]:
|
|
1853
2481
|
"""Traverse the knowledge graph from an entity."""
|
|
1854
|
-
from fastapi.responses import JSONResponse
|
|
1855
|
-
|
|
1856
2482
|
mem = _get_memory()
|
|
1857
2483
|
try:
|
|
1858
2484
|
edges = mem.graph_neighbors(
|
|
@@ -1878,6 +2504,7 @@ async def graph_neighbors(
|
|
|
1878
2504
|
|
|
1879
2505
|
@app.get("/api/v1/pro/graph/expertise")
|
|
1880
2506
|
async def expertise_graph(
|
|
2507
|
+
agent_id: str | None = Query(None),
|
|
1881
2508
|
limit_agents: int = Query(30, ge=1, le=200),
|
|
1882
2509
|
limit_entities: int = Query(30, ge=1, le=200),
|
|
1883
2510
|
_auth: str | None = Depends(verify_api_key),
|
|
@@ -1885,7 +2512,8 @@ async def expertise_graph(
|
|
|
1885
2512
|
"""Build an agent×entity expertise heatmap.
|
|
1886
2513
|
|
|
1887
2514
|
Returns a list of agents, entities, and cells with scores derived
|
|
1888
|
-
from write counts.
|
|
2515
|
+
from write counts. When ``agent_id`` is provided, results are scoped
|
|
2516
|
+
to that single agent.
|
|
1889
2517
|
"""
|
|
1890
2518
|
mem = _get_memory()
|
|
1891
2519
|
entries = mem.list()
|
|
@@ -1896,6 +2524,8 @@ async def expertise_graph(
|
|
|
1896
2524
|
|
|
1897
2525
|
for e in entries:
|
|
1898
2526
|
aid = e.provenance.agent_id
|
|
2527
|
+
if agent_id and aid != agent_id:
|
|
2528
|
+
continue
|
|
1899
2529
|
ep = e.entity_path
|
|
1900
2530
|
agent_totals[aid] = agent_totals.get(aid, 0) + 1
|
|
1901
2531
|
entity_totals[ep] = entity_totals.get(ep, 0) + 1
|
|
@@ -1949,11 +2579,12 @@ async def graph_backfill(
|
|
|
1949
2579
|
outcome causal chains → 'informed' + 'read' edges,
|
|
1950
2580
|
agent writes → 'wrote' edges.
|
|
1951
2581
|
"""
|
|
1952
|
-
from amfs_core.models import GraphEdge
|
|
1953
|
-
|
|
1954
2582
|
mem = _get_memory()
|
|
1955
2583
|
entries = mem.list()
|
|
1956
|
-
|
|
2584
|
+
try:
|
|
2585
|
+
outcomes = mem._adapter.list_outcomes() if hasattr(mem._adapter, "list_outcomes") else []
|
|
2586
|
+
except Exception:
|
|
2587
|
+
outcomes = []
|
|
1957
2588
|
|
|
1958
2589
|
created = 0
|
|
1959
2590
|
errors = 0
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|