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.
Files changed (267) hide show
  1. aethergraph/__init__.py +4 -10
  2. aethergraph/__main__.py +296 -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 +196 -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.0a3.dist-info}/METADATA +138 -31
  249. aethergraph-0.1.0a3.dist-info/RECORD +356 -0
  250. aethergraph-0.1.0a3.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.0a3.dist-info}/WHEEL +0 -0
  265. {aethergraph-0.1.0a1.dist-info → aethergraph-0.1.0a3.dist-info}/licenses/LICENSE +0 -0
  266. {aethergraph-0.1.0a1.dist-info → aethergraph-0.1.0a3.dist-info}/licenses/NOTICE +0 -0
  267. {aethergraph-0.1.0a1.dist-info → aethergraph-0.1.0a3.dist-info}/top_level.txt +0 -0
@@ -6,7 +6,11 @@ import time
6
6
  import aiohttp
7
7
  from fastapi import HTTPException, Request
8
8
 
9
- from aethergraph.services.continuations.continuation import Correlator
9
+ from aethergraph.services.channel.ingress import (
10
+ ChannelIngress,
11
+ IncomingFile,
12
+ IncomingMessage,
13
+ )
10
14
 
11
15
 
12
16
  # --- shared utils ---
@@ -19,11 +23,20 @@ async def _download_slack_file(url: str, token: str) -> bytes:
19
23
  return await r.read()
20
24
 
21
25
 
22
- # async def _download_slack_file(url: str, token: str) -> bytes:
23
- # async with aiohttp.ClientSession() as sess:
24
- # async with sess.get(url, headers={"Authorization": f"Bearer {token}"}) as r:
25
- # r.raise_for_status()
26
- # return await r.read()
26
+ def _slack_scheme_and_channel_id(team_id: str | None, channel_id: str | None) -> tuple[str, str]:
27
+ """
28
+ Map Slack team/channel to the (scheme, channel_id) pair used by ChannelIngress.
29
+
30
+ We keep the existing Slack channel key shape:
31
+ ch_key = "slack:team/T:chan/C"
32
+ and split it as:
33
+ scheme = "slack"
34
+ channel_id = "team/T:chan/C"
35
+ """
36
+ team = team_id or "unknown"
37
+ chan = channel_id or "unknown"
38
+ # This matches your existing _channel_key base form.
39
+ return "slack", f"team/{team}:chan/{chan}"
27
40
 
28
41
 
29
42
  def _verify_sig(request: Request, body: bytes):
@@ -62,7 +75,7 @@ def _channel_key(team_id: str, channel_id: str, thread_ts: str | None) -> str:
62
75
  async def _stage_and_save(c, *, data: bytes, file_id: str, name: str, ch_key: str, cont) -> str:
63
76
  """Write bytes to tmp path, then save via FileArtifactStore.save_file(...).
64
77
  Returns the Artifact.uri (string)."""
65
- tmp = c.artifacts.tmp_path(suffix=f"_{file_id}")
78
+ tmp = await c.artifacts.plan_staging_path(planned_ext=f"_{file_id}")
66
79
  with open(tmp, "wb") as f:
67
80
  f.write(data)
68
81
  run_id = cont.run_id if cont else "ad-hoc"
@@ -88,12 +101,13 @@ async def _stage_and_save(c, *, data: bytes, file_id: str, name: str, ch_key: st
88
101
  async def handle_slack_events_common(container, settings, payload: dict) -> dict:
89
102
  """
90
103
  Common handler for Slack Events API payloads.
91
- This is transport-agnostic: can be called from HTTP route or Socket Mode.
104
+ Now delegates continuation lookup & resume to ChannelIngress.
92
105
  """
93
106
  SLACK_BOT_TOKEN = (
94
107
  settings.slack.bot_token.get_secret_value() if settings.slack.bot_token else ""
95
108
  )
96
109
  c = container
110
+ ingress: ChannelIngress = c.channel_ingress # must exist in your container
97
111
 
98
112
  ev = payload.get("event") or {}
99
113
  ev_type = ev.get("type")
@@ -105,19 +119,13 @@ async def handle_slack_events_common(container, settings, payload: dict) -> dict
105
119
  chan = ev.get("channel")
106
120
  text = ev.get("text", "") or ""
107
121
  files = ev.get("files") or []
108
- ch_key = _channel_key(team, chan, None)
109
122
 
110
- cont = None
111
- if thread_ts:
112
- # Try precise thread-level match
113
- corr = Correlator(scheme="slack", channel=ch_key, thread=thread_ts, message="")
114
- cont = await c.cont_store.find_by_correlator(corr=corr)
115
- if not cont:
116
- # Fallback to channel-root
117
- corr2 = Correlator(scheme="slack", channel=ch_key, thread="", message="")
118
- cont = await c.cont_store.find_by_correlator(corr=corr2)
119
-
120
- file_refs = []
123
+ # Full Slack key for labels/metadata
124
+ ch_key = _channel_key(team, chan, None) # "slack:team/T:chan/C"
125
+ scheme, channel_id = _slack_scheme_and_channel_id(team, chan) # ("slack", "team/T:chan/C")
126
+
127
+ # --- Slack-specific file download + artifact save ---
128
+ file_refs: list[dict] = []
121
129
  if files:
122
130
  token = SLACK_BOT_TOKEN
123
131
  for f in files:
@@ -130,11 +138,17 @@ async def handle_slack_events_common(container, settings, payload: dict) -> dict
130
138
  url_priv = f.get("url_private") or f.get("url_private_download")
131
139
 
132
140
  uri = None
133
- if url_priv:
141
+ if url_priv and token:
134
142
  try:
135
143
  data_bytes = await _download_slack_file(url_priv, token)
144
+ # use Slack-specific labels via _stage_and_save
136
145
  uri = await _stage_and_save(
137
- c, data=data_bytes, file_id=file_id, name=name, ch_key=ch_key, cont=cont
146
+ c,
147
+ data=data_bytes,
148
+ file_id=file_id,
149
+ name=name,
150
+ ch_key=ch_key,
151
+ cont=None, # we don't know cont yet; ChannelIngress will find it
138
152
  )
139
153
  except Exception as e:
140
154
  container.logger and container.logger.warning(
@@ -155,19 +169,48 @@ async def handle_slack_events_common(container, settings, payload: dict) -> dict
155
169
  }
156
170
  )
157
171
 
158
- # append to per-channel inbox (dedupe by id)
159
- inbox_key = f"inbox://{ch_key}"
160
- await c.kv_hot.list_append_unique(inbox_key, file_refs, id_key="id")
172
+ # Turn Slack file_refs into IncomingFile so Ingress can do inbox + payload
173
+ incoming_files: list[IncomingFile] = []
174
+ for fr in file_refs:
175
+ incoming_files.append(
176
+ IncomingFile(
177
+ id=fr["id"],
178
+ name=fr["name"],
179
+ mimetype=fr.get("mimetype"),
180
+ size=fr.get("size"),
181
+ uri=fr.get("uri"), # already artifact-backed
182
+ url=None, # no re-download
183
+ extra={
184
+ "platform": "slack",
185
+ "channel_key": fr.get("channel_key"),
186
+ "ts": fr.get("ts"),
187
+ },
188
+ )
189
+ )
161
190
 
162
- if not cont:
163
- return {}
191
+ meta = {
192
+ "raw": payload,
193
+ "channel_key": ch_key,
194
+ }
195
+
196
+ # Let ChannelIngress find the continuation, update inbox, and resume
197
+ resumed = await ingress.handle(
198
+ IncomingMessage(
199
+ scheme=scheme,
200
+ channel_id=channel_id,
201
+ thread_id=str(thread_ts or ""),
202
+ text=text,
203
+ files=incoming_files or None,
204
+ meta=meta,
205
+ )
206
+ )
164
207
 
165
- if cont.kind in ("user_files", "user_input_or_files"):
166
- await c.resume_router.resume(
167
- cont.run_id, cont.node_id, cont.token, {"text": text, "files": file_refs}
208
+ if container.logger:
209
+ container.logger.for_run().debug(
210
+ f"[Slack] inbound message: text={text!r}, files={len(incoming_files)}, resumed={resumed}"
168
211
  )
169
- else:
170
- await c.resume_router.resume(cont.run_id, cont.node_id, cont.token, {"text": text})
212
+
213
+ # Nothing special to return to Slack (Events API only cares that we 200)
171
214
  return {}
172
215
 
173
216
  # --- file_shared (out-of-band file) ---
@@ -182,15 +225,9 @@ async def handle_slack_events_common(container, settings, payload: dict) -> dict
182
225
  chan = ev.get("channel_id") or (ev.get("channel") or {}).get("id")
183
226
  if not (file_id and chan):
184
227
  return {}
185
- ch_key = _channel_key(team, chan, None)
186
228
 
187
- cont = None
188
- if thread_ts:
189
- corr = Correlator(scheme="slack", channel=ch_key, thread=thread_ts, message="")
190
- cont = await c.cont_store.find_by_correlator(corr=corr)
191
- if not cont:
192
- corr2 = Correlator(scheme="slack", channel=ch_key, thread="", message="")
193
- cont = await c.cont_store.find_by_correlator(corr=corr2)
229
+ ch_key = _channel_key(team, chan, None)
230
+ scheme, channel_id = _slack_scheme_and_channel_id(team, chan)
194
231
 
195
232
  info = await c.slack.client.files_info(file=file_id)
196
233
  f = info.get("file") or {}
@@ -200,39 +237,58 @@ async def handle_slack_events_common(container, settings, payload: dict) -> dict
200
237
  url_priv = f.get("url_private") or f.get("url_private_download")
201
238
 
202
239
  uri = None
203
- if url_priv:
240
+ if url_priv and SLACK_BOT_TOKEN:
204
241
  try:
205
242
  data_bytes = await _download_slack_file(url_priv, SLACK_BOT_TOKEN)
206
243
  uri = await _stage_and_save(
207
- c, data=data_bytes, file_id=file_id, name=name, ch_key=ch_key, cont=cont
244
+ c,
245
+ data=data_bytes,
246
+ file_id=file_id,
247
+ name=name,
248
+ ch_key=ch_key,
249
+ cont=None,
208
250
  )
209
251
  except Exception as e:
210
252
  container.logger and container.logger.for_run().warning(
211
253
  f"Slack download failed: {e}", exc_info=True
212
254
  )
213
255
 
214
- fr = {
215
- "id": file_id,
216
- "name": name,
217
- "mimetype": mimetype,
218
- "size": size,
219
- "uri": uri,
220
- "url_private": url_priv,
221
- "platform": "slack",
222
- "channel_key": ch_key,
223
- "ts": ev.get("event_ts"),
224
- }
256
+ # Build IncomingFile with pre-saved uri
257
+ incoming_file = IncomingFile(
258
+ id=file_id,
259
+ name=name,
260
+ mimetype=mimetype,
261
+ size=size,
262
+ uri=uri, # already artifact-backed, no re-download when uri used in ingress
263
+ url=None,
264
+ extra={
265
+ "platform": "slack",
266
+ "channel_key": ch_key,
267
+ "ts": ev.get("event_ts"),
268
+ },
269
+ )
270
+
271
+ meta = {"raw": payload, "channel_key": ch_key}
225
272
 
226
- inbox_key = f"inbox://{ch_key}"
227
- await c.kv_hot.list_append_unique(inbox_key, [fr], id_key="id")
273
+ resumed = await ingress.handle(
274
+ IncomingMessage(
275
+ scheme=scheme,
276
+ channel_id=channel_id,
277
+ thread_id=str(thread_ts or ""),
278
+ text="", # no text; just a file drop
279
+ files=[incoming_file],
280
+ meta=meta,
281
+ )
282
+ )
228
283
 
229
- if cont and cont.kind in ("user_files", "user_input_or_files"):
230
- await c.resume_router.resume(
231
- cont.run_id, cont.node_id, cont.token, {"text": "", "files": [fr]}
284
+ if container.logger:
285
+ container.logger.for_run().debug(
286
+ f"[Slack] file_shared: file_id={file_id}, resumed={resumed}"
232
287
  )
288
+
233
289
  return {}
234
290
 
235
- # other events might add later
291
+ # other events might be added later
236
292
  return {}
237
293
 
238
294
 
@@ -5,6 +5,11 @@ from typing import Any
5
5
  import aiohttp
6
6
  from fastapi import APIRouter, HTTPException, Request
7
7
 
8
+ from aethergraph.services.channel.ingress import (
9
+ ChannelIngress,
10
+ IncomingFile,
11
+ IncomingMessage,
12
+ )
8
13
  from aethergraph.services.continuations.continuation import Correlator
9
14
 
10
15
  router = APIRouter()
@@ -43,6 +48,23 @@ def _channel_key(chat_id: int, topic_id: int | None) -> str:
43
48
  return f"{base}:topic/{int(topic_id)}" if topic_id else base
44
49
 
45
50
 
51
+ def _tg_scheme_and_channel_id(chat_id: int, topic_id: int | None) -> tuple[str, str]:
52
+ """
53
+ Map Telegram chat/topic to (scheme, channel_id) pair for ChannelIngress.
54
+
55
+ _channel_key(chat_id, topic_id) builds:
56
+ "tg:chat/<id>" or "tg:chat/<id>:topic/<topic_id>"
57
+
58
+ So we use:
59
+ scheme = "tg"
60
+ channel_id = "chat/<id>" or "chat/<id>:topic/<topic_id>"
61
+ """
62
+ base = f"chat/{int(chat_id)}"
63
+ if topic_id:
64
+ base = f"{base}:topic/{int(topic_id)}"
65
+ return "tg", base
66
+
67
+
46
68
  # ---- helpers ----
47
69
  async def _tg_get_file_path(file_id: str, token: str) -> str | None:
48
70
  if not token:
@@ -64,10 +86,11 @@ async def _tg_download_file(file_path: str, token: str) -> bytes:
64
86
  return await r.read()
65
87
 
66
88
 
67
- # -------- NEW: background worker that does the heavy lifting --------
68
89
  async def _process_update(container, payload: dict, token: str):
90
+ ingress: ChannelIngress = container.channel_ingress
91
+
69
92
  try:
70
- # Callback queries (inline button presses)
93
+ # 1) Callback queries (inline buttons) -------------------------
71
94
  cq = payload.get("callback_query")
72
95
  if cq:
73
96
  msg = cq.get("message") or {}
@@ -94,35 +117,37 @@ async def _process_update(container, payload: dict, token: str):
94
117
  choice = str(data_raw)
95
118
 
96
119
  choice_l = choice.lower()
97
- # approved = choice_l.startswith("approve") or choice_l in {"yes","y","ok"} # resolve from choice string
98
120
 
99
- token = None
121
+ tok = None
100
122
  run_id = None
101
123
  node_id = None
102
124
 
103
- # Resolve alias → token (preferred)
125
+ # Preferred: resolve alias → token
104
126
  if resume_key and hasattr(container.cont_store, "token_from_alias"):
105
- token = container.cont_store.token_from_alias(resume_key)
127
+ tok = container.cont_store.token_from_alias(resume_key)
106
128
 
107
- if token and hasattr(container.cont_store, "get_by_token"):
108
- cont = container.cont_store.get_by_token(token)
129
+ if tok and hasattr(container.cont_store, "get_by_token"):
130
+ cont = container.cont_store.get_by_token(tok)
109
131
  if cont:
110
132
  run_id, node_id = cont.run_id, cont.node_id
111
133
 
112
134
  # Fallback: thread-scoped correlator
113
- if not token:
135
+ if not tok:
114
136
  corr = Correlator(
115
- scheme="tg", channel=ch_key, thread=str(topic_id or ""), message=""
137
+ scheme="tg",
138
+ channel=ch_key,
139
+ thread=str(topic_id or ""),
140
+ message="",
116
141
  )
117
142
  cont = await container.cont_store.find_by_correlator(corr=corr)
118
143
  if cont:
119
- run_id, node_id, token = cont.run_id, cont.node_id, cont.token
144
+ run_id, node_id, tok = cont.run_id, cont.node_id, cont.token
120
145
 
121
- if token and run_id and node_id:
146
+ if tok and run_id and node_id:
122
147
  await container.resume_router.resume(
123
148
  run_id=run_id,
124
149
  node_id=node_id,
125
- token=token,
150
+ token=tok,
126
151
  payload={
127
152
  "choice": choice_l,
128
153
  "telegram": {
@@ -142,7 +167,7 @@ async def _process_update(container, payload: dict, token: str):
142
167
  pass
143
168
  return
144
169
 
145
- # Regular messages / uploads
170
+ # 2) Regular messages / uploads -------------------------------
146
171
  msg = payload.get("message")
147
172
  if not msg:
148
173
  return
@@ -153,9 +178,11 @@ async def _process_update(container, payload: dict, token: str):
153
178
  chat_id = chat.get("id")
154
179
  topic_id = msg.get("message_thread_id")
155
180
  ch_key = _channel_key(chat_id, topic_id)
181
+ scheme, channel_id = _tg_scheme_and_channel_id(chat_id, topic_id)
182
+
156
183
  text = (msg.get("text") or msg.get("caption") or "") or ""
157
184
 
158
- files: list[dict[str, Any]] = []
185
+ tg_files: list[dict[str, Any]] = []
159
186
 
160
187
  # Photos
161
188
  photos = msg.get("photo") or []
@@ -171,7 +198,7 @@ async def _process_update(container, payload: dict, token: str):
171
198
  uri = await _stage_and_save(
172
199
  container, data=data, name=name, ch_key=ch_key, cont=None
173
200
  )
174
- files.append(
201
+ tg_files.append(
175
202
  _file_ref(
176
203
  file_id=file_id,
177
204
  name=name,
@@ -198,8 +225,10 @@ async def _process_update(container, payload: dict, token: str):
198
225
  if file_path:
199
226
  try:
200
227
  data = await _tg_download_file(file_path, token)
201
- uri = _stage_and_save(container, data=data, name=name, ch_key=ch_key, cont=None)
202
- files.append(
228
+ uri = await _stage_and_save(
229
+ container, data=data, name=name, ch_key=ch_key, cont=None
230
+ )
231
+ tg_files.append(
203
232
  _file_ref(
204
233
  file_id=file_id,
205
234
  name=name,
@@ -215,29 +244,49 @@ async def _process_update(container, payload: dict, token: str):
215
244
  f"Telegram document download failed: {e}"
216
245
  )
217
246
 
218
- if files:
219
- await _append_inbox(container, ch_key, files)
247
+ # Turn Telegram file_refs into IncomingFile with pre-saved URIs
248
+ incoming_files: list[IncomingFile] = []
249
+ for fr in tg_files:
250
+ incoming_files.append(
251
+ IncomingFile(
252
+ id=fr["id"],
253
+ name=fr["name"],
254
+ mimetype=fr.get("mimetype"),
255
+ size=fr.get("size"),
256
+ uri=fr.get("uri"), # already staged as artifact
257
+ url=None, # no re-download
258
+ extra={
259
+ "platform": "telegram",
260
+ "channel_key": fr.get("channel_key"),
261
+ "ts": fr.get("ts"),
262
+ },
263
+ )
264
+ )
265
+
266
+ meta = {
267
+ "raw": payload,
268
+ "channel_key": ch_key,
269
+ "telegram": {
270
+ "message_id": msg.get("message_id"),
271
+ "chat_id": chat_id,
272
+ },
273
+ }
274
+
275
+ resumed = await ingress.handle(
276
+ IncomingMessage(
277
+ scheme=scheme,
278
+ channel_id=channel_id,
279
+ thread_id=str(topic_id or ""),
280
+ text=text,
281
+ files=incoming_files or None,
282
+ meta=meta,
283
+ )
284
+ )
220
285
 
221
- # Look up continuation by thread-scoped correlator (message-less)
222
- cont = None
223
- corr = Correlator(scheme="tg", channel=ch_key, thread=str(topic_id or ""), message="")
224
- cont = await container.cont_store.find_by_correlator(corr=corr)
225
286
  container.logger and container.logger.for_run().debug(
226
- f"[TG] inbound: text='{text}' files={len(files)} cont={bool(cont)}"
287
+ f"[TG] inbound: text={text!r} files={len(incoming_files)} resumed={resumed}"
227
288
  )
228
289
 
229
- if not cont:
230
- return
231
-
232
- payload_out = {
233
- "text": text,
234
- "telegram": {"message_id": msg.get("message_id"), "chat_id": chat_id},
235
- }
236
- if cont.kind in ("user_files", "user_input_or_files"):
237
- payload_out["files"] = files
238
-
239
- await container.resume_router.resume(cont.run_id, cont.node_id, cont.token, payload_out)
240
-
241
290
  except Exception as e:
242
291
  container.logger and container.logger.for_run().error(
243
292
  f"Telegram inbound processing error: {e}", exc_info=True
@@ -281,7 +330,8 @@ def _normalize_mime_by_name(name: str | None, hint: str | None) -> str:
281
330
 
282
331
 
283
332
  async def _stage_and_save(container, *, data: bytes, name: str, ch_key: str, cont) -> str:
284
- tmp = container.artifacts.tmp_path(suffix=f"_{name}")
333
+ tmp = await container.artifacts.plan_staging_path(planned_ext=f"_{name}")
334
+
285
335
  with open(tmp, "wb") as f:
286
336
  f.write(data)
287
337
  run_id = cont.run_id if cont else "ad-hoc"
@@ -313,12 +363,3 @@ def _file_ref(
313
363
  "channel_key": ch_key,
314
364
  "ts": ts,
315
365
  }
316
-
317
-
318
- async def _append_inbox(container, ch_key: str, file_refs: list[dict[str, Any]]):
319
- kv = getattr(container, "kv_hot", None)
320
- if kv:
321
- await kv.list_append_unique(f"inbox://{ch_key}", file_refs, id_key="id")
322
- else:
323
- logger = getattr(container, "logger", None)
324
- logger and logger.for_run().warning("No KV present; uploads inbox not stored.")
@@ -0,0 +1,172 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import contextlib
5
+ from typing import Any
6
+
7
+ from fastapi import APIRouter, WebSocket, WebSocketDisconnect
8
+
9
+ from aethergraph.api.v1.deps import RequestIdentity # adjust import
10
+ from aethergraph.core.runtime.runtime_services import current_services
11
+ from aethergraph.services.eventhub.event_hub import EventHub
12
+
13
+ """
14
+ WebSocket endpoint for pushing EventLog rows to the browser in real time.
15
+
16
+ Protocol (JSON messages from client):
17
+
18
+ { "type": "subscribe",
19
+ "scope_id": "session:<id>",
20
+ "kinds": ["session_chat"] }
21
+
22
+ { "type": "unsubscribe",
23
+ "scope_id": "session:<id>",
24
+ "kinds": ["session_chat"] }
25
+
26
+ { "type": "ping" }
27
+
28
+ Messages from server:
29
+
30
+ { "type": "event",
31
+ "scope_id": "session:<id>",
32
+ "kind": "session_chat",
33
+ "id": "<event-id>",
34
+ "ts": <float>,
35
+ "payload": { ...same as HTTP /chat/events... }
36
+ }
37
+
38
+ { "type": "pong" }
39
+
40
+ NOTE: This is a scaffold. It is *not* yet wired into auth or your router.
41
+ """
42
+
43
+
44
+ router = APIRouter()
45
+
46
+
47
+ @router.websocket("/ws/events")
48
+ async def ws_events(
49
+ websocket: WebSocket,
50
+ identity: RequestIdentity = None, # TODO: hook in proper auth if desired
51
+ ) -> None:
52
+ """
53
+ WebSocket endpoint for UI event streaming.
54
+
55
+ Typical usage (client-side, future):
56
+
57
+ ws = new WebSocket("wss://.../ws/events");
58
+ ws.send(JSON.stringify({
59
+ type: "subscribe",
60
+ scope_id: "session:<session_id>",
61
+ kinds: ["session_chat"],
62
+ }));
63
+
64
+ For now this is scaffold-only and not used by the frontend.
65
+ """
66
+ await websocket.accept()
67
+
68
+ container = current_services()
69
+ event_hub: EventHub | None = getattr(container, "event_hub", None)
70
+
71
+ if event_hub is None:
72
+ # If EventHub hasn't been wired yet, just close gracefully.
73
+ await websocket.close(code=1011)
74
+ return
75
+
76
+ # (scope_id, kind) -> callback
77
+ callbacks: dict[tuple[str, str], Any] = {}
78
+
79
+ # Queue of rows to send to this client
80
+ queue: asyncio.Queue[dict] = asyncio.Queue()
81
+
82
+ async def make_callback(scope_id: str, kind: str):
83
+ async def _cb(row: dict) -> None:
84
+ """
85
+ Called by EventHub.broadcast(row).
86
+
87
+ We avoid calling websocket.send_json directly here to keep ordering
88
+ and error-handling in a single place (the sender task).
89
+ """
90
+ await queue.put(row)
91
+
92
+ return _cb
93
+
94
+ async def sender() -> None:
95
+ """
96
+ Background task that forwards rows from the queue to the WebSocket.
97
+ """
98
+ try:
99
+ while True:
100
+ row = await queue.get()
101
+ # Minimal envelope; payload matches HTTP /chat/events structure.
102
+ await websocket.send_json(
103
+ {
104
+ "type": "event",
105
+ "scope_id": row.get("scope_id"),
106
+ "kind": row.get("kind"),
107
+ "id": row.get("id"),
108
+ "ts": row.get("ts"),
109
+ "payload": row.get("payload") or {},
110
+ }
111
+ )
112
+ except WebSocketDisconnect:
113
+ # Client went away; main function will handle cleanup.
114
+ return
115
+ except Exception:
116
+ # TODO: log error
117
+ return
118
+
119
+ sender_task = asyncio.create_task(sender())
120
+
121
+ async def subscribe(scope_id: str, kinds: list[str]) -> None:
122
+ for kind in kinds:
123
+ key = (scope_id, kind)
124
+ if key in callbacks:
125
+ continue
126
+ cb = await make_callback(scope_id, kind)
127
+ callbacks[key] = cb
128
+ event_hub.subscribe(scope_id, kind, cb)
129
+
130
+ async def unsubscribe(scope_id: str, kinds: list[str]) -> None:
131
+ for kind in kinds:
132
+ key = (scope_id, kind)
133
+ cb = callbacks.pop(key, None)
134
+ if cb is not None:
135
+ event_hub.unsubscribe(scope_id, kind, cb)
136
+
137
+ try:
138
+ while True:
139
+ msg = await websocket.receive_json()
140
+
141
+ msg_type = msg.get("type")
142
+ if msg_type == "subscribe":
143
+ scope_id = msg["scope_id"]
144
+ kinds = msg.get("kinds") or ["session_chat"]
145
+ # TODO: enforce authorization here based on `identity` & scope_id
146
+ await subscribe(scope_id, kinds)
147
+
148
+ elif msg_type == "unsubscribe":
149
+ scope_id = msg["scope_id"]
150
+ kinds = msg.get("kinds") or ["session_chat"]
151
+ await unsubscribe(scope_id, kinds)
152
+
153
+ elif msg_type == "ping":
154
+ await websocket.send_json({"type": "pong"})
155
+
156
+ # else: ignore unknown types for now
157
+
158
+ except WebSocketDisconnect:
159
+ # Normal disconnect
160
+ pass
161
+ except Exception:
162
+ # TODO: log error
163
+ pass
164
+ finally:
165
+ # Cleanup subscriptions and sender task
166
+ for (scope_id, kind), cb in callbacks.items():
167
+ event_hub.unsubscribe(scope_id, kind, cb)
168
+ callbacks.clear()
169
+
170
+ sender_task.cancel()
171
+ with contextlib.suppress(Exception):
172
+ await sender_task