aethergraph 0.1.0a1__py3-none-any.whl → 0.1.0a3__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- aethergraph/__init__.py +4 -10
- aethergraph/__main__.py +296 -0
- aethergraph/api/v1/__init__.py +0 -0
- aethergraph/api/v1/agents.py +46 -0
- aethergraph/api/v1/apps.py +70 -0
- aethergraph/api/v1/artifacts.py +415 -0
- aethergraph/api/v1/channels.py +89 -0
- aethergraph/api/v1/deps.py +168 -0
- aethergraph/api/v1/graphs.py +259 -0
- aethergraph/api/v1/identity.py +25 -0
- aethergraph/api/v1/memory.py +353 -0
- aethergraph/api/v1/misc.py +47 -0
- aethergraph/api/v1/pagination.py +29 -0
- aethergraph/api/v1/runs.py +568 -0
- aethergraph/api/v1/schemas.py +535 -0
- aethergraph/api/v1/session.py +323 -0
- aethergraph/api/v1/stats.py +201 -0
- aethergraph/api/v1/viz.py +152 -0
- aethergraph/config/config.py +22 -0
- aethergraph/config/loader.py +3 -2
- aethergraph/config/storage.py +209 -0
- aethergraph/contracts/__init__.py +0 -0
- aethergraph/contracts/services/__init__.py +0 -0
- aethergraph/contracts/services/artifacts.py +27 -14
- aethergraph/contracts/services/memory.py +45 -17
- aethergraph/contracts/services/metering.py +129 -0
- aethergraph/contracts/services/runs.py +50 -0
- aethergraph/contracts/services/sessions.py +87 -0
- aethergraph/contracts/services/state_stores.py +3 -0
- aethergraph/contracts/services/viz.py +44 -0
- aethergraph/contracts/storage/artifact_index.py +88 -0
- aethergraph/contracts/storage/artifact_store.py +99 -0
- aethergraph/contracts/storage/async_kv.py +34 -0
- aethergraph/contracts/storage/blob_store.py +50 -0
- aethergraph/contracts/storage/doc_store.py +35 -0
- aethergraph/contracts/storage/event_log.py +31 -0
- aethergraph/contracts/storage/vector_index.py +48 -0
- aethergraph/core/__init__.py +0 -0
- aethergraph/core/execution/forward_scheduler.py +13 -2
- aethergraph/core/execution/global_scheduler.py +21 -15
- aethergraph/core/execution/step_forward.py +10 -1
- aethergraph/core/graph/__init__.py +0 -0
- aethergraph/core/graph/graph_builder.py +8 -4
- aethergraph/core/graph/graph_fn.py +156 -15
- aethergraph/core/graph/graph_spec.py +8 -0
- aethergraph/core/graph/graphify.py +146 -27
- aethergraph/core/graph/node_spec.py +0 -2
- aethergraph/core/graph/node_state.py +3 -0
- aethergraph/core/graph/task_graph.py +39 -1
- aethergraph/core/runtime/__init__.py +0 -0
- aethergraph/core/runtime/ad_hoc_context.py +64 -4
- aethergraph/core/runtime/base_service.py +28 -4
- aethergraph/core/runtime/execution_context.py +13 -15
- aethergraph/core/runtime/graph_runner.py +222 -37
- aethergraph/core/runtime/node_context.py +510 -6
- aethergraph/core/runtime/node_services.py +12 -5
- aethergraph/core/runtime/recovery.py +15 -1
- aethergraph/core/runtime/run_manager.py +783 -0
- aethergraph/core/runtime/run_manager_local.py +204 -0
- aethergraph/core/runtime/run_registration.py +2 -2
- aethergraph/core/runtime/run_types.py +89 -0
- aethergraph/core/runtime/runtime_env.py +136 -7
- aethergraph/core/runtime/runtime_metering.py +71 -0
- aethergraph/core/runtime/runtime_registry.py +36 -13
- aethergraph/core/runtime/runtime_services.py +194 -6
- aethergraph/core/tools/builtins/toolset.py +1 -1
- aethergraph/core/tools/toolkit.py +5 -0
- aethergraph/plugins/agents/default_chat_agent copy.py +90 -0
- aethergraph/plugins/agents/default_chat_agent.py +171 -0
- aethergraph/plugins/agents/shared.py +81 -0
- aethergraph/plugins/channel/adapters/webui.py +112 -112
- aethergraph/plugins/channel/routes/webui_routes.py +367 -102
- aethergraph/plugins/channel/utils/slack_utils.py +115 -59
- aethergraph/plugins/channel/utils/telegram_utils.py +88 -47
- aethergraph/plugins/channel/websockets/weibui_ws.py +172 -0
- aethergraph/runtime/__init__.py +15 -0
- aethergraph/server/app_factory.py +196 -34
- aethergraph/server/clients/channel_client.py +202 -0
- aethergraph/server/http/channel_http_routes.py +116 -0
- aethergraph/server/http/channel_ws_routers.py +45 -0
- aethergraph/server/loading.py +117 -0
- aethergraph/server/server.py +131 -0
- aethergraph/server/server_state.py +240 -0
- aethergraph/server/start.py +227 -66
- aethergraph/server/ui_static/assets/KaTeX_AMS-Regular-BQhdFMY1.woff2 +0 -0
- aethergraph/server/ui_static/assets/KaTeX_AMS-Regular-DMm9YOAa.woff +0 -0
- aethergraph/server/ui_static/assets/KaTeX_AMS-Regular-DRggAlZN.ttf +0 -0
- aethergraph/server/ui_static/assets/KaTeX_Caligraphic-Bold-ATXxdsX0.ttf +0 -0
- aethergraph/server/ui_static/assets/KaTeX_Caligraphic-Bold-BEiXGLvX.woff +0 -0
- aethergraph/server/ui_static/assets/KaTeX_Caligraphic-Bold-Dq_IR9rO.woff2 +0 -0
- aethergraph/server/ui_static/assets/KaTeX_Caligraphic-Regular-CTRA-rTL.woff +0 -0
- aethergraph/server/ui_static/assets/KaTeX_Caligraphic-Regular-Di6jR-x-.woff2 +0 -0
- aethergraph/server/ui_static/assets/KaTeX_Caligraphic-Regular-wX97UBjC.ttf +0 -0
- aethergraph/server/ui_static/assets/KaTeX_Fraktur-Bold-BdnERNNW.ttf +0 -0
- aethergraph/server/ui_static/assets/KaTeX_Fraktur-Bold-BsDP51OF.woff +0 -0
- aethergraph/server/ui_static/assets/KaTeX_Fraktur-Bold-CL6g_b3V.woff2 +0 -0
- aethergraph/server/ui_static/assets/KaTeX_Fraktur-Regular-CB_wures.ttf +0 -0
- aethergraph/server/ui_static/assets/KaTeX_Fraktur-Regular-CTYiF6lA.woff2 +0 -0
- aethergraph/server/ui_static/assets/KaTeX_Fraktur-Regular-Dxdc4cR9.woff +0 -0
- aethergraph/server/ui_static/assets/KaTeX_Main-Bold-Cx986IdX.woff2 +0 -0
- aethergraph/server/ui_static/assets/KaTeX_Main-Bold-Jm3AIy58.woff +0 -0
- aethergraph/server/ui_static/assets/KaTeX_Main-Bold-waoOVXN0.ttf +0 -0
- aethergraph/server/ui_static/assets/KaTeX_Main-BoldItalic-DxDJ3AOS.woff2 +0 -0
- aethergraph/server/ui_static/assets/KaTeX_Main-BoldItalic-DzxPMmG6.ttf +0 -0
- aethergraph/server/ui_static/assets/KaTeX_Main-BoldItalic-SpSLRI95.woff +0 -0
- aethergraph/server/ui_static/assets/KaTeX_Main-Italic-3WenGoN9.ttf +0 -0
- aethergraph/server/ui_static/assets/KaTeX_Main-Italic-BMLOBm91.woff +0 -0
- aethergraph/server/ui_static/assets/KaTeX_Main-Italic-NWA7e6Wa.woff2 +0 -0
- aethergraph/server/ui_static/assets/KaTeX_Main-Regular-B22Nviop.woff2 +0 -0
- aethergraph/server/ui_static/assets/KaTeX_Main-Regular-Dr94JaBh.woff +0 -0
- aethergraph/server/ui_static/assets/KaTeX_Main-Regular-ypZvNtVU.ttf +0 -0
- aethergraph/server/ui_static/assets/KaTeX_Math-BoldItalic-B3XSjfu4.ttf +0 -0
- aethergraph/server/ui_static/assets/KaTeX_Math-BoldItalic-CZnvNsCZ.woff2 +0 -0
- aethergraph/server/ui_static/assets/KaTeX_Math-BoldItalic-iY-2wyZ7.woff +0 -0
- aethergraph/server/ui_static/assets/KaTeX_Math-Italic-DA0__PXp.woff +0 -0
- aethergraph/server/ui_static/assets/KaTeX_Math-Italic-flOr_0UB.ttf +0 -0
- aethergraph/server/ui_static/assets/KaTeX_Math-Italic-t53AETM-.woff2 +0 -0
- aethergraph/server/ui_static/assets/KaTeX_SansSerif-Bold-CFMepnvq.ttf +0 -0
- aethergraph/server/ui_static/assets/KaTeX_SansSerif-Bold-D1sUS0GD.woff2 +0 -0
- aethergraph/server/ui_static/assets/KaTeX_SansSerif-Bold-DbIhKOiC.woff +0 -0
- aethergraph/server/ui_static/assets/KaTeX_SansSerif-Italic-C3H0VqGB.woff2 +0 -0
- aethergraph/server/ui_static/assets/KaTeX_SansSerif-Italic-DN2j7dab.woff +0 -0
- aethergraph/server/ui_static/assets/KaTeX_SansSerif-Italic-YYjJ1zSn.ttf +0 -0
- aethergraph/server/ui_static/assets/KaTeX_SansSerif-Regular-BNo7hRIc.ttf +0 -0
- aethergraph/server/ui_static/assets/KaTeX_SansSerif-Regular-CS6fqUqJ.woff +0 -0
- aethergraph/server/ui_static/assets/KaTeX_SansSerif-Regular-DDBCnlJ7.woff2 +0 -0
- aethergraph/server/ui_static/assets/KaTeX_Script-Regular-C5JkGWo-.ttf +0 -0
- aethergraph/server/ui_static/assets/KaTeX_Script-Regular-D3wIWfF6.woff2 +0 -0
- aethergraph/server/ui_static/assets/KaTeX_Script-Regular-D5yQViql.woff +0 -0
- aethergraph/server/ui_static/assets/KaTeX_Size1-Regular-C195tn64.woff +0 -0
- aethergraph/server/ui_static/assets/KaTeX_Size1-Regular-Dbsnue_I.ttf +0 -0
- aethergraph/server/ui_static/assets/KaTeX_Size1-Regular-mCD8mA8B.woff2 +0 -0
- aethergraph/server/ui_static/assets/KaTeX_Size2-Regular-B7gKUWhC.ttf +0 -0
- aethergraph/server/ui_static/assets/KaTeX_Size2-Regular-Dy4dx90m.woff2 +0 -0
- aethergraph/server/ui_static/assets/KaTeX_Size2-Regular-oD1tc_U0.woff +0 -0
- aethergraph/server/ui_static/assets/KaTeX_Size3-Regular-CTq5MqoE.woff +0 -0
- aethergraph/server/ui_static/assets/KaTeX_Size3-Regular-DgpXs0kz.ttf +0 -0
- aethergraph/server/ui_static/assets/KaTeX_Size4-Regular-BF-4gkZK.woff +0 -0
- aethergraph/server/ui_static/assets/KaTeX_Size4-Regular-DWFBv043.ttf +0 -0
- aethergraph/server/ui_static/assets/KaTeX_Size4-Regular-Dl5lxZxV.woff2 +0 -0
- aethergraph/server/ui_static/assets/KaTeX_Typewriter-Regular-C0xS9mPB.woff +0 -0
- aethergraph/server/ui_static/assets/KaTeX_Typewriter-Regular-CO6r4hn1.woff2 +0 -0
- aethergraph/server/ui_static/assets/KaTeX_Typewriter-Regular-D3Ib7_Hf.ttf +0 -0
- aethergraph/server/ui_static/assets/index-BR5GtXcZ.css +1 -0
- aethergraph/server/ui_static/assets/index-CQ0HZZ83.js +400 -0
- aethergraph/server/ui_static/index.html +15 -0
- aethergraph/server/ui_static/logo.png +0 -0
- aethergraph/services/artifacts/__init__.py +0 -0
- aethergraph/services/artifacts/facade.py +1239 -132
- aethergraph/services/auth/{dev.py → authn.py} +0 -8
- aethergraph/services/auth/authz.py +100 -0
- aethergraph/services/channel/__init__.py +0 -0
- aethergraph/services/channel/channel_bus.py +19 -1
- aethergraph/services/channel/factory.py +13 -1
- aethergraph/services/channel/ingress.py +311 -0
- aethergraph/services/channel/queue_adapter.py +75 -0
- aethergraph/services/channel/session.py +502 -19
- aethergraph/services/container/default_container.py +122 -43
- aethergraph/services/continuations/continuation.py +6 -0
- aethergraph/services/continuations/stores/fs_store.py +19 -0
- aethergraph/services/eventhub/event_hub.py +76 -0
- aethergraph/services/kv/__init__.py +0 -0
- aethergraph/services/kv/ephemeral.py +244 -0
- aethergraph/services/llm/__init__.py +0 -0
- aethergraph/services/llm/generic_client copy.py +691 -0
- aethergraph/services/llm/generic_client.py +1288 -187
- aethergraph/services/llm/providers.py +3 -1
- aethergraph/services/llm/types.py +47 -0
- aethergraph/services/llm/utils.py +284 -0
- aethergraph/services/logger/std.py +3 -0
- aethergraph/services/mcp/__init__.py +9 -0
- aethergraph/services/mcp/http_client.py +38 -0
- aethergraph/services/mcp/service.py +225 -1
- aethergraph/services/mcp/stdio_client.py +41 -6
- aethergraph/services/mcp/ws_client.py +44 -2
- aethergraph/services/memory/__init__.py +0 -0
- aethergraph/services/memory/distillers/llm_long_term.py +234 -0
- aethergraph/services/memory/distillers/llm_meta_summary.py +398 -0
- aethergraph/services/memory/distillers/long_term.py +225 -0
- aethergraph/services/memory/facade/__init__.py +3 -0
- aethergraph/services/memory/facade/chat.py +440 -0
- aethergraph/services/memory/facade/core.py +447 -0
- aethergraph/services/memory/facade/distillation.py +424 -0
- aethergraph/services/memory/facade/rag.py +410 -0
- aethergraph/services/memory/facade/results.py +315 -0
- aethergraph/services/memory/facade/retrieval.py +139 -0
- aethergraph/services/memory/facade/types.py +77 -0
- aethergraph/services/memory/facade/utils.py +43 -0
- aethergraph/services/memory/facade_dep.py +1539 -0
- aethergraph/services/memory/factory.py +9 -3
- aethergraph/services/memory/utils.py +10 -0
- aethergraph/services/metering/eventlog_metering.py +470 -0
- aethergraph/services/metering/noop.py +25 -4
- aethergraph/services/rag/__init__.py +0 -0
- aethergraph/services/rag/facade.py +279 -23
- aethergraph/services/rag/index_factory.py +2 -2
- aethergraph/services/rag/node_rag.py +317 -0
- aethergraph/services/rate_limit/inmem_rate_limit.py +24 -0
- aethergraph/services/registry/__init__.py +0 -0
- aethergraph/services/registry/agent_app_meta.py +419 -0
- aethergraph/services/registry/registry_key.py +1 -1
- aethergraph/services/registry/unified_registry.py +74 -6
- aethergraph/services/scope/scope.py +159 -0
- aethergraph/services/scope/scope_factory.py +164 -0
- aethergraph/services/state_stores/serialize.py +5 -0
- aethergraph/services/state_stores/utils.py +2 -1
- aethergraph/services/viz/__init__.py +0 -0
- aethergraph/services/viz/facade.py +413 -0
- aethergraph/services/viz/viz_service.py +69 -0
- aethergraph/storage/artifacts/artifact_index_jsonl.py +180 -0
- aethergraph/storage/artifacts/artifact_index_sqlite.py +426 -0
- aethergraph/storage/artifacts/cas_store.py +422 -0
- aethergraph/storage/artifacts/fs_cas.py +18 -0
- aethergraph/storage/artifacts/s3_cas.py +14 -0
- aethergraph/storage/artifacts/utils.py +124 -0
- aethergraph/storage/blob/fs_blob.py +86 -0
- aethergraph/storage/blob/s3_blob.py +115 -0
- aethergraph/storage/continuation_store/fs_cont.py +283 -0
- aethergraph/storage/continuation_store/inmem_cont.py +146 -0
- aethergraph/storage/continuation_store/kvdoc_cont.py +261 -0
- aethergraph/storage/docstore/fs_doc.py +63 -0
- aethergraph/storage/docstore/sqlite_doc.py +31 -0
- aethergraph/storage/docstore/sqlite_doc_sync.py +90 -0
- aethergraph/storage/eventlog/fs_event.py +136 -0
- aethergraph/storage/eventlog/sqlite_event.py +47 -0
- aethergraph/storage/eventlog/sqlite_event_sync.py +178 -0
- aethergraph/storage/factory.py +432 -0
- aethergraph/storage/fs_utils.py +28 -0
- aethergraph/storage/graph_state_store/state_store.py +64 -0
- aethergraph/storage/kv/inmem_kv.py +103 -0
- aethergraph/storage/kv/layered_kv.py +52 -0
- aethergraph/storage/kv/sqlite_kv.py +39 -0
- aethergraph/storage/kv/sqlite_kv_sync.py +98 -0
- aethergraph/storage/memory/event_persist.py +68 -0
- aethergraph/storage/memory/fs_persist.py +118 -0
- aethergraph/{services/memory/hotlog_kv.py → storage/memory/hotlog.py} +8 -2
- aethergraph/{services → storage}/memory/indices.py +31 -7
- aethergraph/storage/metering/meter_event.py +55 -0
- aethergraph/storage/runs/doc_store.py +280 -0
- aethergraph/storage/runs/inmen_store.py +82 -0
- aethergraph/storage/runs/sqlite_run_store.py +403 -0
- aethergraph/storage/sessions/doc_store.py +183 -0
- aethergraph/storage/sessions/inmem_store.py +110 -0
- aethergraph/storage/sessions/sqlite_session_store.py +399 -0
- aethergraph/storage/vector_index/chroma_index.py +138 -0
- aethergraph/storage/vector_index/faiss_index.py +179 -0
- aethergraph/storage/vector_index/sqlite_index.py +187 -0
- {aethergraph-0.1.0a1.dist-info → aethergraph-0.1.0a3.dist-info}/METADATA +138 -31
- aethergraph-0.1.0a3.dist-info/RECORD +356 -0
- aethergraph-0.1.0a3.dist-info/entry_points.txt +3 -0
- aethergraph/services/artifacts/factory.py +0 -35
- aethergraph/services/artifacts/fs_store.py +0 -656
- aethergraph/services/artifacts/jsonl_index.py +0 -123
- aethergraph/services/artifacts/sqlite_index.py +0 -209
- aethergraph/services/memory/distillers/episode.py +0 -116
- aethergraph/services/memory/distillers/rolling.py +0 -74
- aethergraph/services/memory/facade.py +0 -633
- aethergraph/services/memory/persist_fs.py +0 -40
- aethergraph/services/rag/index/base.py +0 -27
- aethergraph/services/rag/index/faiss_index.py +0 -121
- aethergraph/services/rag/index/sqlite_index.py +0 -134
- aethergraph-0.1.0a1.dist-info/RECORD +0 -182
- aethergraph-0.1.0a1.dist-info/entry_points.txt +0 -2
- {aethergraph-0.1.0a1.dist-info → aethergraph-0.1.0a3.dist-info}/WHEEL +0 -0
- {aethergraph-0.1.0a1.dist-info → aethergraph-0.1.0a3.dist-info}/licenses/LICENSE +0 -0
- {aethergraph-0.1.0a1.dist-info → aethergraph-0.1.0a3.dist-info}/licenses/NOTICE +0 -0
- {aethergraph-0.1.0a1.dist-info → aethergraph-0.1.0a3.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
import threading
|
|
5
|
+
import time
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from aethergraph.contracts.storage.async_kv import AsyncKV
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass
|
|
12
|
+
class KVEntry:
|
|
13
|
+
value: Any
|
|
14
|
+
expire_at: float | None = None
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class InMemoryKV(AsyncKV):
|
|
18
|
+
"""
|
|
19
|
+
Simple in-memory KV.
|
|
20
|
+
|
|
21
|
+
- Process-local, not shared across processes.
|
|
22
|
+
- Thread-safe via RLock (sidecar + main thread can share safely).
|
|
23
|
+
- TTL managed best-effort on access / purge.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
def __init__(self, *, prefix: str = ""):
|
|
27
|
+
self._data: dict[str, Any] = {}
|
|
28
|
+
self._expires_at: dict[str, float | None] = {}
|
|
29
|
+
self._lock = threading.RLock()
|
|
30
|
+
self._prefix = prefix
|
|
31
|
+
|
|
32
|
+
async def get(self, key: str, default: Any = None) -> Any:
|
|
33
|
+
now = time.time()
|
|
34
|
+
with self._lock:
|
|
35
|
+
if key not in self._data:
|
|
36
|
+
return default
|
|
37
|
+
exp = self._expires_at.get(key)
|
|
38
|
+
if exp is not None and exp < now:
|
|
39
|
+
# expired
|
|
40
|
+
self._data.pop(key, None)
|
|
41
|
+
self._expires_at.pop(key, None)
|
|
42
|
+
return default
|
|
43
|
+
return self._data[key]
|
|
44
|
+
|
|
45
|
+
async def set(self, key: str, value: Any, *, ttl_s: int | None = None) -> None:
|
|
46
|
+
with self._lock:
|
|
47
|
+
self._data[key] = value
|
|
48
|
+
self._expires_at[key] = time.time() + ttl_s if ttl_s is not None else None
|
|
49
|
+
|
|
50
|
+
async def delete(self, key: str) -> None:
|
|
51
|
+
with self._lock:
|
|
52
|
+
self._data.pop(key, None)
|
|
53
|
+
self._expires_at.pop(key, None)
|
|
54
|
+
|
|
55
|
+
async def mget(self, keys: list[str]) -> list[Any]:
|
|
56
|
+
# reuse get() so TTL is respected
|
|
57
|
+
return [await self.get(k) for k in keys]
|
|
58
|
+
|
|
59
|
+
async def mset(self, kv: dict[str, Any], *, ttl_s: int | None = None) -> None:
|
|
60
|
+
for k, v in kv.items():
|
|
61
|
+
await self.set(k, v, ttl_s=ttl_s)
|
|
62
|
+
|
|
63
|
+
async def expire(self, key: str, ttl_s: int) -> None:
|
|
64
|
+
with self._lock:
|
|
65
|
+
if key in self._data:
|
|
66
|
+
self._expires_at[key] = time.time() + ttl_s
|
|
67
|
+
|
|
68
|
+
async def purge_expired(self, limit: int = 1000) -> int:
|
|
69
|
+
now = time.time()
|
|
70
|
+
removed = 0
|
|
71
|
+
with self._lock:
|
|
72
|
+
for k in list(self._data.keys()):
|
|
73
|
+
if removed >= limit:
|
|
74
|
+
break
|
|
75
|
+
exp = self._expires_at.get(k)
|
|
76
|
+
if exp is not None and exp < now:
|
|
77
|
+
self._data.pop(k, None)
|
|
78
|
+
self._expires_at.pop(k, None)
|
|
79
|
+
removed += 1
|
|
80
|
+
return removed
|
|
81
|
+
|
|
82
|
+
# Helper to prefix keys
|
|
83
|
+
def _k(self, k: str) -> str:
|
|
84
|
+
return f"{self._prefix}{k}" if self._prefix else k
|
|
85
|
+
|
|
86
|
+
async def list_append_unique(
|
|
87
|
+
self, key: str, items: list[dict], *, id_key: str = "id", ttl_s: int | None = None
|
|
88
|
+
) -> list[dict]:
|
|
89
|
+
"""Append items to a list at `key`, ensuring uniqueness based on `id_key`."""
|
|
90
|
+
k = self._k(key)
|
|
91
|
+
with self._lock:
|
|
92
|
+
cur = list(self._data.get(k, KVEntry([])).value or [])
|
|
93
|
+
seen = {x.get(id_key) for x in cur if isinstance(x, dict)}
|
|
94
|
+
cur.extend([x for x in items if isinstance(x, dict) and x.get(id_key) not in seen])
|
|
95
|
+
self._data[k] = KVEntry(value=cur, expire_at=(time.time() + ttl_s) if ttl_s else None)
|
|
96
|
+
return cur
|
|
97
|
+
|
|
98
|
+
async def list_pop_all(self, key: str) -> list:
|
|
99
|
+
"""Pop and return all items from the list at `key`."""
|
|
100
|
+
k = self._k(key)
|
|
101
|
+
with self._lock:
|
|
102
|
+
e = self._data.pop(k, None)
|
|
103
|
+
return list(e.value) if e and isinstance(e.value, list) else []
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# storage/kv/layered_kv.py
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from aethergraph.contracts.storage.async_kv import AsyncKV
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class LayeredKV(AsyncKV):
|
|
10
|
+
"""
|
|
11
|
+
Read-through / write-through KV:
|
|
12
|
+
|
|
13
|
+
- hot: typically InMemoryKV
|
|
14
|
+
- cold: persistent KV (SqliteKV, RedisKV, etc.)
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
def __init__(self, hot: AsyncKV, cold: AsyncKV):
|
|
18
|
+
self.hot = hot
|
|
19
|
+
self.cold = cold
|
|
20
|
+
|
|
21
|
+
async def get(self, key: str, default: Any = None) -> Any:
|
|
22
|
+
v = await self.hot.get(key, default=None)
|
|
23
|
+
if v is not None:
|
|
24
|
+
return v
|
|
25
|
+
v = await self.cold.get(key, default=default)
|
|
26
|
+
if v is not None:
|
|
27
|
+
await self.hot.set(key, v)
|
|
28
|
+
return v
|
|
29
|
+
|
|
30
|
+
async def set(self, key: str, value: Any, *, ttl_s: int | None = None) -> None:
|
|
31
|
+
await self.cold.set(key, value, ttl_s=ttl_s)
|
|
32
|
+
await self.hot.set(key, value, ttl_s=ttl_s)
|
|
33
|
+
|
|
34
|
+
async def delete(self, key: str) -> None:
|
|
35
|
+
await self.cold.delete(key)
|
|
36
|
+
await self.hot.delete(key)
|
|
37
|
+
|
|
38
|
+
async def mget(self, keys: list[str]) -> list[Any]:
|
|
39
|
+
return [await self.get(k) for k in keys]
|
|
40
|
+
|
|
41
|
+
async def mset(self, kv: dict[str, Any], *, ttl_s: int | None = None) -> None:
|
|
42
|
+
for k, v in kv.items():
|
|
43
|
+
await self.set(k, v, ttl_s=ttl_s)
|
|
44
|
+
|
|
45
|
+
async def expire(self, key: str, ttl_s: int) -> None:
|
|
46
|
+
await self.cold.expire(key, ttl_s)
|
|
47
|
+
await self.hot.expire(key, ttl_s)
|
|
48
|
+
|
|
49
|
+
async def purge_expired(self, limit: int = 1000) -> int:
|
|
50
|
+
n_cold = await self.cold.purge_expired(limit)
|
|
51
|
+
n_hot = await self.hot.purge_expired(limit)
|
|
52
|
+
return n_cold + n_hot
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from aethergraph.contracts.storage.async_kv import AsyncKV
|
|
7
|
+
|
|
8
|
+
from .sqlite_kv_sync import SQLiteKVSync
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class SqliteKV(AsyncKV):
|
|
12
|
+
"""
|
|
13
|
+
Async KV on top of SQLiteKVSync via asyncio.to_thread.
|
|
14
|
+
Safe across threads (RLock in sync core).
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
def __init__(self, path: str, *, prefix: str = ""):
|
|
18
|
+
self._sync = SQLiteKVSync(path, prefix=prefix)
|
|
19
|
+
|
|
20
|
+
async def get(self, key: str, default: Any = None) -> Any:
|
|
21
|
+
return await asyncio.to_thread(self._sync.get, key, default)
|
|
22
|
+
|
|
23
|
+
async def set(self, key: str, value: Any, *, ttl_s: int | None = None) -> None:
|
|
24
|
+
await asyncio.to_thread(self._sync.set, key, value, ttl_s)
|
|
25
|
+
|
|
26
|
+
async def delete(self, key: str) -> None:
|
|
27
|
+
await asyncio.to_thread(self._sync.delete, key)
|
|
28
|
+
|
|
29
|
+
async def mget(self, keys: list[str]) -> list[Any]:
|
|
30
|
+
return await asyncio.to_thread(self._sync.mget, keys)
|
|
31
|
+
|
|
32
|
+
async def mset(self, kv: dict[str, Any], *, ttl_s: int | None = None) -> None:
|
|
33
|
+
await asyncio.to_thread(self._sync.mset, kv, ttl_s)
|
|
34
|
+
|
|
35
|
+
async def expire(self, key: str, ttl_s: int) -> None:
|
|
36
|
+
await asyncio.to_thread(self._sync.expire, key, ttl_s)
|
|
37
|
+
|
|
38
|
+
async def purge_expired(self, limit: int = 1000) -> int:
|
|
39
|
+
return await asyncio.to_thread(self._sync.purge_expired, limit)
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import sqlite3
|
|
6
|
+
import threading
|
|
7
|
+
import time
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
"""
|
|
11
|
+
SQLite Key-Value Store with TTL (synchronous). Only used by async wrapper SQLiteKV.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class SQLiteKVSync:
|
|
16
|
+
"""
|
|
17
|
+
Durable KV with TTL (JSON values), thread-safe via RLock.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
def __init__(self, path: str, *, prefix: str = ""):
|
|
21
|
+
os.makedirs(os.path.dirname(path), exist_ok=True)
|
|
22
|
+
self._db = sqlite3.connect(path, check_same_thread=False, isolation_level=None)
|
|
23
|
+
self._db.execute("PRAGMA journal_mode=WAL;")
|
|
24
|
+
self._db.execute("PRAGMA synchronous=NORMAL;")
|
|
25
|
+
self._db.execute(
|
|
26
|
+
"""
|
|
27
|
+
CREATE TABLE IF NOT EXISTS kv (
|
|
28
|
+
k TEXT PRIMARY KEY,
|
|
29
|
+
v TEXT,
|
|
30
|
+
expire_at REAL
|
|
31
|
+
)
|
|
32
|
+
"""
|
|
33
|
+
)
|
|
34
|
+
self._db.execute("CREATE INDEX IF NOT EXISTS kv_exp_idx ON kv(expire_at);")
|
|
35
|
+
self._lock = threading.RLock()
|
|
36
|
+
self._prefix = prefix
|
|
37
|
+
|
|
38
|
+
def _k(self, k: str) -> str:
|
|
39
|
+
return f"{self._prefix}{k}" if self._prefix else k
|
|
40
|
+
|
|
41
|
+
def get(self, key: str, default: Any = None) -> Any:
|
|
42
|
+
k = self._k(key)
|
|
43
|
+
with self._lock:
|
|
44
|
+
row = self._db.execute("SELECT v, expire_at FROM kv WHERE k=?", (k,)).fetchone()
|
|
45
|
+
if not row:
|
|
46
|
+
return default
|
|
47
|
+
v_txt, exp = row
|
|
48
|
+
if exp and exp < time.time():
|
|
49
|
+
self._db.execute("DELETE FROM kv WHERE k=?", (k,))
|
|
50
|
+
return default
|
|
51
|
+
try:
|
|
52
|
+
return json.loads(v_txt)
|
|
53
|
+
except Exception:
|
|
54
|
+
return default
|
|
55
|
+
|
|
56
|
+
def set(self, key: str, value: Any, ttl_s: int | None = None) -> None:
|
|
57
|
+
k = self._k(key)
|
|
58
|
+
exp = time.time() + ttl_s if ttl_s is not None else None
|
|
59
|
+
v_txt = json.dumps(value, ensure_ascii=False)
|
|
60
|
+
with self._lock:
|
|
61
|
+
self._db.execute(
|
|
62
|
+
"""
|
|
63
|
+
INSERT INTO kv (k, v, expire_at) VALUES (?, ?, ?)
|
|
64
|
+
ON CONFLICT(k) DO UPDATE SET v=excluded.v, expire_at=excluded.expire_at
|
|
65
|
+
""",
|
|
66
|
+
(k, v_txt, exp),
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
def delete(self, key: str) -> None:
|
|
70
|
+
k = self._k(key)
|
|
71
|
+
with self._lock:
|
|
72
|
+
self._db.execute("DELETE FROM kv WHERE k=?", (k,))
|
|
73
|
+
|
|
74
|
+
def mget(self, keys: list[str]) -> list[Any]:
|
|
75
|
+
return [self.get(k) for k in keys]
|
|
76
|
+
|
|
77
|
+
def mset(self, kv: dict[str, Any], ttl_s: int | None = None) -> None:
|
|
78
|
+
for k, v in kv.items():
|
|
79
|
+
self.set(k, v, ttl_s=ttl_s)
|
|
80
|
+
|
|
81
|
+
def expire(self, key: str, ttl_s: int) -> None:
|
|
82
|
+
k = self._k(key)
|
|
83
|
+
exp = time.time() + ttl_s
|
|
84
|
+
with self._lock:
|
|
85
|
+
self._db.execute("UPDATE kv SET expire_at=? WHERE k=?", (exp, k))
|
|
86
|
+
|
|
87
|
+
def purge_expired(self, limit: int = 1000) -> int:
|
|
88
|
+
now = time.time()
|
|
89
|
+
with self._lock:
|
|
90
|
+
rows = self._db.execute(
|
|
91
|
+
"SELECT k FROM kv WHERE expire_at IS NOT NULL AND expire_at < ? LIMIT ?",
|
|
92
|
+
(now, limit),
|
|
93
|
+
).fetchall()
|
|
94
|
+
keys = [r[0] for r in rows]
|
|
95
|
+
if not keys:
|
|
96
|
+
return 0
|
|
97
|
+
self._db.executemany("DELETE FROM kv WHERE k=?", [(k,) for k in keys])
|
|
98
|
+
return len(keys)
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import asdict
|
|
4
|
+
import hashlib
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from aethergraph.contracts.services.memory import Event, Persistence
|
|
8
|
+
from aethergraph.contracts.storage.doc_store import DocStore
|
|
9
|
+
from aethergraph.contracts.storage.event_log import EventLog
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class EventLogPersistence(Persistence):
|
|
13
|
+
"""
|
|
14
|
+
Persistence built on top of generic EventLog + DocStore.
|
|
15
|
+
|
|
16
|
+
- append_event: logs Event rows into EventLog with scope_id=run_id, kind="memory".
|
|
17
|
+
- save_json / load_json: store arbitrary JSON in DocStore using memdoc:// URIs.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
def __init__(
|
|
21
|
+
self,
|
|
22
|
+
*,
|
|
23
|
+
log: EventLog,
|
|
24
|
+
docs: DocStore,
|
|
25
|
+
uri_prefix: str = "memdoc://",
|
|
26
|
+
):
|
|
27
|
+
self._log = log
|
|
28
|
+
self._docs = docs
|
|
29
|
+
self._prefix = uri_prefix
|
|
30
|
+
|
|
31
|
+
# --------- helpers ---------
|
|
32
|
+
def _doc_id_from_uri(self, uri: str) -> str:
|
|
33
|
+
"""
|
|
34
|
+
Accepts:
|
|
35
|
+
- memdoc://<id> -> <id>
|
|
36
|
+
- anything-else -> hashed to a stable doc_id.
|
|
37
|
+
"""
|
|
38
|
+
if uri.startswith(self._prefix):
|
|
39
|
+
return uri[len(self._prefix) :]
|
|
40
|
+
# fallback: hash to avoid weird chars
|
|
41
|
+
h = hashlib.sha1(uri.encode("utf-8")).hexdigest()
|
|
42
|
+
return f"memdoc/{h}"
|
|
43
|
+
|
|
44
|
+
def _uri_from_doc_id(self, doc_id: str) -> str:
|
|
45
|
+
if doc_id.startswith("memdoc://"):
|
|
46
|
+
return doc_id
|
|
47
|
+
return f"{self._prefix}{doc_id}"
|
|
48
|
+
|
|
49
|
+
# --------- API ---------
|
|
50
|
+
async def append_event(self, run_id: str, evt: Event) -> None:
|
|
51
|
+
payload = asdict(evt)
|
|
52
|
+
payload.setdefault("scope_id", run_id)
|
|
53
|
+
payload.setdefault("kind", "memory")
|
|
54
|
+
# you can add tags like ["mem"] if useful
|
|
55
|
+
await self._log.append(payload)
|
|
56
|
+
|
|
57
|
+
async def save_json(self, uri: str, obj: dict[str, Any]) -> str:
|
|
58
|
+
doc_id = self._doc_id_from_uri(uri)
|
|
59
|
+
# Let DocStore own where/how it writes
|
|
60
|
+
await self._docs.put(doc_id, obj)
|
|
61
|
+
return self._uri_from_doc_id(doc_id)
|
|
62
|
+
|
|
63
|
+
async def load_json(self, uri: str) -> dict[str, Any]:
|
|
64
|
+
doc_id = self._doc_id_from_uri(uri)
|
|
65
|
+
doc = await self._docs.get(doc_id)
|
|
66
|
+
if doc is None:
|
|
67
|
+
raise FileNotFoundError(f"Memory JSON not found for URI: {uri}")
|
|
68
|
+
return doc
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
from dataclasses import asdict
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
import threading
|
|
9
|
+
import time
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from aethergraph.contracts.services.memory import Event, Persistence
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class FSPersistence(Persistence):
|
|
16
|
+
"""
|
|
17
|
+
File-system based persistence for memory events + JSON blobs.
|
|
18
|
+
|
|
19
|
+
- Events are written to:
|
|
20
|
+
<base_dir>/mem/<run_id>/events/YYYY-MM-DD.jsonl
|
|
21
|
+
|
|
22
|
+
- JSON docs are read/written via file:// URIs:
|
|
23
|
+
file://relative/path.json -> <base_dir>/relative/path.json
|
|
24
|
+
file:///abs/path.json -> /abs/path.json (not under base_dir)
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
def __init__(self, *, base_dir: str):
|
|
28
|
+
self.base_dir = Path(base_dir).resolve()
|
|
29
|
+
self._lock = threading.RLock()
|
|
30
|
+
|
|
31
|
+
# ---------- Event log (append-only JSONL) ----------
|
|
32
|
+
|
|
33
|
+
async def append_event(self, run_id: str, evt: Event) -> None:
|
|
34
|
+
day = time.strftime("%Y-%m-%d", time.gmtime())
|
|
35
|
+
path = self.base_dir / "mem" / run_id / "events" / f"{day}.jsonl"
|
|
36
|
+
|
|
37
|
+
def _write() -> None:
|
|
38
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
39
|
+
raw = asdict(evt)
|
|
40
|
+
# Drop None values but keep [] / {} / 0.
|
|
41
|
+
data = {k: v for k, v in raw.items() if v is not None}
|
|
42
|
+
line = json.dumps(data, ensure_ascii=False) + "\n"
|
|
43
|
+
with self._lock, path.open("a", encoding="utf-8") as f:
|
|
44
|
+
f.write(line)
|
|
45
|
+
|
|
46
|
+
await asyncio.to_thread(_write)
|
|
47
|
+
|
|
48
|
+
# ---------- JSON blob helpers (file:// URIs) ----------
|
|
49
|
+
|
|
50
|
+
def _uri_to_path(self, uri: str) -> Path:
|
|
51
|
+
"""
|
|
52
|
+
Convert a file:// URI into a local Path, resolving *relative* paths
|
|
53
|
+
against self.base_dir. Works cross-platform.
|
|
54
|
+
"""
|
|
55
|
+
if not uri.startswith("file://"):
|
|
56
|
+
raise ValueError(f"FSPersistence only supports file:// URIs, got {uri!r}")
|
|
57
|
+
|
|
58
|
+
raw = uri[len("file://") :]
|
|
59
|
+
|
|
60
|
+
# Windows: normalize file:///C:/... -> C:/...
|
|
61
|
+
if (
|
|
62
|
+
os.name == "nt"
|
|
63
|
+
and raw.startswith("/")
|
|
64
|
+
and len(raw) > 2
|
|
65
|
+
and raw[1].isalpha()
|
|
66
|
+
and raw[2] == ":"
|
|
67
|
+
):
|
|
68
|
+
raw = raw[1:]
|
|
69
|
+
|
|
70
|
+
p = Path(raw)
|
|
71
|
+
|
|
72
|
+
# Relative paths are resolved under base_dir
|
|
73
|
+
if not p.is_absolute():
|
|
74
|
+
p = self.base_dir / p
|
|
75
|
+
|
|
76
|
+
return p
|
|
77
|
+
|
|
78
|
+
def _path_to_uri(self, path: Path) -> str:
|
|
79
|
+
"""
|
|
80
|
+
Convert a local Path to canonical file:// URI with forward slashes.
|
|
81
|
+
"""
|
|
82
|
+
p = path.resolve()
|
|
83
|
+
s = p.as_posix()
|
|
84
|
+
|
|
85
|
+
# Ensure absolute paths appear as file:///... (add leading slash on Windows)
|
|
86
|
+
if p.is_absolute() and not s.startswith("/"):
|
|
87
|
+
s = "/" + s
|
|
88
|
+
|
|
89
|
+
return f"file://{s}"
|
|
90
|
+
|
|
91
|
+
async def save_json(self, uri: str, obj: dict[str, Any]) -> str:
|
|
92
|
+
"""
|
|
93
|
+
Save JSON to the location specified by a file:// URI.
|
|
94
|
+
Returns the canonical file:// URI of the saved file.
|
|
95
|
+
"""
|
|
96
|
+
path = self._uri_to_path(uri)
|
|
97
|
+
|
|
98
|
+
def _write() -> None:
|
|
99
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
100
|
+
tmp = path.with_suffix(path.suffix + ".tmp")
|
|
101
|
+
with self._lock, tmp.open("w", encoding="utf-8") as f:
|
|
102
|
+
json.dump(obj, f, ensure_ascii=False, indent=2)
|
|
103
|
+
os.replace(tmp, path)
|
|
104
|
+
|
|
105
|
+
await asyncio.to_thread(_write)
|
|
106
|
+
return self._path_to_uri(path)
|
|
107
|
+
|
|
108
|
+
async def load_json(self, uri: str) -> dict[str, Any]:
|
|
109
|
+
"""
|
|
110
|
+
Inverse of save_json: load JSON from a file:// URI.
|
|
111
|
+
"""
|
|
112
|
+
path = self._uri_to_path(uri)
|
|
113
|
+
|
|
114
|
+
def _read() -> dict[str, Any]:
|
|
115
|
+
with self._lock, path.open("r", encoding="utf-8") as f:
|
|
116
|
+
return json.load(f)
|
|
117
|
+
|
|
118
|
+
return await asyncio.to_thread(_read)
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
from aethergraph.contracts.services.kv import AsyncKV
|
|
2
2
|
from aethergraph.contracts.services.memory import Event, HotLog
|
|
3
3
|
|
|
4
|
+
# No specific backend is required; we use AsyncKV for storage.
|
|
5
|
+
|
|
4
6
|
|
|
5
7
|
def kv_hot_key(run_id: str) -> str:
|
|
6
8
|
return f"mem:{run_id}:hot"
|
|
@@ -13,13 +15,17 @@ class KVHotLog(HotLog):
|
|
|
13
15
|
async def append(self, run_id: str, evt: Event, *, ttl_s: int, limit: int) -> None:
|
|
14
16
|
key = kv_hot_key(run_id)
|
|
15
17
|
buf = list((await self.kv.get(key, default=[])) or [])
|
|
16
|
-
buf.append(evt.__dict__) # store as dict for JSON
|
|
18
|
+
buf.append(evt.__dict__) # store as dict for JSON-ability
|
|
17
19
|
if len(buf) > limit:
|
|
18
20
|
buf = buf[-limit:]
|
|
19
21
|
await self.kv.set(key, buf, ttl_s=ttl_s)
|
|
20
22
|
|
|
21
23
|
async def recent(
|
|
22
|
-
self,
|
|
24
|
+
self,
|
|
25
|
+
run_id: str,
|
|
26
|
+
*,
|
|
27
|
+
kinds: list[str] | None = None,
|
|
28
|
+
limit: int = 50,
|
|
23
29
|
) -> list[Event]:
|
|
24
30
|
buf = (await self.kv.get(kv_hot_key(run_id), default=[])) or []
|
|
25
31
|
if kinds:
|
|
@@ -1,19 +1,22 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
1
3
|
from typing import Any
|
|
2
4
|
|
|
3
|
-
from aethergraph.contracts.services.kv import AsyncKV
|
|
4
5
|
from aethergraph.contracts.services.memory import Event, Indices
|
|
6
|
+
from aethergraph.contracts.storage.async_kv import AsyncKV
|
|
5
7
|
|
|
6
8
|
|
|
7
9
|
def idx_by_ref_kind(run_id: str) -> str:
|
|
8
|
-
return f"mem:{run_id}:
|
|
10
|
+
return f"mem:{run_id}:idx:ref_kind"
|
|
9
11
|
|
|
10
12
|
|
|
11
13
|
def idx_by_name(run_id: str) -> str:
|
|
12
|
-
return f"mem:{run_id}:
|
|
14
|
+
return f"mem:{run_id}:idx:name"
|
|
13
15
|
|
|
14
16
|
|
|
15
17
|
def idx_by_topic(run_id: str) -> str:
|
|
16
|
-
|
|
18
|
+
# topic = tool / agent / flow name
|
|
19
|
+
return f"mem:{run_id}:idx:topic"
|
|
17
20
|
|
|
18
21
|
|
|
19
22
|
class KVIndices(Indices):
|
|
@@ -29,25 +32,39 @@ class KVIndices(Indices):
|
|
|
29
32
|
by_name = (await self.kv.get(idx_by_name(run_id), {})) or {}
|
|
30
33
|
by_topic = (await self.kv.get(idx_by_topic(run_id), {})) or {}
|
|
31
34
|
|
|
35
|
+
# 1) Index by output name & ref.kind
|
|
32
36
|
for v in outs:
|
|
33
37
|
nm = v.get("name")
|
|
34
38
|
if not nm:
|
|
35
39
|
continue
|
|
40
|
+
|
|
41
|
+
# name index
|
|
36
42
|
by_name[nm] = {
|
|
37
43
|
"ts": ts,
|
|
38
44
|
"event_id": eid,
|
|
39
45
|
"vtype": v.get("vtype"),
|
|
40
46
|
"value": v.get("value"),
|
|
41
47
|
}
|
|
48
|
+
|
|
49
|
+
# ref.kind index
|
|
42
50
|
if v.get("vtype") == "ref" and isinstance(v.get("value"), dict):
|
|
43
51
|
kind = v["value"].get("kind")
|
|
44
52
|
uri = v["value"].get("uri")
|
|
45
53
|
if kind and uri:
|
|
46
54
|
lst = by_kind.setdefault(kind, [])
|
|
47
|
-
lst.append(
|
|
55
|
+
lst.append(
|
|
56
|
+
{
|
|
57
|
+
"ts": ts,
|
|
58
|
+
"event_id": eid,
|
|
59
|
+
"name": nm,
|
|
60
|
+
"uri": uri,
|
|
61
|
+
"topic": tool,
|
|
62
|
+
}
|
|
63
|
+
)
|
|
48
64
|
if len(lst) > 200:
|
|
49
65
|
del lst[:-200]
|
|
50
66
|
|
|
67
|
+
# 2) Index by topic (tool / flow / agent)
|
|
51
68
|
if tool:
|
|
52
69
|
last = by_topic.get(tool, {}) or {}
|
|
53
70
|
last["ts"] = ts
|
|
@@ -59,15 +76,22 @@ class KVIndices(Indices):
|
|
|
59
76
|
await self.kv.set(idx_by_name(run_id), by_name, ttl_s=self.ttl)
|
|
60
77
|
await self.kv.set(idx_by_topic(run_id), by_topic, ttl_s=self.ttl)
|
|
61
78
|
|
|
79
|
+
# ---------- queries ----------
|
|
62
80
|
async def last_by_name(self, run_id: str, name: str) -> dict[str, Any] | None:
|
|
63
81
|
by_name = await self.kv.get(idx_by_name(run_id), {}) or {}
|
|
64
82
|
return by_name.get(name)
|
|
65
83
|
|
|
66
84
|
async def latest_refs_by_kind(
|
|
67
|
-
self,
|
|
85
|
+
self,
|
|
86
|
+
run_id: str,
|
|
87
|
+
kind: str,
|
|
88
|
+
*,
|
|
89
|
+
limit: int = 50,
|
|
68
90
|
) -> list[dict[str, Any]]:
|
|
69
91
|
by_kind = await self.kv.get(idx_by_ref_kind(run_id), {}) or {}
|
|
70
|
-
|
|
92
|
+
arr = (by_kind.get(kind) or [])[-limit:]
|
|
93
|
+
# latest first
|
|
94
|
+
return list(reversed(arr))
|
|
71
95
|
|
|
72
96
|
async def last_outputs_by_topic(self, run_id: str, topic: str) -> dict[str, Any] | None:
|
|
73
97
|
by_topic = await self.kv.get(idx_by_topic(run_id), {}) or {}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
from datetime import datetime
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from aethergraph.contracts.services.metering import MeteringStore
|
|
6
|
+
from aethergraph.contracts.storage.event_log import EventLog
|
|
7
|
+
|
|
8
|
+
METER_TAG = "meter" # shared tag for all metering events
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass
|
|
12
|
+
class EventLogMeteringStore(MeteringStore):
|
|
13
|
+
"""
|
|
14
|
+
MeteringStore backed by a generic EventLog.
|
|
15
|
+
|
|
16
|
+
Convention:
|
|
17
|
+
- kind: e.g. "meter.llm", "meter.run", "meter.artifact", "meter.event"
|
|
18
|
+
- tags: always includes "meter" so queries don't mix with other app events
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
event_log: EventLog
|
|
22
|
+
|
|
23
|
+
async def append(self, event: dict[str, Any]) -> None:
|
|
24
|
+
# Enforce metering conventions
|
|
25
|
+
kind = event.get("kind")
|
|
26
|
+
if not kind or not kind.startswith("meter."):
|
|
27
|
+
raise ValueError(f"Metering event kind must start with 'meter.': {kind!r}")
|
|
28
|
+
|
|
29
|
+
tags = set(event.get("tags") or [])
|
|
30
|
+
tags.add(METER_TAG)
|
|
31
|
+
event["tags"] = list(tags)
|
|
32
|
+
|
|
33
|
+
await self.event_log.append(event)
|
|
34
|
+
|
|
35
|
+
async def query(
|
|
36
|
+
self,
|
|
37
|
+
*,
|
|
38
|
+
since: datetime | None = None,
|
|
39
|
+
until: datetime | None = None,
|
|
40
|
+
kinds: list[str] | None = None,
|
|
41
|
+
limit: int | None = None,
|
|
42
|
+
user_id: str | None = None,
|
|
43
|
+
org_id: str | None = None,
|
|
44
|
+
) -> list[dict[str, Any]]:
|
|
45
|
+
# Always filter by meter tag
|
|
46
|
+
return await self.event_log.query(
|
|
47
|
+
scope_id=None,
|
|
48
|
+
since=since,
|
|
49
|
+
until=until,
|
|
50
|
+
kinds=kinds,
|
|
51
|
+
tags=[METER_TAG],
|
|
52
|
+
limit=limit,
|
|
53
|
+
user_id=user_id,
|
|
54
|
+
org_id=org_id,
|
|
55
|
+
)
|