amfs-http-server 0.2.0__tar.gz → 0.3.1__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.2.0 → amfs_http_server-0.3.1}/PKG-INFO +1 -1
- {amfs_http_server-0.2.0 → amfs_http_server-0.3.1}/pyproject.toml +1 -1
- {amfs_http_server-0.2.0 → amfs_http_server-0.3.1}/src/amfs_http/auth.py +7 -6
- {amfs_http_server-0.2.0 → amfs_http_server-0.3.1}/src/amfs_http/models.py +1 -0
- {amfs_http_server-0.2.0 → amfs_http_server-0.3.1}/src/amfs_http/server.py +342 -5
- {amfs_http_server-0.2.0 → amfs_http_server-0.3.1}/.gitignore +0 -0
- {amfs_http_server-0.2.0 → amfs_http_server-0.3.1}/src/amfs_http/__init__.py +0 -0
- {amfs_http_server-0.2.0 → amfs_http_server-0.3.1}/src/amfs_http/pro_proxy.py +0 -0
- {amfs_http_server-0.2.0 → amfs_http_server-0.3.1}/src/amfs_http/sse.py +0 -0
- {amfs_http_server-0.2.0 → amfs_http_server-0.3.1}/src/amfs_http/tenant_middleware.py +0 -0
|
@@ -26,6 +26,10 @@ def _check_db_key(api_key: str) -> bool:
|
|
|
26
26
|
|
|
27
27
|
Keys are stored as SHA-256 hex digests, so we hash the incoming raw key
|
|
28
28
|
before comparing.
|
|
29
|
+
|
|
30
|
+
Uses SECURITY DEFINER functions to bypass RLS — this connection does not
|
|
31
|
+
set amfs.current_account_id, so direct table queries would return nothing
|
|
32
|
+
when FORCE ROW LEVEL SECURITY is enabled.
|
|
29
33
|
"""
|
|
30
34
|
try:
|
|
31
35
|
import psycopg
|
|
@@ -39,16 +43,13 @@ def _check_db_key(api_key: str) -> bool:
|
|
|
39
43
|
|
|
40
44
|
with psycopg.connect(dsn, row_factory=dict_row) as conn:
|
|
41
45
|
row = conn.execute(
|
|
42
|
-
"SELECT
|
|
43
|
-
"WHERE key_hash = %s AND active = TRUE "
|
|
44
|
-
"AND (expires_at IS NULL OR expires_at > NOW()) "
|
|
45
|
-
"LIMIT 1",
|
|
46
|
+
"SELECT * FROM amfs_authenticate_api_key(%s)",
|
|
46
47
|
(key_hash,),
|
|
47
48
|
).fetchone()
|
|
48
49
|
if row:
|
|
49
50
|
conn.execute(
|
|
50
|
-
"
|
|
51
|
-
(row["
|
|
51
|
+
"SELECT amfs_touch_api_key_usage(%s)",
|
|
52
|
+
(row["key_id"],),
|
|
52
53
|
)
|
|
53
54
|
return row is not None
|
|
54
55
|
except Exception:
|
|
@@ -33,6 +33,7 @@ from amfs.config import load_config_or_default
|
|
|
33
33
|
from pydantic import BaseModel, Field
|
|
34
34
|
from amfs_core.models import (
|
|
35
35
|
AMFSConfig,
|
|
36
|
+
DecisionTrace,
|
|
36
37
|
Event,
|
|
37
38
|
EventType,
|
|
38
39
|
GraphEdge,
|
|
@@ -400,10 +401,11 @@ async def write_entry(
|
|
|
400
401
|
async def list_entries(
|
|
401
402
|
entity_path: str | None = Query(None),
|
|
402
403
|
branch: str = Query("main"),
|
|
404
|
+
include_superseded: bool = Query(False),
|
|
403
405
|
_auth: str | None = Depends(verify_api_key),
|
|
404
406
|
) -> dict[str, Any]:
|
|
405
407
|
mem = _get_memory()
|
|
406
|
-
entries = mem.list(entity_path, branch=branch)
|
|
408
|
+
entries = mem.list(entity_path, branch=branch, include_superseded=include_superseded)
|
|
407
409
|
return {"entries": [_entry_to_response(e) for e in entries]}
|
|
408
410
|
|
|
409
411
|
|
|
@@ -429,6 +431,7 @@ async def search_entries(
|
|
|
429
431
|
pattern_ref=req.pattern_ref,
|
|
430
432
|
sort_by=req.sort_by,
|
|
431
433
|
limit=req.limit,
|
|
434
|
+
depth=req.depth,
|
|
432
435
|
)
|
|
433
436
|
try:
|
|
434
437
|
results = mem._adapter.search(sq, branch=branch)
|
|
@@ -462,6 +465,176 @@ async def get_stats(
|
|
|
462
465
|
return json.loads(json.dumps(stats.model_dump(mode="json"), default=str))
|
|
463
466
|
|
|
464
467
|
|
|
468
|
+
# ──────────────────────────────────────────────────────────────────────
|
|
469
|
+
# Integrity verification
|
|
470
|
+
# ──────────────────────────────────────────────────────────────────────
|
|
471
|
+
|
|
472
|
+
|
|
473
|
+
@app.post("/api/v1/verify")
|
|
474
|
+
async def verify_integrity(
|
|
475
|
+
body: dict[str, Any] = {},
|
|
476
|
+
_auth: str | None = Depends(verify_api_key),
|
|
477
|
+
) -> dict[str, Any]:
|
|
478
|
+
mem = _get_memory()
|
|
479
|
+
entity_path = body.get("entity_path")
|
|
480
|
+
return mem.verify(entity_path)
|
|
481
|
+
|
|
482
|
+
|
|
483
|
+
# ──────────────────────────────────────────────────────────────────────
|
|
484
|
+
# Atomic commits
|
|
485
|
+
# ──────────────────────────────────────────────────────────────────────
|
|
486
|
+
|
|
487
|
+
|
|
488
|
+
@app.post("/api/v1/commits")
|
|
489
|
+
async def create_commit(
|
|
490
|
+
body: dict[str, Any],
|
|
491
|
+
_auth: str | None = Depends(verify_api_key),
|
|
492
|
+
) -> dict[str, Any]:
|
|
493
|
+
mem = _get_memory()
|
|
494
|
+
writes = body.get("writes", [])
|
|
495
|
+
message = body.get("message", "")
|
|
496
|
+
with mem.transaction(message) as tx:
|
|
497
|
+
for w in writes:
|
|
498
|
+
tx.write(w["entity_path"], w["key"], w.get("value"))
|
|
499
|
+
return {
|
|
500
|
+
"commit_id": tx.commit.id if tx.commit else None,
|
|
501
|
+
"message": message,
|
|
502
|
+
"entries_written": len(writes),
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
|
|
506
|
+
@app.get("/api/v1/commits")
|
|
507
|
+
async def list_commits(
|
|
508
|
+
limit: int = Query(50, ge=1, le=500),
|
|
509
|
+
_auth: str | None = Depends(verify_api_key),
|
|
510
|
+
) -> dict[str, Any]:
|
|
511
|
+
mem = _get_memory()
|
|
512
|
+
commits = mem.commit_log(limit=limit)
|
|
513
|
+
return {
|
|
514
|
+
"commits": [json.loads(json.dumps(c.model_dump(mode="json"), default=str)) for c in commits],
|
|
515
|
+
"count": len(commits),
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
|
|
519
|
+
@app.get("/api/v1/commits/{commit_id}")
|
|
520
|
+
async def get_commit(
|
|
521
|
+
commit_id: str,
|
|
522
|
+
_auth: str | None = Depends(verify_api_key),
|
|
523
|
+
) -> dict[str, Any]:
|
|
524
|
+
mem = _get_memory()
|
|
525
|
+
commit = mem.get_commit(commit_id)
|
|
526
|
+
if commit is None:
|
|
527
|
+
from fastapi import HTTPException
|
|
528
|
+
raise HTTPException(status_code=404, detail="Commit not found")
|
|
529
|
+
return json.loads(json.dumps(commit.model_dump(mode="json"), default=str))
|
|
530
|
+
|
|
531
|
+
|
|
532
|
+
@app.post("/api/v1/merge-base")
|
|
533
|
+
async def merge_base(
|
|
534
|
+
body: dict[str, Any],
|
|
535
|
+
_auth: str | None = Depends(verify_api_key),
|
|
536
|
+
) -> dict[str, Any]:
|
|
537
|
+
mem = _get_memory()
|
|
538
|
+
ancestor = mem.common_ancestor(body["commit_a"], body["commit_b"])
|
|
539
|
+
return {
|
|
540
|
+
"ancestor_commit_id": ancestor,
|
|
541
|
+
"commit_a": body["commit_a"],
|
|
542
|
+
"commit_b": body["commit_b"],
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
|
|
546
|
+
# ──────────────────────────────────────────────────────────────────────
|
|
547
|
+
# Agent binding
|
|
548
|
+
# ──────────────────────────────────────────────────────────────────────
|
|
549
|
+
|
|
550
|
+
|
|
551
|
+
@app.put("/api/v1/agents/{agent_id}/profile")
|
|
552
|
+
async def update_agent_profile(
|
|
553
|
+
agent_id: str,
|
|
554
|
+
body: dict[str, Any],
|
|
555
|
+
_auth: str | None = Depends(verify_api_key),
|
|
556
|
+
) -> dict[str, Any]:
|
|
557
|
+
from amfs_core.models import AgentProfile
|
|
558
|
+
|
|
559
|
+
mem = _get_memory()
|
|
560
|
+
profile = AgentProfile.model_validate(body)
|
|
561
|
+
agent = mem._adapter.update_agent_profile(agent_id, profile)
|
|
562
|
+
return json.loads(json.dumps(agent.model_dump(mode="json"), default=str))
|
|
563
|
+
|
|
564
|
+
|
|
565
|
+
@app.put("/api/v1/agents/{agent_id}/capabilities")
|
|
566
|
+
async def update_agent_capabilities(
|
|
567
|
+
agent_id: str,
|
|
568
|
+
body: dict[str, Any],
|
|
569
|
+
_auth: str | None = Depends(verify_api_key),
|
|
570
|
+
) -> dict[str, Any]:
|
|
571
|
+
from amfs_core.models import AgentCapability
|
|
572
|
+
|
|
573
|
+
mem = _get_memory()
|
|
574
|
+
capabilities = [AgentCapability.model_validate(c) for c in body.get("capabilities", [])]
|
|
575
|
+
agent = mem._adapter.update_agent_capabilities(agent_id, capabilities)
|
|
576
|
+
return json.loads(json.dumps(agent.model_dump(mode="json"), default=str))
|
|
577
|
+
|
|
578
|
+
|
|
579
|
+
@app.put("/api/v1/agents/{agent_id}/contracts")
|
|
580
|
+
async def update_agent_contracts(
|
|
581
|
+
agent_id: str,
|
|
582
|
+
body: dict[str, Any],
|
|
583
|
+
_auth: str | None = Depends(verify_api_key),
|
|
584
|
+
) -> dict[str, Any]:
|
|
585
|
+
from amfs_core.models import MemoryContract
|
|
586
|
+
|
|
587
|
+
mem = _get_memory()
|
|
588
|
+
contracts = [MemoryContract.model_validate(c) for c in body.get("contracts", [])]
|
|
589
|
+
agent = mem._adapter.update_agent_contracts(agent_id, contracts)
|
|
590
|
+
return json.loads(json.dumps(agent.model_dump(mode="json"), default=str))
|
|
591
|
+
|
|
592
|
+
|
|
593
|
+
@app.get("/api/v1/agents/discover")
|
|
594
|
+
async def discover_agents(
|
|
595
|
+
capability: str | None = Query(None),
|
|
596
|
+
entity_path: str | None = Query(None),
|
|
597
|
+
_auth: str | None = Depends(verify_api_key),
|
|
598
|
+
) -> dict[str, Any]:
|
|
599
|
+
mem = _get_memory()
|
|
600
|
+
agents = mem.discover_agents(capability=capability, entity_path=entity_path)
|
|
601
|
+
return {
|
|
602
|
+
"agents": [json.loads(json.dumps(a.model_dump(mode="json"), default=str)) for a in agents],
|
|
603
|
+
"count": len(agents),
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
|
|
607
|
+
# ──────────────────────────────────────────────────────────────────────
|
|
608
|
+
# Diff & patch
|
|
609
|
+
# ──────────────────────────────────────────────────────────────────────
|
|
610
|
+
|
|
611
|
+
|
|
612
|
+
@app.post("/api/v1/diff")
|
|
613
|
+
async def compute_diff(
|
|
614
|
+
body: dict[str, Any],
|
|
615
|
+
_auth: str | None = Depends(verify_api_key),
|
|
616
|
+
) -> dict[str, Any]:
|
|
617
|
+
mem = _get_memory()
|
|
618
|
+
return mem.diff(
|
|
619
|
+
body["entity_path"],
|
|
620
|
+
body["key"],
|
|
621
|
+
body.get("old_version"),
|
|
622
|
+
)
|
|
623
|
+
|
|
624
|
+
|
|
625
|
+
@app.post("/api/v1/patches")
|
|
626
|
+
async def create_patch(
|
|
627
|
+
body: dict[str, Any],
|
|
628
|
+
_auth: str | None = Depends(verify_api_key),
|
|
629
|
+
) -> dict[str, Any]:
|
|
630
|
+
mem = _get_memory()
|
|
631
|
+
return mem.create_patch(
|
|
632
|
+
body["entity_path"],
|
|
633
|
+
body["key"],
|
|
634
|
+
body.get("source_version"),
|
|
635
|
+
)
|
|
636
|
+
|
|
637
|
+
|
|
465
638
|
# ──────────────────────────────────────────────────────────────────────
|
|
466
639
|
# History
|
|
467
640
|
# ──────────────────────────────────────────────────────────────────────
|
|
@@ -541,6 +714,8 @@ def _auto_seal_trace(mem: AgentMemory) -> str | None:
|
|
|
541
714
|
if store is None:
|
|
542
715
|
return None
|
|
543
716
|
try:
|
|
717
|
+
from uuid import UUID as _UUID
|
|
718
|
+
|
|
544
719
|
now = datetime.now(timezone.utc)
|
|
545
720
|
causal = [
|
|
546
721
|
TraceEntry(
|
|
@@ -569,7 +744,20 @@ def _auto_seal_trace(mem: AgentMemory) -> str | None:
|
|
|
569
744
|
seq = _seal_sequence.get(session_id, 0)
|
|
570
745
|
parent_hash = store.get_latest_hash(session_id)
|
|
571
746
|
|
|
747
|
+
trace_id = _UUID(oss_trace.id) if oss_trace.id else None
|
|
748
|
+
|
|
749
|
+
account_id = None
|
|
750
|
+
try:
|
|
751
|
+
from amfs_postgres.tenant_context import get_request_tenant_account_id
|
|
752
|
+
tid = get_request_tenant_account_id()
|
|
753
|
+
if tid:
|
|
754
|
+
account_id = _UUID(tid)
|
|
755
|
+
except (ImportError, ValueError):
|
|
756
|
+
pass
|
|
757
|
+
|
|
572
758
|
imm = ImmutableDecisionTrace(
|
|
759
|
+
**({"id": trace_id} if trace_id else {}),
|
|
760
|
+
**({"account_id": account_id} if account_id else {}),
|
|
573
761
|
agent_id=mem.agent_id,
|
|
574
762
|
session_id=session_id,
|
|
575
763
|
sequence_number=seq,
|
|
@@ -578,7 +766,7 @@ def _auto_seal_trace(mem: AgentMemory) -> str | None:
|
|
|
578
766
|
decision_summary=getattr(oss_trace, "decision_summary", None),
|
|
579
767
|
causal_entries=causal,
|
|
580
768
|
external_contexts=contexts,
|
|
581
|
-
created_at=
|
|
769
|
+
created_at=now,
|
|
582
770
|
)
|
|
583
771
|
sealed = seal(
|
|
584
772
|
imm,
|
|
@@ -710,6 +898,19 @@ async def list_traces(
|
|
|
710
898
|
return {"traces": [t.model_dump(mode="json") for t in traces]}
|
|
711
899
|
|
|
712
900
|
|
|
901
|
+
@app.post("/api/v1/traces")
|
|
902
|
+
async def save_trace(
|
|
903
|
+
req: Request,
|
|
904
|
+
_auth: str | None = Depends(verify_api_key),
|
|
905
|
+
) -> dict[str, Any]:
|
|
906
|
+
"""Persist a decision trace (used by HttpAdapter.save_trace)."""
|
|
907
|
+
body = await req.json()
|
|
908
|
+
trace = DecisionTrace.model_validate(body)
|
|
909
|
+
mem = _get_memory()
|
|
910
|
+
saved = mem._adapter.save_trace(trace)
|
|
911
|
+
return saved.model_dump(mode="json")
|
|
912
|
+
|
|
913
|
+
|
|
713
914
|
@app.get("/api/v1/traces/{trace_id}")
|
|
714
915
|
async def get_trace(
|
|
715
916
|
trace_id: str,
|
|
@@ -928,11 +1129,37 @@ async def list_agents(
|
|
|
928
1129
|
"entries_written": 0,
|
|
929
1130
|
"entities_touched": set(),
|
|
930
1131
|
"last_active": e.provenance.written_at,
|
|
1132
|
+
"first_seen": e.provenance.written_at,
|
|
931
1133
|
}
|
|
932
1134
|
agent_data[aid]["entries_written"] += 1
|
|
933
1135
|
agent_data[aid]["entities_touched"].add(e.entity_path)
|
|
934
1136
|
if e.provenance.written_at > agent_data[aid]["last_active"]:
|
|
935
1137
|
agent_data[aid]["last_active"] = e.provenance.written_at
|
|
1138
|
+
if e.provenance.written_at and (
|
|
1139
|
+
agent_data[aid]["first_seen"] is None
|
|
1140
|
+
or e.provenance.written_at < agent_data[aid]["first_seen"]
|
|
1141
|
+
):
|
|
1142
|
+
agent_data[aid]["first_seen"] = e.provenance.written_at
|
|
1143
|
+
|
|
1144
|
+
agent_registration: dict[str, dict[str, Any]] = {}
|
|
1145
|
+
known_agent_ids = list(agent_data.keys())
|
|
1146
|
+
if known_agent_ids:
|
|
1147
|
+
try:
|
|
1148
|
+
from amfs_postgres.adapter import PostgresAdapter
|
|
1149
|
+
adapter = mem._adapter
|
|
1150
|
+
if isinstance(adapter, PostgresAdapter):
|
|
1151
|
+
with adapter._pool.connection() as conn:
|
|
1152
|
+
with conn.cursor() as cur:
|
|
1153
|
+
placeholders = ", ".join(["%s"] * len(known_agent_ids))
|
|
1154
|
+
cur.execute(
|
|
1155
|
+
f"SELECT agent_id, created_at FROM amfs_agents "
|
|
1156
|
+
f"WHERE namespace = %s AND agent_id IN ({placeholders})",
|
|
1157
|
+
[adapter._namespace, *known_agent_ids],
|
|
1158
|
+
)
|
|
1159
|
+
for row in cur.fetchall():
|
|
1160
|
+
agent_registration[row["agent_id"]] = {"created_at": row["created_at"]}
|
|
1161
|
+
except (ImportError, Exception):
|
|
1162
|
+
pass
|
|
936
1163
|
|
|
937
1164
|
agent_descriptions: dict[str, dict[str, Any]] = {}
|
|
938
1165
|
try:
|
|
@@ -949,11 +1176,14 @@ async def list_agents(
|
|
|
949
1176
|
agents = []
|
|
950
1177
|
for ad in sorted(agent_data.values(), key=lambda x: x["entries_written"], reverse=True):
|
|
951
1178
|
desc_info = agent_descriptions.get(ad["agent_id"], {})
|
|
1179
|
+
reg = agent_registration.get(ad["agent_id"], {})
|
|
1180
|
+
created = reg.get("created_at") or ad.get("first_seen")
|
|
952
1181
|
agents.append({
|
|
953
1182
|
"agentId": ad["agent_id"],
|
|
954
1183
|
"entriesWritten": ad["entries_written"],
|
|
955
1184
|
"entitiesTouched": len(ad["entities_touched"]),
|
|
956
1185
|
"lastActive": ad["last_active"].isoformat() if ad["last_active"] else None,
|
|
1186
|
+
"createdAt": created.isoformat() if created else None,
|
|
957
1187
|
"description": desc_info.get("description", ""),
|
|
958
1188
|
"platform": desc_info.get("platform", ""),
|
|
959
1189
|
})
|
|
@@ -987,6 +1217,7 @@ async def agent_memory_graph(
|
|
|
987
1217
|
"writtenAt": e.provenance.written_at.isoformat(),
|
|
988
1218
|
})
|
|
989
1219
|
|
|
1220
|
+
# Build read counts from both traces (causal_entries) and timeline events.
|
|
990
1221
|
read_entities: dict[str, dict[str, int]] = {}
|
|
991
1222
|
for t in traces:
|
|
992
1223
|
for ce in t.causal_entries:
|
|
@@ -996,6 +1227,26 @@ async def agent_memory_graph(
|
|
|
996
1227
|
read_entities[ep] = {}
|
|
997
1228
|
read_entities[ep][key] = read_entities[ep].get(key, 0) + 1
|
|
998
1229
|
|
|
1230
|
+
# Supplement with READ events from the timeline (persisted independently).
|
|
1231
|
+
total_read_events = 0
|
|
1232
|
+
try:
|
|
1233
|
+
read_events = mem._adapter.list_events(
|
|
1234
|
+
agent_id, mem.namespace, event_type="read", limit=10000,
|
|
1235
|
+
)
|
|
1236
|
+
cross_read_events = mem._adapter.list_events(
|
|
1237
|
+
agent_id, mem.namespace, event_type="cross_agent_read", limit=10000,
|
|
1238
|
+
)
|
|
1239
|
+
for ev in read_events + cross_read_events:
|
|
1240
|
+
ep = ev.details.get("entity_path", "")
|
|
1241
|
+
key = ev.details.get("key", "")
|
|
1242
|
+
if ep and key:
|
|
1243
|
+
if ep not in read_entities:
|
|
1244
|
+
read_entities[ep] = {}
|
|
1245
|
+
read_entities[ep][key] = read_entities[ep].get(key, 0) + 1
|
|
1246
|
+
total_read_events = len(read_events) + len(cross_read_events)
|
|
1247
|
+
except Exception:
|
|
1248
|
+
pass
|
|
1249
|
+
|
|
999
1250
|
entry_authors: dict[str, str] = {}
|
|
1000
1251
|
for e in entries:
|
|
1001
1252
|
entry_authors[f"{e.entity_path}/{e.key}"] = e.provenance.agent_id
|
|
@@ -1026,6 +1277,7 @@ async def agent_memory_graph(
|
|
|
1026
1277
|
"nodes": nodes,
|
|
1027
1278
|
"traceCount": len(traces),
|
|
1028
1279
|
"totalWritten": len(written_by_agent),
|
|
1280
|
+
"totalReads": total_read_events,
|
|
1029
1281
|
"crossAgentReads": cross_agent_reads,
|
|
1030
1282
|
}
|
|
1031
1283
|
|
|
@@ -1452,6 +1704,72 @@ async def recover_snapshot(
|
|
|
1452
1704
|
}
|
|
1453
1705
|
|
|
1454
1706
|
|
|
1707
|
+
# ──────────────────────────────────────────────────────────────────────
|
|
1708
|
+
# Rollback
|
|
1709
|
+
# ──────────────────────────────────────────────────────────────────────
|
|
1710
|
+
|
|
1711
|
+
|
|
1712
|
+
@app.post("/api/v1/rollback")
|
|
1713
|
+
async def rollback(
|
|
1714
|
+
body: dict[str, Any],
|
|
1715
|
+
_auth: str | None = Depends(verify_api_key),
|
|
1716
|
+
) -> dict[str, Any]:
|
|
1717
|
+
"""Rollback an agent's memory to a specific event or timestamp.
|
|
1718
|
+
|
|
1719
|
+
Accepts either ``target_event_id`` (looks up the event to get its
|
|
1720
|
+
timestamp and agent) or ``target_timestamp`` + ``agent_id``.
|
|
1721
|
+
"""
|
|
1722
|
+
mem = _get_memory()
|
|
1723
|
+
target_event_id = body.get("target_event_id")
|
|
1724
|
+
target_timestamp = body.get("target_timestamp")
|
|
1725
|
+
agent_id = body.get("agent_id")
|
|
1726
|
+
|
|
1727
|
+
if target_event_id:
|
|
1728
|
+
event = mem._adapter.get_event(target_event_id, mem.namespace)
|
|
1729
|
+
if event is None:
|
|
1730
|
+
raise HTTPException(status_code=404, detail="Event not found")
|
|
1731
|
+
timestamp = event.created_at
|
|
1732
|
+
agent_id = agent_id or event.agent_id
|
|
1733
|
+
elif target_timestamp:
|
|
1734
|
+
if not agent_id:
|
|
1735
|
+
raise HTTPException(
|
|
1736
|
+
status_code=400,
|
|
1737
|
+
detail="agent_id is required when using target_timestamp",
|
|
1738
|
+
)
|
|
1739
|
+
timestamp = datetime.fromisoformat(target_timestamp)
|
|
1740
|
+
else:
|
|
1741
|
+
raise HTTPException(
|
|
1742
|
+
status_code=400,
|
|
1743
|
+
detail="Provide target_event_id or target_timestamp",
|
|
1744
|
+
)
|
|
1745
|
+
|
|
1746
|
+
count = mem._adapter.rollback_to_timestamp(
|
|
1747
|
+
agent_id, "main", timestamp, mem.namespace,
|
|
1748
|
+
)
|
|
1749
|
+
|
|
1750
|
+
try:
|
|
1751
|
+
mem._adapter.log_event(Event(
|
|
1752
|
+
namespace=mem.namespace,
|
|
1753
|
+
agent_id=agent_id,
|
|
1754
|
+
branch="main",
|
|
1755
|
+
event_type=EventType.ROLLBACK,
|
|
1756
|
+
summary=f"Rolled back to {timestamp.isoformat()}",
|
|
1757
|
+
details={
|
|
1758
|
+
"rolled_back_to": timestamp.isoformat(),
|
|
1759
|
+
"entries_restored": count,
|
|
1760
|
+
"source_event_id": target_event_id,
|
|
1761
|
+
},
|
|
1762
|
+
))
|
|
1763
|
+
except Exception:
|
|
1764
|
+
logger.debug("Failed to log rollback event", exc_info=True)
|
|
1765
|
+
|
|
1766
|
+
return {
|
|
1767
|
+
"entries_restored": count,
|
|
1768
|
+
"rolled_back_to": timestamp.isoformat(),
|
|
1769
|
+
"agent_id": agent_id,
|
|
1770
|
+
}
|
|
1771
|
+
|
|
1772
|
+
|
|
1455
1773
|
# ──────────────────────────────────────────────────────────────────────
|
|
1456
1774
|
# Agent-scoped branches & pull requests
|
|
1457
1775
|
# ──────────────────────────────────────────────────────────────────────
|
|
@@ -2874,9 +3192,10 @@ async def cortex_status(
|
|
|
2874
3192
|
@app.get("/api/v1/cortex/digests")
|
|
2875
3193
|
async def list_cortex_digests(
|
|
2876
3194
|
digest_type: str | None = Query(None),
|
|
3195
|
+
scope: str | None = Query(None),
|
|
2877
3196
|
_auth: str | None = Depends(verify_api_key),
|
|
2878
3197
|
) -> dict[str, Any]:
|
|
2879
|
-
"""List
|
|
3198
|
+
"""List compiled digests, optionally filtered by type and scope."""
|
|
2880
3199
|
try:
|
|
2881
3200
|
from amfs_postgres.adapter import PostgresAdapter
|
|
2882
3201
|
from amfs_core.models import DigestType
|
|
@@ -2885,13 +3204,18 @@ async def list_cortex_digests(
|
|
|
2885
3204
|
adapter = mem._adapter
|
|
2886
3205
|
if isinstance(adapter, PostgresAdapter):
|
|
2887
3206
|
dt = DigestType(digest_type) if digest_type else None
|
|
2888
|
-
|
|
3207
|
+
namespace = getattr(adapter, "_namespace", "default")
|
|
3208
|
+
digests = adapter.list_digests(digest_type=dt, namespace=namespace)
|
|
3209
|
+
if scope:
|
|
3210
|
+
digests = [d for d in digests if d.scope == scope]
|
|
2889
3211
|
return {
|
|
2890
3212
|
"digests": [d.model_dump(mode="json") for d in digests],
|
|
2891
3213
|
"total": len(digests),
|
|
2892
3214
|
}
|
|
2893
|
-
except (ImportError, ValueError
|
|
3215
|
+
except (ImportError, ValueError):
|
|
2894
3216
|
pass
|
|
3217
|
+
except Exception:
|
|
3218
|
+
logger.exception("Failed to list cortex digests")
|
|
2895
3219
|
|
|
2896
3220
|
return {"digests": [], "total": 0}
|
|
2897
3221
|
|
|
@@ -2914,6 +3238,17 @@ async def cortex_activity(
|
|
|
2914
3238
|
return {"events": [], "total": 0, "throughput": [], "stats": None}
|
|
2915
3239
|
|
|
2916
3240
|
|
|
3241
|
+
@app.post("/api/v1/cortex/recompile")
|
|
3242
|
+
async def cortex_recompile(
|
|
3243
|
+
_auth: str | None = Depends(verify_api_key),
|
|
3244
|
+
) -> dict[str, Any]:
|
|
3245
|
+
"""Trigger a full digest recompilation."""
|
|
3246
|
+
if not _cortex_worker:
|
|
3247
|
+
raise HTTPException(status_code=503, detail="Cortex worker not running")
|
|
3248
|
+
count = _cortex_worker._compiler.recompile_all()
|
|
3249
|
+
return {"recompiled": count}
|
|
3250
|
+
|
|
3251
|
+
|
|
2917
3252
|
@app.post("/api/v1/webhooks/{connector_name}")
|
|
2918
3253
|
async def ingest_webhook(
|
|
2919
3254
|
connector_name: str,
|
|
@@ -3122,6 +3457,8 @@ def main() -> None:
|
|
|
3122
3457
|
logger.info("Embedded Cortex worker started")
|
|
3123
3458
|
except ImportError:
|
|
3124
3459
|
logger.warning("--with-cortex requires amfs-cortex package")
|
|
3460
|
+
except Exception:
|
|
3461
|
+
logger.exception("Failed to start embedded Cortex worker — server will run without Cortex")
|
|
3125
3462
|
else:
|
|
3126
3463
|
logger.warning("--with-cortex requires AMFS_POSTGRES_DSN")
|
|
3127
3464
|
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|