amfs-http-server 0.2.0__tar.gz → 0.3.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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: amfs-http-server
3
- Version: 0.2.0
3
+ Version: 0.3.0
4
4
  Summary: AMFS HTTP/REST API server with SSE support
5
5
  License-Expression: Apache-2.0
6
6
  Requires-Python: >=3.11
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "amfs-http-server"
3
- version = "0.2.0"
3
+ version = "0.3.0"
4
4
  description = "AMFS HTTP/REST API server with SSE support"
5
5
  requires-python = ">=3.11"
6
6
  license = "Apache-2.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 id FROM amfs_api_keys "
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
- "UPDATE amfs_api_keys SET last_used = NOW() WHERE id = %s",
51
- (row["id"],),
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:
@@ -38,6 +38,7 @@ class SearchRequest(BaseModel):
38
38
  limit: int = 100
39
39
  sort_by: str = "confidence"
40
40
  branch: str = "main"
41
+ depth: int = 3
41
42
 
42
43
 
43
44
  class ContextRequest(BaseModel):
@@ -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=datetime.now(timezone.utc),
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
 
@@ -2874,9 +3126,10 @@ async def cortex_status(
2874
3126
  @app.get("/api/v1/cortex/digests")
2875
3127
  async def list_cortex_digests(
2876
3128
  digest_type: str | None = Query(None),
3129
+ scope: str | None = Query(None),
2877
3130
  _auth: str | None = Depends(verify_api_key),
2878
3131
  ) -> dict[str, Any]:
2879
- """List all compiled digests."""
3132
+ """List compiled digests, optionally filtered by type and scope."""
2880
3133
  try:
2881
3134
  from amfs_postgres.adapter import PostgresAdapter
2882
3135
  from amfs_core.models import DigestType
@@ -2885,13 +3138,18 @@ async def list_cortex_digests(
2885
3138
  adapter = mem._adapter
2886
3139
  if isinstance(adapter, PostgresAdapter):
2887
3140
  dt = DigestType(digest_type) if digest_type else None
2888
- digests = adapter.list_digests(digest_type=dt)
3141
+ namespace = getattr(adapter, "_namespace", "default")
3142
+ digests = adapter.list_digests(digest_type=dt, namespace=namespace)
3143
+ if scope:
3144
+ digests = [d for d in digests if d.scope == scope]
2889
3145
  return {
2890
3146
  "digests": [d.model_dump(mode="json") for d in digests],
2891
3147
  "total": len(digests),
2892
3148
  }
2893
- except (ImportError, ValueError, Exception):
3149
+ except (ImportError, ValueError):
2894
3150
  pass
3151
+ except Exception:
3152
+ logger.exception("Failed to list cortex digests")
2895
3153
 
2896
3154
  return {"digests": [], "total": 0}
2897
3155
 
@@ -2914,6 +3172,17 @@ async def cortex_activity(
2914
3172
  return {"events": [], "total": 0, "throughput": [], "stats": None}
2915
3173
 
2916
3174
 
3175
+ @app.post("/api/v1/cortex/recompile")
3176
+ async def cortex_recompile(
3177
+ _auth: str | None = Depends(verify_api_key),
3178
+ ) -> dict[str, Any]:
3179
+ """Trigger a full digest recompilation."""
3180
+ if not _cortex_worker:
3181
+ raise HTTPException(status_code=503, detail="Cortex worker not running")
3182
+ count = _cortex_worker._compiler.recompile_all()
3183
+ return {"recompiled": count}
3184
+
3185
+
2917
3186
  @app.post("/api/v1/webhooks/{connector_name}")
2918
3187
  async def ingest_webhook(
2919
3188
  connector_name: str,
@@ -3122,6 +3391,8 @@ def main() -> None:
3122
3391
  logger.info("Embedded Cortex worker started")
3123
3392
  except ImportError:
3124
3393
  logger.warning("--with-cortex requires amfs-cortex package")
3394
+ except Exception:
3395
+ logger.exception("Failed to start embedded Cortex worker — server will run without Cortex")
3125
3396
  else:
3126
3397
  logger.warning("--with-cortex requires AMFS_POSTGRES_DSN")
3127
3398