aethergraph 0.1.0a1__py3-none-any.whl → 0.1.0a2__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.
Files changed (267) hide show
  1. aethergraph/__init__.py +4 -10
  2. aethergraph/__main__.py +293 -0
  3. aethergraph/api/v1/__init__.py +0 -0
  4. aethergraph/api/v1/agents.py +46 -0
  5. aethergraph/api/v1/apps.py +70 -0
  6. aethergraph/api/v1/artifacts.py +415 -0
  7. aethergraph/api/v1/channels.py +89 -0
  8. aethergraph/api/v1/deps.py +168 -0
  9. aethergraph/api/v1/graphs.py +259 -0
  10. aethergraph/api/v1/identity.py +25 -0
  11. aethergraph/api/v1/memory.py +353 -0
  12. aethergraph/api/v1/misc.py +47 -0
  13. aethergraph/api/v1/pagination.py +29 -0
  14. aethergraph/api/v1/runs.py +568 -0
  15. aethergraph/api/v1/schemas.py +535 -0
  16. aethergraph/api/v1/session.py +323 -0
  17. aethergraph/api/v1/stats.py +201 -0
  18. aethergraph/api/v1/viz.py +152 -0
  19. aethergraph/config/config.py +22 -0
  20. aethergraph/config/loader.py +3 -2
  21. aethergraph/config/storage.py +209 -0
  22. aethergraph/contracts/__init__.py +0 -0
  23. aethergraph/contracts/services/__init__.py +0 -0
  24. aethergraph/contracts/services/artifacts.py +27 -14
  25. aethergraph/contracts/services/memory.py +45 -17
  26. aethergraph/contracts/services/metering.py +129 -0
  27. aethergraph/contracts/services/runs.py +50 -0
  28. aethergraph/contracts/services/sessions.py +87 -0
  29. aethergraph/contracts/services/state_stores.py +3 -0
  30. aethergraph/contracts/services/viz.py +44 -0
  31. aethergraph/contracts/storage/artifact_index.py +88 -0
  32. aethergraph/contracts/storage/artifact_store.py +99 -0
  33. aethergraph/contracts/storage/async_kv.py +34 -0
  34. aethergraph/contracts/storage/blob_store.py +50 -0
  35. aethergraph/contracts/storage/doc_store.py +35 -0
  36. aethergraph/contracts/storage/event_log.py +31 -0
  37. aethergraph/contracts/storage/vector_index.py +48 -0
  38. aethergraph/core/__init__.py +0 -0
  39. aethergraph/core/execution/forward_scheduler.py +13 -2
  40. aethergraph/core/execution/global_scheduler.py +21 -15
  41. aethergraph/core/execution/step_forward.py +10 -1
  42. aethergraph/core/graph/__init__.py +0 -0
  43. aethergraph/core/graph/graph_builder.py +8 -4
  44. aethergraph/core/graph/graph_fn.py +156 -15
  45. aethergraph/core/graph/graph_spec.py +8 -0
  46. aethergraph/core/graph/graphify.py +146 -27
  47. aethergraph/core/graph/node_spec.py +0 -2
  48. aethergraph/core/graph/node_state.py +3 -0
  49. aethergraph/core/graph/task_graph.py +39 -1
  50. aethergraph/core/runtime/__init__.py +0 -0
  51. aethergraph/core/runtime/ad_hoc_context.py +64 -4
  52. aethergraph/core/runtime/base_service.py +28 -4
  53. aethergraph/core/runtime/execution_context.py +13 -15
  54. aethergraph/core/runtime/graph_runner.py +222 -37
  55. aethergraph/core/runtime/node_context.py +510 -6
  56. aethergraph/core/runtime/node_services.py +12 -5
  57. aethergraph/core/runtime/recovery.py +15 -1
  58. aethergraph/core/runtime/run_manager.py +783 -0
  59. aethergraph/core/runtime/run_manager_local.py +204 -0
  60. aethergraph/core/runtime/run_registration.py +2 -2
  61. aethergraph/core/runtime/run_types.py +89 -0
  62. aethergraph/core/runtime/runtime_env.py +136 -7
  63. aethergraph/core/runtime/runtime_metering.py +71 -0
  64. aethergraph/core/runtime/runtime_registry.py +36 -13
  65. aethergraph/core/runtime/runtime_services.py +194 -6
  66. aethergraph/core/tools/builtins/toolset.py +1 -1
  67. aethergraph/core/tools/toolkit.py +5 -0
  68. aethergraph/plugins/agents/default_chat_agent copy.py +90 -0
  69. aethergraph/plugins/agents/default_chat_agent.py +171 -0
  70. aethergraph/plugins/agents/shared.py +81 -0
  71. aethergraph/plugins/channel/adapters/webui.py +112 -112
  72. aethergraph/plugins/channel/routes/webui_routes.py +367 -102
  73. aethergraph/plugins/channel/utils/slack_utils.py +115 -59
  74. aethergraph/plugins/channel/utils/telegram_utils.py +88 -47
  75. aethergraph/plugins/channel/websockets/weibui_ws.py +172 -0
  76. aethergraph/runtime/__init__.py +15 -0
  77. aethergraph/server/app_factory.py +190 -34
  78. aethergraph/server/clients/channel_client.py +202 -0
  79. aethergraph/server/http/channel_http_routes.py +116 -0
  80. aethergraph/server/http/channel_ws_routers.py +45 -0
  81. aethergraph/server/loading.py +117 -0
  82. aethergraph/server/server.py +131 -0
  83. aethergraph/server/server_state.py +240 -0
  84. aethergraph/server/start.py +227 -66
  85. aethergraph/server/ui_static/assets/KaTeX_AMS-Regular-BQhdFMY1.woff2 +0 -0
  86. aethergraph/server/ui_static/assets/KaTeX_AMS-Regular-DMm9YOAa.woff +0 -0
  87. aethergraph/server/ui_static/assets/KaTeX_AMS-Regular-DRggAlZN.ttf +0 -0
  88. aethergraph/server/ui_static/assets/KaTeX_Caligraphic-Bold-ATXxdsX0.ttf +0 -0
  89. aethergraph/server/ui_static/assets/KaTeX_Caligraphic-Bold-BEiXGLvX.woff +0 -0
  90. aethergraph/server/ui_static/assets/KaTeX_Caligraphic-Bold-Dq_IR9rO.woff2 +0 -0
  91. aethergraph/server/ui_static/assets/KaTeX_Caligraphic-Regular-CTRA-rTL.woff +0 -0
  92. aethergraph/server/ui_static/assets/KaTeX_Caligraphic-Regular-Di6jR-x-.woff2 +0 -0
  93. aethergraph/server/ui_static/assets/KaTeX_Caligraphic-Regular-wX97UBjC.ttf +0 -0
  94. aethergraph/server/ui_static/assets/KaTeX_Fraktur-Bold-BdnERNNW.ttf +0 -0
  95. aethergraph/server/ui_static/assets/KaTeX_Fraktur-Bold-BsDP51OF.woff +0 -0
  96. aethergraph/server/ui_static/assets/KaTeX_Fraktur-Bold-CL6g_b3V.woff2 +0 -0
  97. aethergraph/server/ui_static/assets/KaTeX_Fraktur-Regular-CB_wures.ttf +0 -0
  98. aethergraph/server/ui_static/assets/KaTeX_Fraktur-Regular-CTYiF6lA.woff2 +0 -0
  99. aethergraph/server/ui_static/assets/KaTeX_Fraktur-Regular-Dxdc4cR9.woff +0 -0
  100. aethergraph/server/ui_static/assets/KaTeX_Main-Bold-Cx986IdX.woff2 +0 -0
  101. aethergraph/server/ui_static/assets/KaTeX_Main-Bold-Jm3AIy58.woff +0 -0
  102. aethergraph/server/ui_static/assets/KaTeX_Main-Bold-waoOVXN0.ttf +0 -0
  103. aethergraph/server/ui_static/assets/KaTeX_Main-BoldItalic-DxDJ3AOS.woff2 +0 -0
  104. aethergraph/server/ui_static/assets/KaTeX_Main-BoldItalic-DzxPMmG6.ttf +0 -0
  105. aethergraph/server/ui_static/assets/KaTeX_Main-BoldItalic-SpSLRI95.woff +0 -0
  106. aethergraph/server/ui_static/assets/KaTeX_Main-Italic-3WenGoN9.ttf +0 -0
  107. aethergraph/server/ui_static/assets/KaTeX_Main-Italic-BMLOBm91.woff +0 -0
  108. aethergraph/server/ui_static/assets/KaTeX_Main-Italic-NWA7e6Wa.woff2 +0 -0
  109. aethergraph/server/ui_static/assets/KaTeX_Main-Regular-B22Nviop.woff2 +0 -0
  110. aethergraph/server/ui_static/assets/KaTeX_Main-Regular-Dr94JaBh.woff +0 -0
  111. aethergraph/server/ui_static/assets/KaTeX_Main-Regular-ypZvNtVU.ttf +0 -0
  112. aethergraph/server/ui_static/assets/KaTeX_Math-BoldItalic-B3XSjfu4.ttf +0 -0
  113. aethergraph/server/ui_static/assets/KaTeX_Math-BoldItalic-CZnvNsCZ.woff2 +0 -0
  114. aethergraph/server/ui_static/assets/KaTeX_Math-BoldItalic-iY-2wyZ7.woff +0 -0
  115. aethergraph/server/ui_static/assets/KaTeX_Math-Italic-DA0__PXp.woff +0 -0
  116. aethergraph/server/ui_static/assets/KaTeX_Math-Italic-flOr_0UB.ttf +0 -0
  117. aethergraph/server/ui_static/assets/KaTeX_Math-Italic-t53AETM-.woff2 +0 -0
  118. aethergraph/server/ui_static/assets/KaTeX_SansSerif-Bold-CFMepnvq.ttf +0 -0
  119. aethergraph/server/ui_static/assets/KaTeX_SansSerif-Bold-D1sUS0GD.woff2 +0 -0
  120. aethergraph/server/ui_static/assets/KaTeX_SansSerif-Bold-DbIhKOiC.woff +0 -0
  121. aethergraph/server/ui_static/assets/KaTeX_SansSerif-Italic-C3H0VqGB.woff2 +0 -0
  122. aethergraph/server/ui_static/assets/KaTeX_SansSerif-Italic-DN2j7dab.woff +0 -0
  123. aethergraph/server/ui_static/assets/KaTeX_SansSerif-Italic-YYjJ1zSn.ttf +0 -0
  124. aethergraph/server/ui_static/assets/KaTeX_SansSerif-Regular-BNo7hRIc.ttf +0 -0
  125. aethergraph/server/ui_static/assets/KaTeX_SansSerif-Regular-CS6fqUqJ.woff +0 -0
  126. aethergraph/server/ui_static/assets/KaTeX_SansSerif-Regular-DDBCnlJ7.woff2 +0 -0
  127. aethergraph/server/ui_static/assets/KaTeX_Script-Regular-C5JkGWo-.ttf +0 -0
  128. aethergraph/server/ui_static/assets/KaTeX_Script-Regular-D3wIWfF6.woff2 +0 -0
  129. aethergraph/server/ui_static/assets/KaTeX_Script-Regular-D5yQViql.woff +0 -0
  130. aethergraph/server/ui_static/assets/KaTeX_Size1-Regular-C195tn64.woff +0 -0
  131. aethergraph/server/ui_static/assets/KaTeX_Size1-Regular-Dbsnue_I.ttf +0 -0
  132. aethergraph/server/ui_static/assets/KaTeX_Size1-Regular-mCD8mA8B.woff2 +0 -0
  133. aethergraph/server/ui_static/assets/KaTeX_Size2-Regular-B7gKUWhC.ttf +0 -0
  134. aethergraph/server/ui_static/assets/KaTeX_Size2-Regular-Dy4dx90m.woff2 +0 -0
  135. aethergraph/server/ui_static/assets/KaTeX_Size2-Regular-oD1tc_U0.woff +0 -0
  136. aethergraph/server/ui_static/assets/KaTeX_Size3-Regular-CTq5MqoE.woff +0 -0
  137. aethergraph/server/ui_static/assets/KaTeX_Size3-Regular-DgpXs0kz.ttf +0 -0
  138. aethergraph/server/ui_static/assets/KaTeX_Size4-Regular-BF-4gkZK.woff +0 -0
  139. aethergraph/server/ui_static/assets/KaTeX_Size4-Regular-DWFBv043.ttf +0 -0
  140. aethergraph/server/ui_static/assets/KaTeX_Size4-Regular-Dl5lxZxV.woff2 +0 -0
  141. aethergraph/server/ui_static/assets/KaTeX_Typewriter-Regular-C0xS9mPB.woff +0 -0
  142. aethergraph/server/ui_static/assets/KaTeX_Typewriter-Regular-CO6r4hn1.woff2 +0 -0
  143. aethergraph/server/ui_static/assets/KaTeX_Typewriter-Regular-D3Ib7_Hf.ttf +0 -0
  144. aethergraph/server/ui_static/assets/index-BR5GtXcZ.css +1 -0
  145. aethergraph/server/ui_static/assets/index-CQ0HZZ83.js +400 -0
  146. aethergraph/server/ui_static/index.html +15 -0
  147. aethergraph/server/ui_static/logo.png +0 -0
  148. aethergraph/services/artifacts/__init__.py +0 -0
  149. aethergraph/services/artifacts/facade.py +1239 -132
  150. aethergraph/services/auth/{dev.py → authn.py} +0 -8
  151. aethergraph/services/auth/authz.py +100 -0
  152. aethergraph/services/channel/__init__.py +0 -0
  153. aethergraph/services/channel/channel_bus.py +19 -1
  154. aethergraph/services/channel/factory.py +13 -1
  155. aethergraph/services/channel/ingress.py +311 -0
  156. aethergraph/services/channel/queue_adapter.py +75 -0
  157. aethergraph/services/channel/session.py +502 -19
  158. aethergraph/services/container/default_container.py +122 -43
  159. aethergraph/services/continuations/continuation.py +6 -0
  160. aethergraph/services/continuations/stores/fs_store.py +19 -0
  161. aethergraph/services/eventhub/event_hub.py +76 -0
  162. aethergraph/services/kv/__init__.py +0 -0
  163. aethergraph/services/kv/ephemeral.py +244 -0
  164. aethergraph/services/llm/__init__.py +0 -0
  165. aethergraph/services/llm/generic_client copy.py +691 -0
  166. aethergraph/services/llm/generic_client.py +1288 -187
  167. aethergraph/services/llm/providers.py +3 -1
  168. aethergraph/services/llm/types.py +47 -0
  169. aethergraph/services/llm/utils.py +284 -0
  170. aethergraph/services/logger/std.py +3 -0
  171. aethergraph/services/mcp/__init__.py +9 -0
  172. aethergraph/services/mcp/http_client.py +38 -0
  173. aethergraph/services/mcp/service.py +225 -1
  174. aethergraph/services/mcp/stdio_client.py +41 -6
  175. aethergraph/services/mcp/ws_client.py +44 -2
  176. aethergraph/services/memory/__init__.py +0 -0
  177. aethergraph/services/memory/distillers/llm_long_term.py +234 -0
  178. aethergraph/services/memory/distillers/llm_meta_summary.py +398 -0
  179. aethergraph/services/memory/distillers/long_term.py +225 -0
  180. aethergraph/services/memory/facade/__init__.py +3 -0
  181. aethergraph/services/memory/facade/chat.py +440 -0
  182. aethergraph/services/memory/facade/core.py +447 -0
  183. aethergraph/services/memory/facade/distillation.py +424 -0
  184. aethergraph/services/memory/facade/rag.py +410 -0
  185. aethergraph/services/memory/facade/results.py +315 -0
  186. aethergraph/services/memory/facade/retrieval.py +139 -0
  187. aethergraph/services/memory/facade/types.py +77 -0
  188. aethergraph/services/memory/facade/utils.py +43 -0
  189. aethergraph/services/memory/facade_dep.py +1539 -0
  190. aethergraph/services/memory/factory.py +9 -3
  191. aethergraph/services/memory/utils.py +10 -0
  192. aethergraph/services/metering/eventlog_metering.py +470 -0
  193. aethergraph/services/metering/noop.py +25 -4
  194. aethergraph/services/rag/__init__.py +0 -0
  195. aethergraph/services/rag/facade.py +279 -23
  196. aethergraph/services/rag/index_factory.py +2 -2
  197. aethergraph/services/rag/node_rag.py +317 -0
  198. aethergraph/services/rate_limit/inmem_rate_limit.py +24 -0
  199. aethergraph/services/registry/__init__.py +0 -0
  200. aethergraph/services/registry/agent_app_meta.py +419 -0
  201. aethergraph/services/registry/registry_key.py +1 -1
  202. aethergraph/services/registry/unified_registry.py +74 -6
  203. aethergraph/services/scope/scope.py +159 -0
  204. aethergraph/services/scope/scope_factory.py +164 -0
  205. aethergraph/services/state_stores/serialize.py +5 -0
  206. aethergraph/services/state_stores/utils.py +2 -1
  207. aethergraph/services/viz/__init__.py +0 -0
  208. aethergraph/services/viz/facade.py +413 -0
  209. aethergraph/services/viz/viz_service.py +69 -0
  210. aethergraph/storage/artifacts/artifact_index_jsonl.py +180 -0
  211. aethergraph/storage/artifacts/artifact_index_sqlite.py +426 -0
  212. aethergraph/storage/artifacts/cas_store.py +422 -0
  213. aethergraph/storage/artifacts/fs_cas.py +18 -0
  214. aethergraph/storage/artifacts/s3_cas.py +14 -0
  215. aethergraph/storage/artifacts/utils.py +124 -0
  216. aethergraph/storage/blob/fs_blob.py +86 -0
  217. aethergraph/storage/blob/s3_blob.py +115 -0
  218. aethergraph/storage/continuation_store/fs_cont.py +283 -0
  219. aethergraph/storage/continuation_store/inmem_cont.py +146 -0
  220. aethergraph/storage/continuation_store/kvdoc_cont.py +261 -0
  221. aethergraph/storage/docstore/fs_doc.py +63 -0
  222. aethergraph/storage/docstore/sqlite_doc.py +31 -0
  223. aethergraph/storage/docstore/sqlite_doc_sync.py +90 -0
  224. aethergraph/storage/eventlog/fs_event.py +136 -0
  225. aethergraph/storage/eventlog/sqlite_event.py +47 -0
  226. aethergraph/storage/eventlog/sqlite_event_sync.py +178 -0
  227. aethergraph/storage/factory.py +432 -0
  228. aethergraph/storage/fs_utils.py +28 -0
  229. aethergraph/storage/graph_state_store/state_store.py +64 -0
  230. aethergraph/storage/kv/inmem_kv.py +103 -0
  231. aethergraph/storage/kv/layered_kv.py +52 -0
  232. aethergraph/storage/kv/sqlite_kv.py +39 -0
  233. aethergraph/storage/kv/sqlite_kv_sync.py +98 -0
  234. aethergraph/storage/memory/event_persist.py +68 -0
  235. aethergraph/storage/memory/fs_persist.py +118 -0
  236. aethergraph/{services/memory/hotlog_kv.py → storage/memory/hotlog.py} +8 -2
  237. aethergraph/{services → storage}/memory/indices.py +31 -7
  238. aethergraph/storage/metering/meter_event.py +55 -0
  239. aethergraph/storage/runs/doc_store.py +280 -0
  240. aethergraph/storage/runs/inmen_store.py +82 -0
  241. aethergraph/storage/runs/sqlite_run_store.py +403 -0
  242. aethergraph/storage/sessions/doc_store.py +183 -0
  243. aethergraph/storage/sessions/inmem_store.py +110 -0
  244. aethergraph/storage/sessions/sqlite_session_store.py +399 -0
  245. aethergraph/storage/vector_index/chroma_index.py +138 -0
  246. aethergraph/storage/vector_index/faiss_index.py +179 -0
  247. aethergraph/storage/vector_index/sqlite_index.py +187 -0
  248. {aethergraph-0.1.0a1.dist-info → aethergraph-0.1.0a2.dist-info}/METADATA +138 -31
  249. aethergraph-0.1.0a2.dist-info/RECORD +356 -0
  250. aethergraph-0.1.0a2.dist-info/entry_points.txt +3 -0
  251. aethergraph/services/artifacts/factory.py +0 -35
  252. aethergraph/services/artifacts/fs_store.py +0 -656
  253. aethergraph/services/artifacts/jsonl_index.py +0 -123
  254. aethergraph/services/artifacts/sqlite_index.py +0 -209
  255. aethergraph/services/memory/distillers/episode.py +0 -116
  256. aethergraph/services/memory/distillers/rolling.py +0 -74
  257. aethergraph/services/memory/facade.py +0 -633
  258. aethergraph/services/memory/persist_fs.py +0 -40
  259. aethergraph/services/rag/index/base.py +0 -27
  260. aethergraph/services/rag/index/faiss_index.py +0 -121
  261. aethergraph/services/rag/index/sqlite_index.py +0 -134
  262. aethergraph-0.1.0a1.dist-info/RECORD +0 -182
  263. aethergraph-0.1.0a1.dist-info/entry_points.txt +0 -2
  264. {aethergraph-0.1.0a1.dist-info → aethergraph-0.1.0a2.dist-info}/WHEEL +0 -0
  265. {aethergraph-0.1.0a1.dist-info → aethergraph-0.1.0a2.dist-info}/licenses/LICENSE +0 -0
  266. {aethergraph-0.1.0a1.dist-info → aethergraph-0.1.0a2.dist-info}/licenses/NOTICE +0 -0
  267. {aethergraph-0.1.0a1.dist-info → aethergraph-0.1.0a2.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,403 @@
1
+ # aethergraph/storage/sqlite_run_store.py
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ from dataclasses import asdict, is_dataclass
7
+ from datetime import datetime
8
+ import json
9
+ from pathlib import Path
10
+ import sqlite3
11
+ import threading
12
+ from typing import Any
13
+
14
+ from aethergraph.contracts.services.runs import RunStore
15
+ from aethergraph.core.runtime.run_types import RunRecord, RunStatus
16
+
17
+
18
+ def _dt_to_ts(dt: datetime | None) -> float | None:
19
+ if dt is None:
20
+ return None
21
+ if dt.tzinfo is None:
22
+ # assume UTC if naive
23
+ return dt.replace(tzinfo=datetime.timezone.utc).timestamp()
24
+ return dt.timestamp()
25
+
26
+
27
+ def _encode_run(record: RunRecord) -> dict[str, Any]:
28
+ """Convert RunRecord -> plain dict with JSON-safe types."""
29
+ if is_dataclass(record): # noqa: SIM108
30
+ data = asdict(record)
31
+ else:
32
+ # fallback; should not really happen
33
+ data = dict(record.__dict__)
34
+
35
+ for k, v in list(data.items()):
36
+ if isinstance(v, datetime):
37
+ data[k] = v.isoformat()
38
+ return data
39
+
40
+
41
+ def _decode_run(data: dict[str, Any]) -> RunRecord:
42
+ """Convert dict from JSON back into RunRecord."""
43
+
44
+ # Best-effort datetime parsing for common fields
45
+ def _parse_dt(val: Any) -> datetime | None:
46
+ if val is None:
47
+ return None
48
+ if isinstance(val, datetime):
49
+ return val
50
+ if isinstance(val, str):
51
+ try:
52
+ return datetime.fromisoformat(val)
53
+ except Exception:
54
+ return None
55
+ return None
56
+
57
+ for key in (
58
+ "created_at",
59
+ "updated_at",
60
+ "started_at",
61
+ "finished_at",
62
+ "first_artifact_at",
63
+ "last_artifact_at",
64
+ ):
65
+ if key in data:
66
+ parsed = _parse_dt(data[key])
67
+ if parsed is not None:
68
+ data[key] = parsed
69
+
70
+ return RunRecord(**data)
71
+
72
+
73
+ class SQLiteRunStoreSync:
74
+ """
75
+ SQLite-backed RunStore.
76
+
77
+ - Stores full RunRecord as JSON in `data_json`
78
+ - Promotes a few fields to columns for fast filtering:
79
+ run_id, graph_id, status, user_id, org_id, session_id,
80
+ started_at, finished_at
81
+ """
82
+
83
+ def __init__(self, path: str):
84
+ path_obj = Path(path)
85
+ path_obj.parent.mkdir(parents=True, exist_ok=True)
86
+
87
+ self._db = sqlite3.connect(
88
+ str(path_obj),
89
+ check_same_thread=False,
90
+ isolation_level=None, # autocommit
91
+ )
92
+ self._db.execute("PRAGMA journal_mode=WAL;")
93
+ self._db.execute("PRAGMA synchronous=NORMAL;")
94
+
95
+ # Base table
96
+ self._db.execute(
97
+ """
98
+ CREATE TABLE IF NOT EXISTS runs (
99
+ run_id TEXT PRIMARY KEY,
100
+ data_json TEXT NOT NULL,
101
+ graph_id TEXT,
102
+ status TEXT,
103
+ user_id TEXT,
104
+ org_id TEXT,
105
+ session_id TEXT,
106
+ started_at REAL,
107
+ finished_at REAL
108
+ )
109
+ """
110
+ )
111
+
112
+ # Indices for common queries
113
+ self._db.execute(
114
+ "CREATE INDEX IF NOT EXISTS idx_runs_graph_started ON runs(graph_id, started_at DESC)"
115
+ )
116
+ self._db.execute(
117
+ "CREATE INDEX IF NOT EXISTS idx_runs_status_started ON runs(status, started_at DESC)"
118
+ )
119
+ self._db.execute(
120
+ "CREATE INDEX IF NOT EXISTS idx_runs_user_started ON runs(user_id, started_at DESC)"
121
+ )
122
+ self._db.execute(
123
+ "CREATE INDEX IF NOT EXISTS idx_runs_org_started ON runs(org_id, started_at DESC)"
124
+ )
125
+ self._db.execute(
126
+ "CREATE INDEX IF NOT EXISTS idx_runs_session_started ON runs(session_id, started_at DESC)"
127
+ )
128
+
129
+ self._lock = threading.RLock()
130
+
131
+ # --- core ops ---
132
+
133
+ def create(self, record: RunRecord) -> None:
134
+ data = _encode_run(record)
135
+ payload = json.dumps(data, ensure_ascii=False)
136
+ started_ts = _dt_to_ts(getattr(record, "started_at", None))
137
+ finished_ts = _dt_to_ts(getattr(record, "finished_at", None))
138
+
139
+ with self._lock:
140
+ self._db.execute(
141
+ """
142
+ INSERT INTO runs (
143
+ run_id, data_json,
144
+ graph_id, status,
145
+ user_id, org_id, session_id,
146
+ started_at, finished_at
147
+ )
148
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
149
+ """,
150
+ (
151
+ record.run_id,
152
+ payload,
153
+ getattr(record, "graph_id", None),
154
+ record.status.value
155
+ if isinstance(record.status, RunStatus)
156
+ else str(record.status),
157
+ getattr(record, "user_id", None),
158
+ getattr(record, "org_id", None),
159
+ getattr(record, "session_id", None),
160
+ started_ts,
161
+ finished_ts,
162
+ ),
163
+ )
164
+
165
+ def update_status(
166
+ self,
167
+ run_id: str,
168
+ status: RunStatus,
169
+ *,
170
+ finished_at: datetime | None = None,
171
+ error: str | None = None,
172
+ ) -> None:
173
+ with self._lock:
174
+ row = self._db.execute(
175
+ "SELECT data_json FROM runs WHERE run_id = ?",
176
+ (run_id,),
177
+ ).fetchone()
178
+ if not row:
179
+ return
180
+
181
+ data = json.loads(row[0])
182
+ data["status"] = status.value if isinstance(status, RunStatus) else str(status)
183
+ if finished_at is not None:
184
+ data["finished_at"] = finished_at.isoformat()
185
+ if error is not None:
186
+ data["error"] = error
187
+
188
+ payload = json.dumps(data, ensure_ascii=False)
189
+ finished_ts = _dt_to_ts(finished_at)
190
+
191
+ self._db.execute(
192
+ """
193
+ UPDATE runs
194
+ SET data_json = ?, status = ?, finished_at = ?
195
+ WHERE run_id = ?
196
+ """,
197
+ (payload, status.value, finished_ts, run_id),
198
+ )
199
+
200
+ def get(self, run_id: str) -> RunRecord | None:
201
+ with self._lock:
202
+ row = self._db.execute(
203
+ "SELECT data_json FROM runs WHERE run_id = ?",
204
+ (run_id,),
205
+ ).fetchone()
206
+ if not row:
207
+ return None
208
+ data = json.loads(row[0])
209
+ return _decode_run(data)
210
+
211
+ def list(
212
+ self,
213
+ *,
214
+ graph_id: str | None = None,
215
+ status: RunStatus | None = None,
216
+ user_id: str | None = None,
217
+ org_id: str | None = None,
218
+ session_id: str | None = None,
219
+ limit: int = 100,
220
+ offset: int = 0,
221
+ ) -> list[RunRecord]:
222
+ """
223
+ List runs ordered by started_at DESC.
224
+
225
+ NOTE: session_id is optional; you can ignore it if you want to keep
226
+ the signature 100% identical to your current RunStore, or add it
227
+ and update RunManager accordingly.
228
+ """
229
+ where: list[str] = []
230
+ params: list[Any] = []
231
+
232
+ if graph_id is not None:
233
+ where.append("graph_id = ?")
234
+ params.append(graph_id)
235
+
236
+ if status is not None:
237
+ where.append("status = ?")
238
+ status_val = status.value if isinstance(status, RunStatus) else str(status)
239
+ params.append(status_val)
240
+
241
+ if org_id is not None:
242
+ where.append("org_id = ?")
243
+ params.append(org_id)
244
+
245
+ if user_id is not None:
246
+ where.append("user_id = ?")
247
+ params.append(user_id)
248
+
249
+ if session_id is not None:
250
+ where.append("session_id = ?")
251
+ params.append(session_id)
252
+
253
+ sql = "SELECT data_json FROM runs"
254
+ if where:
255
+ sql += " WHERE " + " AND ".join(where)
256
+ sql += " ORDER BY started_at DESC"
257
+
258
+ if limit is not None:
259
+ sql += " LIMIT ? OFFSET ?"
260
+ params.extend([limit, offset])
261
+
262
+ with self._lock:
263
+ rows = self._db.execute(sql, params).fetchall()
264
+
265
+ return [_decode_run(json.loads(r[0])) for r in rows]
266
+
267
+ def record_artifact(
268
+ self,
269
+ run_id: str,
270
+ *,
271
+ artifact_id: str,
272
+ created_at: datetime | None = None,
273
+ max_recent: int = 10,
274
+ ) -> None:
275
+ """
276
+ Optional API used by ArtifactFacade._record via getattr(..., 'record_artifact', None).
277
+
278
+ Updates artifact-related metadata:
279
+
280
+ - artifact_count
281
+ - first_artifact_at
282
+ - last_artifact_at
283
+ - recent_artifact_ids (bounded to `max_recent`)
284
+
285
+ No-op if the run does not exist.
286
+ """
287
+ with self._lock:
288
+ row = self._db.execute(
289
+ "SELECT data_json FROM runs WHERE run_id = ?",
290
+ (run_id,),
291
+ ).fetchone()
292
+
293
+ if not row:
294
+ return
295
+
296
+ # Decode current RunRecord from JSON
297
+ data = json.loads(row[0])
298
+ record = _decode_run(data)
299
+
300
+ # Choose timestamp
301
+ ts = created_at or datetime.utcnow()
302
+
303
+ # Update stats
304
+ record.artifact_count = (record.artifact_count or 0) + 1
305
+
306
+ if record.first_artifact_at is None or ts < record.first_artifact_at:
307
+ record.first_artifact_at = ts
308
+
309
+ if record.last_artifact_at is None or ts > record.last_artifact_at:
310
+ record.last_artifact_at = ts
311
+
312
+ # Maintain a small rolling window of recent IDs
313
+ if artifact_id:
314
+ recent = list(record.recent_artifact_ids or [])
315
+ recent.append(artifact_id)
316
+ record.recent_artifact_ids = recent[-max_recent:]
317
+
318
+ # Re-encode and persist JSON
319
+ new_data = _encode_run(record)
320
+ payload = json.dumps(new_data, ensure_ascii=False)
321
+
322
+ self._db.execute(
323
+ """
324
+ UPDATE runs
325
+ SET data_json = ?
326
+ WHERE run_id = ?
327
+ """,
328
+ (payload, run_id),
329
+ )
330
+
331
+
332
+ class SQLiteRunStore(RunStore):
333
+ """
334
+ Async RunStore implementation that delegates to SQLiteRunStoreSync
335
+ using asyncio.to_thread for I/O.
336
+ """
337
+
338
+ def __init__(self, path: str):
339
+ self._sync = SQLiteRunStoreSync(path)
340
+
341
+ async def create(self, record: RunRecord) -> None:
342
+ await asyncio.to_thread(self._sync.create, record)
343
+
344
+ async def update_status(
345
+ self,
346
+ run_id: str,
347
+ status: RunStatus,
348
+ *,
349
+ finished_at: datetime | None = None,
350
+ error: str | None = None,
351
+ ) -> None:
352
+ await asyncio.to_thread(
353
+ self._sync.update_status,
354
+ run_id,
355
+ status,
356
+ finished_at=finished_at,
357
+ error=error,
358
+ )
359
+
360
+ async def get(self, run_id: str) -> RunRecord | None:
361
+ return await asyncio.to_thread(self._sync.get, run_id)
362
+
363
+ async def list(
364
+ self,
365
+ *,
366
+ graph_id: str | None = None,
367
+ status: RunStatus | None = None,
368
+ user_id: str | None = None,
369
+ org_id: str | None = None,
370
+ session_id: str | None = None,
371
+ limit: int = 100,
372
+ offset: int = 0,
373
+ # If you decide to expose session_id here, add it and thread it down.
374
+ ) -> list[RunRecord]:
375
+ # For now we only use graph_id/status; session_id can be added later
376
+ return await asyncio.to_thread(
377
+ self._sync.list,
378
+ graph_id=graph_id,
379
+ status=status,
380
+ user_id=user_id,
381
+ org_id=org_id,
382
+ session_id=session_id,
383
+ limit=limit,
384
+ offset=offset,
385
+ )
386
+
387
+ async def record_artifact(
388
+ self,
389
+ run_id: str,
390
+ *,
391
+ artifact_id: str,
392
+ created_at: datetime | None = None,
393
+ ) -> None:
394
+ """
395
+ Async façade for artifact stats update.
396
+ Called from ArtifactFacade._record via getattr(..., 'record_artifact', None).
397
+ """
398
+ await asyncio.to_thread(
399
+ self._sync.record_artifact,
400
+ run_id,
401
+ artifact_id=artifact_id,
402
+ created_at=created_at,
403
+ )
@@ -0,0 +1,183 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ from datetime import datetime, timezone
5
+ from typing import Any
6
+ import uuid
7
+
8
+ from aethergraph.api.v1.schemas import Session
9
+ from aethergraph.contracts.services.sessions import SessionStore
10
+ from aethergraph.contracts.storage.doc_store import (
11
+ DocStore, # wherever your DocStore Protocol lives
12
+ )
13
+ from aethergraph.core.runtime.run_types import SessionKind
14
+
15
+
16
+ def _encode_dt(dt: datetime | None) -> str | None:
17
+ return dt.isoformat() if dt else None
18
+
19
+
20
+ def _decode_dt(s: str | None) -> datetime | None:
21
+ if not s:
22
+ return None
23
+ if s.endswith("Z"):
24
+ s = s[:-1] + "+00:00"
25
+ return datetime.fromisoformat(s)
26
+
27
+
28
+ def _session_to_doc(s: Session) -> dict[str, Any]:
29
+ d = s.model_dump() if hasattr(s, "model_dump") else dict(s) # pydantic v2/v1 tolerant
30
+ d["created_at"] = _encode_dt(getattr(s, "created_at", None))
31
+ d["updated_at"] = _encode_dt(getattr(s, "updated_at", None))
32
+ return d
33
+
34
+
35
+ def _doc_to_session(doc: dict[str, Any]) -> Session:
36
+ doc = dict(doc)
37
+ doc["created_at"] = _decode_dt(doc.get("created_at"))
38
+ doc["updated_at"] = _decode_dt(doc.get("updated_at"))
39
+ return Session(**doc)
40
+
41
+
42
+ class DocSessionStore(SessionStore):
43
+ """
44
+ SessionStore backed by an arbitrary DocStore.
45
+
46
+ - Uses doc IDs like "<prefix><session_id>" (prefix defaults to "session:").
47
+ - Persists Session as JSON-friendly dicts (ISO datetimes).
48
+ - Supports FS-backed or SQLite-backed DocStore transparently.
49
+
50
+ The only requirement is that the underlying DocStore implements `list()`
51
+ if you want list_for_user() to work.
52
+ """
53
+
54
+ def __init__(self, doc_store: DocStore, *, prefix: str = "session:") -> None:
55
+ self._ds = doc_store
56
+ self._prefix = prefix
57
+ self._lock = asyncio.Lock()
58
+
59
+ def _doc_id(self, session_id: str) -> str:
60
+ return f"{self._prefix}{session_id}"
61
+
62
+ async def create(
63
+ self,
64
+ *,
65
+ kind: SessionKind,
66
+ user_id: str | None,
67
+ org_id: str | None,
68
+ title: str | None = None,
69
+ source: str = "webui",
70
+ external_ref: str | None = None,
71
+ ) -> Session:
72
+ now = datetime.now(timezone.utc)
73
+ session_id = f"sess_{uuid.uuid4().hex[:8]}"
74
+ sess = Session(
75
+ session_id=session_id,
76
+ kind=kind,
77
+ title=title,
78
+ user_id=user_id,
79
+ org_id=org_id,
80
+ source=source,
81
+ external_ref=external_ref,
82
+ created_at=now,
83
+ updated_at=now,
84
+ )
85
+
86
+ async with self._lock:
87
+ await self._ds.put(self._doc_id(session_id), _session_to_doc(sess))
88
+ return sess
89
+
90
+ async def get(self, session_id: str) -> Session | None:
91
+ doc_id = self._doc_id(session_id)
92
+ async with self._lock:
93
+ doc = await self._ds.get(doc_id)
94
+ if not doc:
95
+ return None
96
+ return _doc_to_session(doc)
97
+
98
+ async def list_for_user(
99
+ self,
100
+ *,
101
+ user_id: str | None,
102
+ org_id: str | None = None,
103
+ kind: SessionKind | None = None,
104
+ limit: int = 50,
105
+ offset: int = 0,
106
+ ) -> list[Session]:
107
+ # Same tradeoff as DocRunStore.list(): scan all, filter in Python
108
+ if not hasattr(self._ds, "list"):
109
+ raise RuntimeError(
110
+ "Underlying DocStore does not implement list(); "
111
+ "cannot support SessionStore.list_for_user()."
112
+ )
113
+
114
+ async with self._lock:
115
+ doc_ids: list[str] = await self._ds.list() # type: ignore[attr-defined]
116
+ doc_ids = [d for d in doc_ids if d.startswith(self._prefix)]
117
+
118
+ records: list[Session] = []
119
+ for doc_id in doc_ids:
120
+ doc = await self._ds.get(doc_id)
121
+ if not doc:
122
+ continue
123
+ sess = _doc_to_session(doc)
124
+
125
+ if user_id is not None and sess.user_id != user_id:
126
+ continue
127
+ if org_id is not None and sess.org_id != org_id:
128
+ continue
129
+ if kind is not None and sess.kind != kind:
130
+ continue
131
+
132
+ records.append(sess)
133
+
134
+ records.sort(key=lambda s: s.created_at, reverse=True)
135
+
136
+ if offset > 0:
137
+ records = records[offset:]
138
+ if limit is not None:
139
+ records = records[:limit]
140
+ return records
141
+
142
+ async def touch(
143
+ self,
144
+ session_id: str,
145
+ *,
146
+ updated_at: datetime | None = None,
147
+ ) -> None:
148
+ doc_id = self._doc_id(session_id)
149
+ async with self._lock:
150
+ doc = await self._ds.get(doc_id)
151
+ if doc is None:
152
+ return
153
+ doc["updated_at"] = _encode_dt(updated_at or datetime.now(timezone.utc))
154
+ await self._ds.put(doc_id, doc)
155
+
156
+ async def update(
157
+ self,
158
+ session_id: str,
159
+ *,
160
+ title: str | None = None,
161
+ external_ref: str | None = None,
162
+ ) -> Session | None:
163
+ doc_id = self._doc_id(session_id)
164
+ async with self._lock:
165
+ doc = await self._ds.get(doc_id)
166
+ if doc is None:
167
+ return None
168
+
169
+ if title is not None:
170
+ doc["title"] = title
171
+ if external_ref is not None:
172
+ doc["external_ref"] = external_ref
173
+
174
+ # Always bump updated_at
175
+ doc["updated_at"] = _encode_dt(datetime.now(timezone.utc))
176
+
177
+ await self._ds.put(doc_id, doc)
178
+
179
+ return _doc_to_session(doc)
180
+
181
+ async def delete(self, session_id: str) -> None:
182
+ async with self._lock:
183
+ await self._ds.delete(self._doc_id(session_id))
@@ -0,0 +1,110 @@
1
+ import asyncio
2
+ from datetime import datetime, timezone
3
+ import uuid
4
+
5
+ from aethergraph.api.v1.schemas import Session
6
+ from aethergraph.contracts.services.sessions import SessionStore
7
+ from aethergraph.core.runtime.run_types import SessionKind
8
+
9
+
10
+ class InMemorySessionStore(SessionStore):
11
+ def __init__(self) -> None:
12
+ self._sessions: dict[str, Session] = {}
13
+ self._lock = asyncio.Lock() # TODO: confirm async lock is fine bc this will only be used inside uvicorn process with UI.
14
+
15
+ async def create(
16
+ self,
17
+ *,
18
+ kind: SessionKind,
19
+ user_id: str | None,
20
+ org_id: str | None,
21
+ title: str | None = None,
22
+ source: str = "webui",
23
+ external_ref: str | None = None,
24
+ ) -> Session:
25
+ async with self._lock:
26
+ now = datetime.now(timezone.utc)
27
+ session_id = f"sess_{uuid.uuid4().hex[:8]}"
28
+ sess = Session(
29
+ session_id=session_id,
30
+ kind=kind,
31
+ title=title,
32
+ user_id=user_id,
33
+ org_id=org_id,
34
+ source=source,
35
+ external_ref=external_ref,
36
+ created_at=now,
37
+ updated_at=now,
38
+ )
39
+ self._sessions[session_id] = sess
40
+ return sess
41
+
42
+ async def get(self, session_id: str) -> Session | None:
43
+ async with self._lock:
44
+ return self._sessions.get(session_id)
45
+
46
+ async def list_for_user(
47
+ self,
48
+ *,
49
+ user_id: str | None,
50
+ org_id: str | None = None,
51
+ kind: SessionKind | None = None,
52
+ limit: int = 50,
53
+ offset: int = 0,
54
+ ) -> list[Session]:
55
+ async with self._lock:
56
+ records = list(self._sessions.values())
57
+
58
+ if user_id is not None:
59
+ records = [s for s in records if s.user_id == user_id]
60
+ if org_id is not None:
61
+ records = [s for s in records if s.org_id == org_id]
62
+ if kind is not None:
63
+ records = [s for s in records if s.kind == kind]
64
+
65
+ records.sort(key=lambda s: s.created_at, reverse=True)
66
+
67
+ if offset:
68
+ records = records[offset:]
69
+ if limit:
70
+ records = records[:limit]
71
+
72
+ return records
73
+
74
+ async def touch(
75
+ self,
76
+ session_id: str,
77
+ *,
78
+ updated_at: datetime | None = None,
79
+ ) -> None:
80
+ async with self._lock:
81
+ sess = self._sessions.get(session_id)
82
+ if not sess:
83
+ return
84
+ sess.updated_at = updated_at or datetime.now(timezone.utc)
85
+
86
+ async def update(
87
+ self,
88
+ session_id: str,
89
+ *,
90
+ title: str | None = None,
91
+ external_ref: str | None = None,
92
+ ) -> Session | None:
93
+ async with self._lock:
94
+ sess = self._sessions.get(session_id)
95
+ if not sess:
96
+ return None
97
+
98
+ # Mutate in-place (Session is a Pydantic model or similar)
99
+ if title is not None:
100
+ sess.title = title
101
+ if external_ref is not None:
102
+ sess.external_ref = external_ref
103
+
104
+ sess.updated_at = datetime.now(timezone.utc)
105
+ self._sessions[session_id] = sess
106
+ return sess
107
+
108
+ async def delete(self, session_id: str) -> None:
109
+ async with self._lock:
110
+ self._sessions.pop(session_id, None)