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,415 @@
1
+ # /artifacts
2
+
3
+ import mimetypes
4
+ import os
5
+ from typing import Annotated, Any
6
+
7
+ from fastapi import APIRouter, Body, Depends, HTTPException, Query, Response
8
+ from fastapi.responses import RedirectResponse
9
+
10
+ from aethergraph.api.v1.pagination import decode_cursor, encode_cursor
11
+ from aethergraph.contracts.storage.artifact_index import Artifact
12
+ from aethergraph.core.runtime.runtime_services import current_services
13
+
14
+ from .deps import RequestIdentity, get_identity
15
+ from .schemas import (
16
+ ArtifactListResponse,
17
+ ArtifactMeta,
18
+ ArtifactSearchHit,
19
+ ArtifactSearchRequest,
20
+ ArtifactSearchResponse,
21
+ )
22
+
23
+ router = APIRouter(tags=["artifacts"])
24
+
25
+
26
+ # -------- Helpers -------- #
27
+
28
+
29
+ def _tenant_label_filters(identity: RequestIdentity) -> dict[str, str]:
30
+ """
31
+ Convert RequestIdentity into artifact label filters.
32
+
33
+ All modes (cloud/demo/local) get org_id + user_id set, so we just use that.
34
+ """
35
+ org_id, user_id = identity.tenant_key
36
+ filters: dict[str, str] = {}
37
+
38
+ if org_id is not None:
39
+ filters["org_id"] = org_id
40
+ if user_id is not None:
41
+ filters["user_id"] = user_id
42
+
43
+ return filters
44
+
45
+
46
+ def _extract_tags(labels: dict[str, Any]) -> list[str]:
47
+ """
48
+ Conventions:
49
+ - labels["tags"] may be a list[str] or comma-separated str
50
+ """
51
+ tags = labels.get("tags")
52
+ if isinstance(tags, list):
53
+ return [str(t) for t in tags]
54
+ if isinstance(tags, str):
55
+ return [t.strip() for t in tags.split(",") if t.strip()]
56
+ return []
57
+
58
+
59
+ def _extract_scope_id(a: Artifact) -> str | None:
60
+ """
61
+ Conventions:
62
+ - labels["scope_id"] is preferred
63
+ - labels["scope"] is legacy
64
+ - fallback to run_id if no scope label found
65
+ """
66
+ labels = a.labels or {}
67
+ scope = labels.get("scope_id") or labels.get("scope") # legacy
68
+ if scope is not None:
69
+ return str(scope)
70
+ return a.run_id # fallback to run_id if no scope label found
71
+
72
+
73
+ def _guess_mime(a: Artifact) -> str:
74
+ # 1) explicit mime wins
75
+ if a.mime:
76
+ return a.mime
77
+
78
+ # 2) infer from URI / filename
79
+ mime = None
80
+ if a.uri:
81
+ guessed, _ = mimetypes.guess_type(a.uri)
82
+ if guessed:
83
+ mime = guessed
84
+
85
+ # 3) heuristics from kind (optional but nice)
86
+ if not mime and a.kind:
87
+ k = a.kind.lower()
88
+ if any(x in k for x in ["log", "text", "stdout", "stderr"]):
89
+ mime = "text/plain"
90
+ elif "json" in k:
91
+ mime = "application/json"
92
+ elif "csv" in k:
93
+ mime = "text/csv"
94
+ elif "markdown" in k or "md" in k:
95
+ mime = "text/markdown"
96
+
97
+ # 4) fallback
98
+ return mime or "application/octet-stream"
99
+
100
+
101
+ def _artifact_to_meta(a: Artifact) -> ArtifactMeta:
102
+ """
103
+ Convert Artifact to ArtifactMeta schema.
104
+ """
105
+ labels = a.labels or {}
106
+
107
+ out = ArtifactMeta(
108
+ artifact_id=a.artifact_id,
109
+ kind=a.kind,
110
+ mime_type=_guess_mime(a),
111
+ size=a.bytes,
112
+ scope_id=_extract_scope_id(a) or "unknown_scope",
113
+ tags=_extract_tags(labels),
114
+ created_at=a.created_at, # pydantic will parse ISO str -> datetime
115
+ uri=a.uri,
116
+ pinned=a.pinned,
117
+ preview_uri=a.preview_uri,
118
+ run_id=a.run_id,
119
+ graph_id=a.graph_id,
120
+ node_id=a.node_id if getattr(a, "node_id", None) else None,
121
+ session_id=a.session_id if getattr(a, "session_id", None) else None,
122
+ filename=labels.get("filename"),
123
+ )
124
+ return out
125
+
126
+
127
+ # -------- API Endpoints -------- #
128
+ @router.get("/artifacts", response_model=ArtifactListResponse)
129
+ async def list_artifacts(
130
+ scope_id: Annotated[str | None, Query()] = None,
131
+ kind: Annotated[str | None, Query()] = None,
132
+ tags: Annotated[str | None, Query()] = None,
133
+ cursor: Annotated[str | None, Query()] = None,
134
+ limit: Annotated[int, Query(ge=1, le=200)] = 50,
135
+ identity: RequestIdentity = Depends(get_identity), # noqa: B008
136
+ ) -> ArtifactListResponse:
137
+ container = current_services()
138
+ index = getattr(container, "artifact_index", None)
139
+ if index is None:
140
+ return ArtifactListResponse(artifacts=[], next_cursor=None)
141
+
142
+ offset = decode_cursor(cursor.strip() if cursor else None)
143
+
144
+ label_filters: dict[str, Any] = {}
145
+
146
+ if scope_id and scope_id.strip():
147
+ label_filters["scope_id"] = scope_id.strip()
148
+
149
+ if tags and tags.strip():
150
+ tag_list = [t.strip() for t in tags.split(",") if t.strip()]
151
+ if tag_list:
152
+ label_filters["tags"] = tag_list
153
+
154
+ # 🔹 Tenant scoping: org_id + user_id
155
+ label_filters.update(_tenant_label_filters(identity))
156
+
157
+ artifacts = await index.search(
158
+ kind=kind.strip() if kind and kind.strip() else None,
159
+ labels=label_filters or None,
160
+ metric=None,
161
+ mode=None,
162
+ limit=limit,
163
+ offset=offset,
164
+ )
165
+ metas = [_artifact_to_meta(a) for a in artifacts]
166
+ next_cursor = encode_cursor(offset + limit) if len(artifacts) == limit else None
167
+ return ArtifactListResponse(artifacts=metas, next_cursor=next_cursor)
168
+
169
+
170
+ @router.get("/artifacts/{artifact_id}", response_model=ArtifactMeta)
171
+ async def get_artifact(
172
+ artifact_id: str,
173
+ identity: RequestIdentity = Depends(get_identity), # noqa: B008
174
+ ) -> ArtifactMeta:
175
+ """
176
+ Get single artifact metadata.
177
+ """
178
+ container = current_services()
179
+ index = getattr(container, "artifact_index", None)
180
+ rm = getattr(container, "run_manager", None)
181
+ if index is None or (identity.mode == "demo" and rm is None):
182
+ raise HTTPException(status_code=503, detail="Artifact index not configured")
183
+
184
+ artifact = await index.get(artifact_id)
185
+ if artifact is None:
186
+ raise HTTPException(status_code=404, detail=f"Artifact {artifact_id} not found")
187
+
188
+ meta = _artifact_to_meta(artifact)
189
+ return meta
190
+
191
+
192
+ @router.get("/artifacts/{artifact_id}/content")
193
+ async def get_artifact_content(
194
+ artifact_id: str,
195
+ identity: RequestIdentity = Depends(get_identity), # noqa: B008
196
+ ) -> Response:
197
+ container = current_services()
198
+ index = getattr(container, "artifact_index", None)
199
+ store = getattr(container, "artifacts", None)
200
+ rm = getattr(container, "run_manager", None)
201
+ if index is None or store is None or (identity.client_id and rm is None):
202
+ raise HTTPException(status_code=503, detail="Artifact services not configured")
203
+
204
+ artifact = await index.get(artifact_id)
205
+ if artifact is None:
206
+ raise HTTPException(status_code=404, detail=f"Artifact {artifact_id} not found")
207
+
208
+ # If user provided a fully qualified preview URI (e.g. S3 signed URL)
209
+ if artifact.preview_uri and str(artifact.preview_uri).startswith(("http://", "https://")):
210
+ return RedirectResponse(artifact.preview_uri)
211
+
212
+ # Otherwise, stream raw bytes from the artifact store.
213
+ data = await store.load_artifact_bytes(artifact.uri)
214
+
215
+ # Derive a filename that's at least somewhat meaningful
216
+ labels = artifact.labels or {}
217
+ filename = (
218
+ labels.get("filename")
219
+ or (os.path.basename(artifact.uri) if artifact.uri else None)
220
+ or artifact.artifact_id
221
+ )
222
+
223
+ media_type = artifact.mime or "application/octet-stream"
224
+
225
+ return Response(
226
+ content=data,
227
+ media_type=media_type,
228
+ headers={
229
+ "Content-Length": str(len(data)),
230
+ "Content-Disposition": f'attachment; filename="{filename}"',
231
+ "X-AetherGraph-Artifact-Id": artifact.artifact_id,
232
+ },
233
+ )
234
+
235
+
236
+ @router.post("/artifacts/{artifact_id}/pin")
237
+ async def pin_artifact(
238
+ artifact_id: str,
239
+ pinned: Annotated[bool, Body()] = True,
240
+ identity: Annotated[RequestIdentity, Depends(get_identity)] = None,
241
+ ) -> dict:
242
+ """
243
+ Mark/unmark an artifact as pinned in the index.
244
+
245
+ Pinned artifacts can be treated as "keep" in GC policies or highlighted in UIs.
246
+ """
247
+ container = current_services()
248
+ rm = getattr(container, "run_manager", None)
249
+ index = getattr(container, "artifact_index", None)
250
+ if index is None:
251
+ raise HTTPException(status_code=503, detail="Artifact index not configured")
252
+
253
+ if identity.client_id and rm is None:
254
+ # Can't enforce client scoping without RunManager
255
+ raise HTTPException(status_code=503, detail="Run manager not configured")
256
+
257
+ artifact = await index.get(artifact_id)
258
+ if artifact is None:
259
+ raise HTTPException(status_code=404, detail=f"Artifact {artifact_id} not found")
260
+
261
+ await index.pin(artifact_id, pinned=pinned)
262
+ return {"artifact_id": artifact_id, "pinned": pinned}
263
+
264
+
265
+ @router.get("/runs/{run_id}/artifacts", response_model=ArtifactListResponse)
266
+ async def list_run_artifacts(
267
+ run_id: str,
268
+ cursor: Annotated[str | None, Query()] = None,
269
+ limit: Annotated[int, Query(ge=1, le=200)] = 50,
270
+ identity: RequestIdentity = Depends(get_identity), # noqa: B008
271
+ ) -> ArtifactListResponse:
272
+ container = current_services()
273
+ index = getattr(container, "artifact_index", None)
274
+ if index is None:
275
+ raise HTTPException(status_code=503, detail="Artifact index not configured")
276
+
277
+ offset = decode_cursor(cursor.strip() if cursor else None)
278
+
279
+ label_filters: dict[str, Any] = {"run_id": run_id}
280
+ label_filters.update(_tenant_label_filters(identity))
281
+
282
+ artifacts = await index.search(
283
+ labels=label_filters,
284
+ limit=limit,
285
+ offset=offset,
286
+ )
287
+
288
+ metas = [_artifact_to_meta(a) for a in artifacts]
289
+ next_cursor = encode_cursor(offset + limit) if len(artifacts) == limit else None
290
+ return ArtifactListResponse(artifacts=metas, next_cursor=next_cursor)
291
+
292
+
293
+ @router.get("/sessions/{session_id}/artifacts", response_model=ArtifactListResponse)
294
+ async def list_session_artifacts(
295
+ session_id: str,
296
+ cursor: Annotated[str | None, Query()] = None, # noqa: B008
297
+ limit: Annotated[int, Query(ge=1, le=200)] = 50, # noqa: B008
298
+ identity: RequestIdentity = Depends(get_identity), # noqa: B008
299
+ ) -> ArtifactListResponse:
300
+ container = current_services()
301
+ index = getattr(container, "artifact_index", None)
302
+ if index is None:
303
+ raise HTTPException(status_code=503, detail="Artifact index not configured")
304
+
305
+ offset = decode_cursor(cursor.strip() if cursor else None)
306
+
307
+ label_filters: dict[str, Any] = {"session_id": session_id}
308
+ label_filters.update(_tenant_label_filters(identity))
309
+
310
+ artifacts = await index.search(
311
+ labels=label_filters,
312
+ limit=limit,
313
+ offset=offset,
314
+ )
315
+
316
+ metas = [_artifact_to_meta(a) for a in artifacts]
317
+ next_cursor = encode_cursor(offset + limit) if len(artifacts) == limit else None
318
+ return ArtifactListResponse(artifacts=metas, next_cursor=next_cursor)
319
+
320
+
321
+ @router.post("/artifacts/search", response_model=ArtifactSearchResponse)
322
+ async def search_artifacts(
323
+ req: ArtifactSearchRequest,
324
+ identity: Annotated[RequestIdentity, Depends(get_identity)],
325
+ ) -> ArtifactSearchResponse:
326
+ """
327
+ Structured search over artifacts via the artifact index.
328
+
329
+ We interpret fields on ArtifactSearchRequest in a flexible way:
330
+ - kind: optional artifact kind filter
331
+ - scope_id: maps to labels["scope_id"]
332
+ - tags: optional list[str] or comma-separated string -> labels["tags"]
333
+ - labels: optional extra label filters
334
+ - metric + mode: if provided, used for ranking (and required for best-only)
335
+ - limit: max results
336
+ - best_only: if True, use index.best(...) and return a single hit
337
+
338
+ Tenant scoping is enforced via org_id/user_id/client_id/app_id from RequestIdentity.
339
+ """
340
+ container = current_services()
341
+ index = getattr(container, "artifact_index", None)
342
+ if index is None:
343
+ return ArtifactSearchResponse(results=[])
344
+
345
+ kind = getattr(req, "kind", None)
346
+ scope_id = getattr(req, "scope_id", None)
347
+ tags = getattr(req, "tags", None)
348
+ extra_labels = getattr(req, "labels", None)
349
+ metric = getattr(req, "metric", None)
350
+ mode = getattr(req, "mode", None)
351
+ limit = getattr(req, "limit", 50)
352
+ best_only = getattr(req, "best_only", False)
353
+
354
+ label_filter: dict[str, Any] = {}
355
+
356
+ if scope_id:
357
+ label_filter["scope_id"] = scope_id
358
+
359
+ # Handle tags, may be list or comma-separated str
360
+ if tags:
361
+ if isinstance(tags, str):
362
+ tag_list = [t.strip() for t in tags.split(",") if t.strip()]
363
+ elif isinstance(tags, list):
364
+ tag_list = [str(t) for t in tags]
365
+ else:
366
+ tag_list = []
367
+ if tag_list:
368
+ label_filter["tags"] = tag_list
369
+
370
+ if extra_labels:
371
+ label_filter.update(extra_labels)
372
+
373
+ # 🔹 Tenant scoping
374
+ tenant_filters = _tenant_label_filters(identity)
375
+ label_filter.update(tenant_filters)
376
+
377
+ hits: list[ArtifactSearchHit] = []
378
+
379
+ if best_only and metric and mode:
380
+ best = await index.best(
381
+ kind=kind or "",
382
+ metric=metric,
383
+ mode=mode,
384
+ filters=label_filter or None,
385
+ )
386
+ if best is not None:
387
+ score = float(best.metrics.get(metric, 0.0)) if best.metrics else 0.0
388
+ hits.append(
389
+ ArtifactSearchHit(
390
+ artifact=_artifact_to_meta(best),
391
+ score=score,
392
+ )
393
+ )
394
+ return ArtifactSearchResponse(results=hits)
395
+
396
+ artifacts = await index.search(
397
+ kind=kind,
398
+ labels=label_filter or None,
399
+ metric=metric,
400
+ mode=mode,
401
+ limit=limit,
402
+ )
403
+
404
+ for a in artifacts:
405
+ score = 1.0
406
+ if metric and a.metrics:
407
+ score = float(a.metrics.get(metric, 0.0))
408
+ hits.append(
409
+ ArtifactSearchHit(
410
+ artifact=_artifact_to_meta(a),
411
+ score=score,
412
+ )
413
+ )
414
+
415
+ return ArtifactSearchResponse(results=hits)
@@ -0,0 +1,89 @@
1
+ # stub, to move the server.channels module here later
2
+
3
+
4
+ from datetime import datetime, timedelta
5
+
6
+ from fastapi import APIRouter, Depends, Query, WebSocket, WebSocketDisconnect
7
+
8
+ from .deps import RequestIdentity, get_identity
9
+ from .schemas import (
10
+ ChannelEvent,
11
+ ChannelEventListResponse,
12
+ ChannelIngressRequest,
13
+ )
14
+
15
+ router = APIRouter(tags=["channels"])
16
+
17
+
18
+ @router.post("/channels/{channel_id}/ingress")
19
+ async def channel_ingress(
20
+ channel_id: str,
21
+ req: ChannelIngressRequest,
22
+ identity: RequestIdentity = Depends(get_identity), # noqa: B008
23
+ ) -> dict:
24
+ """
25
+ Ingest a message into a channel (HTTP).
26
+
27
+ TODO:
28
+ - Forward to Channel service / Correlator.
29
+ - Likely emit a memory event + trigger continuations.
30
+ """
31
+ # Stub: just echo
32
+ return {
33
+ "channel_id": channel_id,
34
+ "kind": req.kind,
35
+ "text": req.text,
36
+ "metadata": req.metadata,
37
+ "user_id": identity.user_id,
38
+ }
39
+
40
+
41
+ @router.get("/channels/{channel_id}/events", response_model=ChannelEventListResponse)
42
+ async def list_channel_events(
43
+ channel_id: str,
44
+ cursor: str | None = Query(None), # noqa: B008
45
+ limit: int = Query(50, ge=1, le=200), # noqa: B008
46
+ identity: RequestIdentity = Depends(get_identity), # noqa: B008
47
+ ) -> ChannelEventListResponse:
48
+ """
49
+ Polling-based channel event retrieval.
50
+
51
+ TODO:
52
+ - Integrate with channel/event store using cursor pagination.
53
+ """
54
+ now = datetime.utcnow()
55
+ dummy = ChannelEvent(
56
+ event_id="ch-evt-1",
57
+ channel_id=channel_id,
58
+ kind="chat_assistant",
59
+ created_at=now - timedelta(seconds=30),
60
+ data={"text": "Stub channel event"},
61
+ )
62
+ return ChannelEventListResponse(events=[dummy], next_cursor=None)
63
+
64
+
65
+ # ----- WebSocket for real-time -----
66
+
67
+
68
+ @router.websocket("/ws/channels/{channel_id}")
69
+ async def channel_websocket(
70
+ websocket: WebSocket,
71
+ channel_id: str,
72
+ ):
73
+ """
74
+ WebSocket endpoint for real-time channel events.
75
+
76
+ TODO:
77
+ - Authenticate if needed (e.g., via query param token or headers).
78
+ - Subscribe this socket to channel event stream.
79
+ - Push events as they arrive; accept client messages as ingress.
80
+ """
81
+ await websocket.accept()
82
+ try:
83
+ # Very basic echo loop as a stub
84
+ while True:
85
+ data = await websocket.receive_text()
86
+ await websocket.send_text(f"[stub] Channel {channel_id} received: {data}")
87
+ except WebSocketDisconnect:
88
+ # TODO: clean up subscriptions if add them.
89
+ pass
@@ -0,0 +1,168 @@
1
+ from typing import Literal
2
+
3
+ from fastapi import Depends, Header, HTTPException, Request, status
4
+ from pydantic import BaseModel, Field
5
+
6
+ from aethergraph.core.runtime.runtime_services import current_services
7
+ from aethergraph.services.auth.authz import AuthZService
8
+
9
+
10
+ class RequestIdentity(BaseModel):
11
+ user_id: str | None = None
12
+ org_id: str | None = None
13
+ roles: list[str] = Field(default_factory=list)
14
+
15
+ # Demo-only/browser identity
16
+ client_id: str | None = None
17
+
18
+ # How this request is “authenticated”
19
+ mode: Literal["cloud", "demo", "local"] = "local"
20
+
21
+ @property
22
+ def is_cloud(self) -> bool:
23
+ return self.mode == "cloud"
24
+
25
+ @property
26
+ def is_demo(self) -> bool:
27
+ return self.mode == "demo"
28
+
29
+ @property
30
+ def is_local(self) -> bool:
31
+ return self.mode == "local"
32
+
33
+ @property
34
+ def tenant_key(self) -> tuple[str | None, str | None]:
35
+ """Convenience key for tenant scoping."""
36
+ return (self.org_id, self.user_id)
37
+
38
+
39
+ async def get_identity(
40
+ request: Request,
41
+ x_user_id: str | None = Header(None, alias="X-User-ID"),
42
+ x_org_id: str | None = Header(None, alias="X-Org-ID"),
43
+ x_roles: str | None = Header(None, alias="X-Roles"),
44
+ x_client_id: str | None = Header(None, alias="X-Client-ID"),
45
+ ) -> RequestIdentity:
46
+ """
47
+ Identity extraction hook.
48
+
49
+ Modes:
50
+ - CLOUD: auth gateway injects X-User-ID / X-Org-ID (optionally X-Client-ID).
51
+ - DEMO: no user/org, but a client_id is provided (header or query param).
52
+ - LOCAL: no headers; fall back to a single 'local' user/org.
53
+ """
54
+
55
+ roles = x_roles.split(",") if x_roles else []
56
+
57
+ # Allow demo frontend to keep sending ?client_id=... for now
58
+ query_client_id = request.query_params.get("client_id")
59
+ client_id = x_client_id or query_client_id
60
+
61
+ # --- Cloud mode: real auth in front of us ---
62
+ if x_user_id or x_org_id:
63
+ return RequestIdentity(
64
+ user_id=x_user_id,
65
+ org_id=x_org_id,
66
+ roles=roles,
67
+ client_id=client_id, # optional; may be unused in cloud
68
+ mode="cloud",
69
+ )
70
+
71
+ # --- Demo mode: no auth, but we have a client_id ---
72
+ if client_id:
73
+ # Treat client_id as the actual user_id for demo
74
+ demo_user_id = f"demo:{client_id}"
75
+ return RequestIdentity(
76
+ user_id=demo_user_id,
77
+ org_id="demo",
78
+ roles=["demo"],
79
+ client_id=client_id,
80
+ mode="demo",
81
+ )
82
+
83
+ # --- Local mode: dev / sidecar ---
84
+ return RequestIdentity(
85
+ user_id="local",
86
+ org_id="local",
87
+ roles=["dev"],
88
+ client_id=None,
89
+ mode="local",
90
+ )
91
+
92
+
93
+ def _rate_key(identity: RequestIdentity) -> str:
94
+ """
95
+ Compute a stable key for rate limiting.
96
+
97
+ - CLOUD: prefer org_id, then user_id
98
+ - DEMO: use client_id if present, else "demo"
99
+ - LOCAL: just "local"
100
+ """
101
+ if identity.mode == "cloud":
102
+ return identity.org_id or identity.user_id or "anonymous"
103
+
104
+ if identity.mode == "demo":
105
+ # Each browser/client gets its own key if possible
106
+ return identity.client_id or "demo"
107
+
108
+ # local / dev
109
+ return "local"
110
+
111
+
112
+ def get_authz() -> AuthZService:
113
+ container = current_services()
114
+ return container.authz # type: ignore[return-value]
115
+
116
+
117
+ async def require_runs_execute(
118
+ identity: RequestIdentity = Depends(get_identity), # noqa B008
119
+ ) -> RequestIdentity:
120
+ container = current_services()
121
+ if container.authz:
122
+ await container.authz.authorize(identity=identity, scope="runs", action="execute")
123
+ return identity
124
+
125
+
126
+ async def enforce_run_rate_limits(
127
+ request: Request,
128
+ identity: RequestIdentity = Depends(get_identity), # noqa B008
129
+ ) -> None:
130
+ container = current_services()
131
+ settings = getattr(container, "settings", None)
132
+ if not settings or not settings.rate_limit.enabled:
133
+ return
134
+
135
+ # In local/dev mode, don't annoy with limits
136
+ if identity.mode == "local":
137
+ return
138
+
139
+ rl_cfg = settings.rate_limit
140
+
141
+ # ---------- 1) Long-window per-identity cap via metering ----------
142
+ meter = getattr(container, "metering", None)
143
+ if meter is not None:
144
+ # For demo mode this will be user_id="demo", org_id="demo",
145
+ # so all demo clients share the hourly cap. That's fine for now.
146
+ overview = await meter.get_overview(
147
+ user_id=identity.user_id,
148
+ org_id=identity.org_id,
149
+ window=rl_cfg.runs_window,
150
+ )
151
+ if overview.get("runs", 0) >= rl_cfg.max_runs_per_window:
152
+ raise HTTPException(
153
+ status_code=status.HTTP_429_TOO_MANY_REQUESTS,
154
+ detail=(
155
+ f"Run limit exceeded: at most "
156
+ f"{rl_cfg.max_runs_per_window} runs per {rl_cfg.runs_window}."
157
+ ),
158
+ )
159
+
160
+ # ---------- 2) Short-burst limiter (in-memory) ----------
161
+ limiter = getattr(container, "run_burst_limiter", None)
162
+ if limiter is not None:
163
+ key = _rate_key(identity)
164
+ if not limiter.allow(key):
165
+ raise HTTPException(
166
+ status_code=status.HTTP_429_TOO_MANY_REQUESTS,
167
+ detail="Too many runs started in a short period. Please wait a moment.",
168
+ )