amfs-http-server 0.1.0__tar.gz → 0.1.2__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.1.0
3
+ Version: 0.1.2
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.1.0"
3
+ version = "0.1.2"
4
4
  description = "AMFS HTTP/REST API server with SSE support"
5
5
  requires-python = ">=3.11"
6
6
  license = "Apache-2.0"
@@ -16,6 +16,7 @@ class WriteRequest(BaseModel):
16
16
  memory_type: str = "fact"
17
17
  shared: bool = True
18
18
  branch: str = "main"
19
+ agent_id: str | None = None
19
20
 
20
21
 
21
22
  class OutcomeRequest(BaseModel):
@@ -23,6 +24,7 @@ class OutcomeRequest(BaseModel):
23
24
  outcome_type: str
24
25
  causal_entry_keys: list[str] | None = None
25
26
  causal_confidence: float = 1.0
27
+ agent_id: str | None = None
26
28
 
27
29
 
28
30
  class SearchRequest(BaseModel):
@@ -42,6 +44,7 @@ class ContextRequest(BaseModel):
42
44
  label: str
43
45
  summary: str
44
46
  source: str | None = None
47
+ agent_id: str | None = None
45
48
 
46
49
 
47
50
  class CreateAPIKeyRequest(BaseModel):
@@ -79,12 +79,18 @@ async def _dashboard_tenant_middleware(request: Request, call_next):
79
79
  _memory: AgentMemory | None = None
80
80
  _sse_manager = SSEManager()
81
81
 
82
+ _immutable_trace_store = None
82
83
  try:
83
84
  from amfs_traces.api import mount_pro_routes
84
85
  mount_pro_routes(app)
85
86
  logger.info("Pro trace endpoints mounted at /api/v1/pro/traces")
87
+
88
+ from amfs_traces.store import PostgresImmutableTraceStore
89
+ from amfs_traces.crypto import seal, get_signing_key, get_signing_key_id
90
+ from amfs_traces.models import ImmutableDecisionTrace, TraceEntry, TraceExternalContext
91
+ _HAS_PRO_TRACES = True
86
92
  except ImportError:
87
- pass
93
+ _HAS_PRO_TRACES = False
88
94
 
89
95
  try:
90
96
  from amfs_cortex_pro import mount_cortex_pro
@@ -218,6 +224,40 @@ async def health() -> dict[str, str]:
218
224
  return {"status": "ok"}
219
225
 
220
226
 
227
+ @app.get("/api/v1/auth/whoami")
228
+ async def whoami(
229
+ request: Request,
230
+ _auth: str | None = Depends(verify_api_key),
231
+ ) -> dict[str, Any]:
232
+ """Return information about the authenticated caller.
233
+
234
+ When Pro middleware is active, this returns account, key type, scopes,
235
+ and rate limit info. In OSS mode, returns basic auth status.
236
+ """
237
+ ctx = getattr(request.state, "tenant_ctx", None)
238
+ if ctx is not None:
239
+ return {
240
+ "authenticated": True,
241
+ "mode": "pro",
242
+ "account_id": str(ctx.account_id),
243
+ "actor_id": str(ctx.actor_id),
244
+ "key_type": ctx.key_type.value if ctx.key_type else None,
245
+ "scopes": [
246
+ {
247
+ "entity_path_pattern": s.entity_path_pattern,
248
+ "permission": s.permission.value,
249
+ }
250
+ for s in ctx.scopes
251
+ ],
252
+ "rate_limit_rpm": ctx.rate_limit_rpm,
253
+ "is_admin": ctx.is_admin,
254
+ }
255
+ return {
256
+ "authenticated": _auth is not None,
257
+ "mode": "oss",
258
+ }
259
+
260
+
221
261
  # ──────────────────────────────────────────────────────────────────────
222
262
  # Entries
223
263
  # ──────────────────────────────────────────────────────────────────────
@@ -252,16 +292,27 @@ async def write_entry(
252
292
  }
253
293
  mt = type_map.get(req.memory_type.lower(), MemoryType.FACT)
254
294
 
255
- entry = mem.write(
256
- req.entity_path,
257
- req.key,
258
- req.value,
259
- confidence=req.confidence,
260
- pattern_refs=req.pattern_refs or None,
261
- memory_type=mt,
262
- shared=req.shared,
263
- branch=req.branch,
264
- )
295
+ original_agent = mem._tagger.agent_id if req.agent_id else None
296
+ if req.agent_id:
297
+ mem._tagger.agent_id = req.agent_id
298
+ try:
299
+ mem._adapter.ensure_agent(req.agent_id, mem.namespace)
300
+ except Exception:
301
+ pass
302
+ try:
303
+ entry = mem.write(
304
+ req.entity_path,
305
+ req.key,
306
+ req.value,
307
+ confidence=req.confidence,
308
+ pattern_refs=req.pattern_refs or None,
309
+ memory_type=mt,
310
+ shared=req.shared,
311
+ branch=req.branch,
312
+ )
313
+ finally:
314
+ if original_agent is not None:
315
+ mem._tagger.agent_id = original_agent
265
316
  _sse_manager.broadcast(entry)
266
317
  _audit_log(
267
318
  "memory.write",
@@ -380,6 +431,98 @@ _OUTCOME_TYPE_MAP = {
380
431
  }
381
432
 
382
433
 
434
+ def _get_immutable_store():
435
+ """Lazily create the immutable trace store (Pro only)."""
436
+ global _immutable_trace_store
437
+ if _immutable_trace_store is not None:
438
+ return _immutable_trace_store
439
+ if not _HAS_PRO_TRACES:
440
+ return None
441
+ dsn = os.environ.get("AMFS_POSTGRES_DSN")
442
+ if not dsn:
443
+ return None
444
+ try:
445
+ import psycopg
446
+ from psycopg.rows import dict_row
447
+ conn = psycopg.connect(dsn, row_factory=dict_row, autocommit=True)
448
+ _immutable_trace_store = PostgresImmutableTraceStore(conn)
449
+ logger.info("Immutable trace store initialized")
450
+ return _immutable_trace_store
451
+ except Exception:
452
+ logger.debug("Failed to init immutable trace store", exc_info=True)
453
+ return None
454
+
455
+
456
+ _seal_sequence: dict[str, int] = {}
457
+
458
+
459
+ def _auto_seal_trace(mem: AgentMemory) -> str | None:
460
+ """If Pro traces are available, auto-seal the last OSS trace as immutable."""
461
+ if not _HAS_PRO_TRACES:
462
+ return None
463
+ oss_trace = getattr(mem, "_last_trace", None)
464
+ if oss_trace is None:
465
+ return None
466
+ store = _get_immutable_store()
467
+ if store is None:
468
+ return None
469
+ try:
470
+ now = datetime.now(timezone.utc)
471
+ causal = [
472
+ TraceEntry(
473
+ entity_path=e.entity_path,
474
+ key=e.key,
475
+ version=e.version,
476
+ confidence=e.confidence,
477
+ value=e.value,
478
+ memory_type=getattr(e, "memory_type", None) or "fact",
479
+ written_by=getattr(e, "written_by", None),
480
+ read_at=getattr(e, "read_at", None) or now,
481
+ )
482
+ for e in (oss_trace.causal_entries or [])
483
+ ]
484
+ contexts = [
485
+ TraceExternalContext(
486
+ label=c.label,
487
+ summary=c.summary,
488
+ source=getattr(c, "source", None),
489
+ recorded_at=getattr(c, "recorded_at", None) or now,
490
+ )
491
+ for c in (oss_trace.external_contexts or [])
492
+ ]
493
+
494
+ session_id = mem.session_id
495
+ seq = _seal_sequence.get(session_id, 0)
496
+ parent_hash = store.get_latest_hash(session_id)
497
+
498
+ imm = ImmutableDecisionTrace(
499
+ agent_id=mem.agent_id,
500
+ session_id=session_id,
501
+ sequence_number=seq,
502
+ outcome_ref=oss_trace.outcome_ref,
503
+ outcome_type=oss_trace.outcome_type,
504
+ decision_summary=getattr(oss_trace, "decision_summary", None),
505
+ causal_entries=causal,
506
+ external_contexts=contexts,
507
+ created_at=datetime.now(timezone.utc),
508
+ )
509
+ sealed = seal(
510
+ imm,
511
+ get_signing_key(),
512
+ parent_hash=parent_hash,
513
+ sequence_number=seq,
514
+ signing_key_id=get_signing_key_id(),
515
+ )
516
+ saved = store.save(sealed)
517
+ _seal_sequence[session_id] = seq + 1
518
+ logger.info("Auto-sealed immutable trace %s for outcome %s",
519
+ saved.id, oss_trace.outcome_ref)
520
+ return str(saved.id)
521
+ except Exception:
522
+ logger.warning("Failed to auto-seal immutable trace", exc_info=True)
523
+ return None
524
+
525
+
383
526
  @app.post("/api/v1/outcomes")
384
527
  async def commit_outcome(
385
528
  req: OutcomeRequest,
@@ -393,23 +536,40 @@ async def commit_outcome(
393
536
  valid = ", ".join(_OUTCOME_TYPE_MAP.keys())
394
537
  return {"error": f"Invalid outcome_type '{req.outcome_type}'. Must be one of: {valid}"}
395
538
 
396
- entries = mem.commit_outcome(
397
- req.outcome_ref,
398
- otype,
399
- causal_entry_keys=req.causal_entry_keys,
400
- causal_confidence=req.causal_confidence,
401
- )
539
+ original_agent = mem._tagger.agent_id if req.agent_id else None
540
+ if req.agent_id:
541
+ mem._tagger.agent_id = req.agent_id
542
+ try:
543
+ mem._adapter.ensure_agent(req.agent_id, mem.namespace)
544
+ except Exception:
545
+ pass
546
+ try:
547
+ entries = mem.commit_outcome(
548
+ req.outcome_ref,
549
+ otype,
550
+ causal_entry_keys=req.causal_entry_keys,
551
+ causal_confidence=req.causal_confidence,
552
+ )
553
+ finally:
554
+ if original_agent is not None:
555
+ mem._tagger.agent_id = original_agent
402
556
  _audit_log(
403
557
  "outcome.commit",
404
558
  resource=req.outcome_ref,
405
559
  ip_address=request.client.host if request.client else None,
406
560
  )
407
- return {
561
+
562
+ immutable_trace_id = _auto_seal_trace(mem)
563
+
564
+ result: dict[str, Any] = {
408
565
  "outcome_ref": req.outcome_ref,
409
566
  "outcome_type": req.outcome_type,
410
567
  "affected_entries": len(entries),
411
568
  "entries": [_entry_to_response(e) for e in entries],
412
569
  }
570
+ if immutable_trace_id:
571
+ result["immutable_trace_id"] = immutable_trace_id
572
+ return result
413
573
 
414
574
 
415
575
  @app.get("/api/v1/outcomes")
@@ -1680,6 +1840,42 @@ async def resolve_pattern(
1680
1840
  # ──────────────────────────────────────────────────────────────────────
1681
1841
 
1682
1842
 
1843
+ @app.get("/api/v1/pro/graph/neighbors")
1844
+ async def graph_neighbors(
1845
+ entity: str = Query(...),
1846
+ relation: str | None = Query(None),
1847
+ direction: str = Query("both"),
1848
+ min_confidence: float = Query(0.0),
1849
+ depth: int = Query(1, ge=1, le=5),
1850
+ limit: int = Query(200, ge=1, le=1000),
1851
+ _auth: str | None = Depends(verify_api_key),
1852
+ ) -> dict[str, Any]:
1853
+ """Traverse the knowledge graph from an entity."""
1854
+ from fastapi.responses import JSONResponse
1855
+
1856
+ mem = _get_memory()
1857
+ try:
1858
+ edges = mem.graph_neighbors(
1859
+ entity,
1860
+ relation=relation,
1861
+ direction=direction,
1862
+ min_confidence=min_confidence,
1863
+ depth=depth,
1864
+ limit=limit,
1865
+ )
1866
+ except Exception as exc:
1867
+ logger.warning("graph_neighbors failed for %s: %s", entity, exc)
1868
+ return JSONResponse(
1869
+ {"entity": entity, "edges": [], "count": 0, "error": str(exc)},
1870
+ status_code=200,
1871
+ )
1872
+ return {
1873
+ "entity": entity,
1874
+ "edges": [e.model_dump(mode="json") for e in edges],
1875
+ "count": len(edges),
1876
+ }
1877
+
1878
+
1683
1879
  @app.get("/api/v1/pro/graph/expertise")
1684
1880
  async def expertise_graph(
1685
1881
  limit_agents: int = Query(30, ge=1, le=200),
@@ -1743,6 +1939,92 @@ async def expertise_graph(
1743
1939
  }
1744
1940
 
1745
1941
 
1942
+ @app.post("/api/v1/pro/graph/backfill")
1943
+ async def graph_backfill(
1944
+ _auth: str | None = Depends(verify_api_key),
1945
+ ) -> dict[str, Any]:
1946
+ """Backfill knowledge graph edges from existing entries and outcomes.
1947
+
1948
+ Materializes edges from: pattern_refs → 'references' edges,
1949
+ outcome causal chains → 'informed' + 'read' edges,
1950
+ agent writes → 'wrote' edges.
1951
+ """
1952
+ from amfs_core.models import GraphEdge
1953
+
1954
+ mem = _get_memory()
1955
+ entries = mem.list()
1956
+ outcomes = mem._adapter.list_outcomes(namespace=mem.namespace) if hasattr(mem._adapter, "list_outcomes") else []
1957
+
1958
+ created = 0
1959
+ errors = 0
1960
+
1961
+ for e in entries:
1962
+ aid = e.provenance.agent_id
1963
+ ep = e.entity_path
1964
+ ek = f"{ep}/{e.key}"
1965
+
1966
+ try:
1967
+ mem._adapter.upsert_graph_edge(
1968
+ GraphEdge(
1969
+ source_entity=aid,
1970
+ source_type="agent",
1971
+ relation="wrote",
1972
+ target_entity=ek,
1973
+ target_type="entry",
1974
+ confidence=e.confidence,
1975
+ provenance={"agent_id": aid, "trigger": "backfill"},
1976
+ ),
1977
+ namespace=mem.namespace,
1978
+ branch=e.branch or "main",
1979
+ )
1980
+ created += 1
1981
+ except Exception:
1982
+ errors += 1
1983
+
1984
+ for ref in (e.provenance.pattern_refs or []):
1985
+ try:
1986
+ mem._adapter.upsert_graph_edge(
1987
+ GraphEdge(
1988
+ source_entity=ek,
1989
+ source_type="entry",
1990
+ relation="references",
1991
+ target_entity=ref,
1992
+ target_type="entry",
1993
+ provenance={"agent_id": aid, "trigger": "backfill"},
1994
+ ),
1995
+ namespace=mem.namespace,
1996
+ branch=e.branch or "main",
1997
+ )
1998
+ created += 1
1999
+ except Exception:
2000
+ errors += 1
2001
+
2002
+ for o in outcomes:
2003
+ otype = getattr(o, "outcome_type", None)
2004
+ edge_conf = 1.0 if otype and otype.value == "success" else 0.7
2005
+ aid = getattr(o, "agent_id", "unknown")
2006
+ for ek in getattr(o, "causal_entry_keys", []):
2007
+ try:
2008
+ mem._adapter.upsert_graph_edge(
2009
+ GraphEdge(
2010
+ source_entity=ek,
2011
+ source_type="entry",
2012
+ relation="informed",
2013
+ target_entity=o.outcome_ref,
2014
+ target_type="outcome",
2015
+ confidence=edge_conf,
2016
+ provenance={"agent_id": aid, "trigger": "backfill"},
2017
+ ),
2018
+ namespace=mem.namespace,
2019
+ branch="main",
2020
+ )
2021
+ created += 1
2022
+ except Exception:
2023
+ errors += 1
2024
+
2025
+ return {"edges_created": created, "errors": errors, "entries_scanned": len(entries), "outcomes_scanned": len(outcomes)}
2026
+
2027
+
1746
2028
  # ──────────────────────────────────────────────────────────────────────
1747
2029
  # Pro — HMO Memory Tiers
1748
2030
  # ──────────────────────────────────────────────────────────────────────
@@ -1759,11 +2041,14 @@ def _compute_tiers(entries: list) -> tuple[dict[str, int], dict[str, float]]:
1759
2041
 
1760
2042
  @app.get("/api/v1/pro/tiers/distribution")
1761
2043
  async def tiers_distribution(
2044
+ agent_id: str | None = Query(None),
1762
2045
  _auth: str | None = Depends(verify_api_key),
1763
2046
  ) -> dict[str, Any]:
1764
2047
  """Return HMO tier distribution (Hot / Warm / Archive)."""
1765
2048
  mem = _get_memory()
1766
2049
  entries = mem.list()
2050
+ if agent_id:
2051
+ entries = [e for e in entries if e.provenance.agent_id == agent_id]
1767
2052
 
1768
2053
  tiers, scores = _compute_tiers(entries)
1769
2054
 
@@ -1795,11 +2080,14 @@ async def tiers_distribution(
1795
2080
  async def tiers_entries(
1796
2081
  tier: int = Query(..., ge=1, le=3),
1797
2082
  limit: int = Query(50, ge=1, le=500),
2083
+ agent_id: str | None = Query(None),
1798
2084
  _auth: str | None = Depends(verify_api_key),
1799
2085
  ) -> list[dict[str, Any]]:
1800
2086
  """Return entries for a given HMO tier as a flat array."""
1801
2087
  mem = _get_memory()
1802
2088
  entries = mem.list()
2089
+ if agent_id:
2090
+ entries = [e for e in entries if e.provenance.agent_id == agent_id]
1803
2091
 
1804
2092
  tier_map, score_map = _compute_tiers(entries)
1805
2093
 
@@ -14,11 +14,16 @@ logger = logging.getLogger(__name__)
14
14
  def apply_tenant_headers_from_request(request: Request) -> None:
15
15
  """If proxy secret + account id headers match env, set thread-local tenant for DB RLS."""
16
16
  try:
17
- from amfs_postgres.tenant_context import set_tls_tenant_account_id
17
+ from amfs_postgres.tenant_context import (
18
+ get_request_tenant_account_id,
19
+ set_tls_tenant_account_id,
20
+ )
18
21
  except ImportError:
19
22
  return
20
23
 
21
- set_tls_tenant_account_id(None)
24
+ if get_request_tenant_account_id() is not None:
25
+ return
26
+
22
27
  secret = os.environ.get("AMFS_DASHBOARD_PROXY_SECRET", "")
23
28
  if not secret:
24
29
  return