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
@@ -1,656 +0,0 @@
1
- from contextlib import contextmanager
2
- import datetime
3
- import json
4
- import logging
5
- import mimetypes
6
- import os
7
- from pathlib import Path
8
- import shutil
9
- import tempfile
10
- from typing import Any, BinaryIO
11
- from urllib.parse import unquote, urlparse
12
- from urllib.request import url2pathname
13
-
14
- from aethergraph.contracts.services.artifacts import Artifact
15
-
16
- from .utils import (
17
- _content_addr_dir_path,
18
- _content_addr_path,
19
- _maybe_cleanup_tmp_parent,
20
- _now_iso,
21
- _sha256_file,
22
- _tree_manifest_and_hash,
23
- _write_json,
24
- to_thread,
25
- )
26
-
27
-
28
- def _to_file_uri(path_str: str) -> str:
29
- """Canonical RFC-8089 file URI (file:///C:/..., forward slashes)."""
30
- return Path(path_str).resolve().as_uri()
31
-
32
-
33
- def _from_uri_or_path(s: str) -> Path:
34
- """Robustly turn a file:// URI or plain path into a local Path."""
35
- if "://" not in s:
36
- return Path(s)
37
- u = urlparse(s)
38
- if (u.scheme or "").lower() != "file":
39
- raise ValueError(f"Unsupported URI scheme: {u.scheme}")
40
- # if u.netloc:
41
- # raw = f"//{u.netloc}{u.path}" # UNC: file://server/share/...
42
- # else:
43
- # raw = u.path # Local drive: file:///C:/...
44
- raw = f"//{u.netloc}{u.path}" if u.netloc else u.path
45
- return Path(url2pathname(unquote(raw)))
46
-
47
-
48
- def _normalize_pretty_path(base_dir_for_pretty: str, suggested_uri: str) -> str:
49
- """Normalize a suggested_uri into a local filesystem path for pretty linking.
50
- Args:
51
- base_dir_for_pretty (str): Base directory to resolve relative paths against.
52
- suggested_uri (str): The suggested URI, which may be a file:// URI or a relative/absolute path.
53
- Returns:
54
- str: The normalized local filesystem path.
55
-
56
- Example:
57
- - suggested_uri = "file://./outputs/my_artifact.txt" -> "./outputs/my_artifact.txt
58
- - suggested_uri = "./outputs/my_artifact.txt" -> "./outputs/my_artifact.txt
59
- - suggested_uri = "/var/data/my_artifact.txt" -> "/var/data/my_artifact.txt
60
- NOTE:
61
- Only used for local filesystem paths. For other URI schemes, additional handling would be needed.
62
- """
63
- p = _from_uri_or_path(suggested_uri)
64
- if not p.is_absolute():
65
- p = Path(base_dir_for_pretty) / p
66
- return str(p.resolve())
67
-
68
-
69
- class _Writer:
70
- """Helper class for streaming writes to a temp file."""
71
-
72
- def __init__(self, tmp_dir: str, planned_ext: str | None):
73
- self.tmp_dir = tmp_dir
74
- suffix = planned_ext or ""
75
- file_dir, self.tmp_path = tempfile.mkstemp(suffix=suffix, dir=tmp_dir)
76
- os.close(file_dir)
77
- with open(self.tmp_path, "wb") as f:
78
- self._f = f
79
- self._labels = {}
80
- self._metrics = {}
81
-
82
- def write(self, chunk: bytes):
83
- self._f.write(chunk)
84
-
85
- def add_labels(self, labels: dict):
86
- self._labels.update(labels or {})
87
-
88
- def add_metrics(self, metrics: dict):
89
- self._metrics.update(metrics or {})
90
-
91
- def close(self):
92
- if not self._f.closed:
93
- self._f.close()
94
-
95
-
96
- class _Reader:
97
- """Helper class for reading from a file."""
98
-
99
- def __init__(self, path: str, f: BinaryIO):
100
- self._path, self._f = path, f
101
-
102
- def read(self, n: int = -1) -> bytes:
103
- return self._f.read(n)
104
-
105
- def as_local_path(self) -> str:
106
- return self._path
107
-
108
-
109
- class FileArtifactStoreSync:
110
- """
111
- Synchronous file-based artifact store.
112
- """
113
-
114
- def __init__(self, base_dir: str):
115
- # base directory for content-addressed storage
116
- self.base_dir = os.path.abspath(base_dir)
117
- os.makedirs(self.base_dir, exist_ok=True)
118
-
119
- # temporary staging area for in-progress writes
120
- self._tmp_root = os.path.join(self.base_dir, "_tmp")
121
- os.makedirs(self._tmp_root, exist_ok=True)
122
-
123
- self.last_artifact: Artifact | None = None
124
-
125
- @property
126
- def base_uri(self) -> str:
127
- return _to_file_uri(self.base_dir)
128
-
129
- def tmp_path(self, suffix: str = "") -> str:
130
- """Return a temporary path for external tools to write to."""
131
- os.makedirs(self._tmp_root, exist_ok=True)
132
- fd, p = tempfile.mkstemp(suffix=suffix, dir=self._tmp_root)
133
- os.close(fd)
134
- return p
135
-
136
- def save_file(
137
- self,
138
- path: str,
139
- *,
140
- kind: str,
141
- run_id: str,
142
- graph_id: str,
143
- node_id: str,
144
- tool_name: str,
145
- tool_version: str,
146
- suggested_uri: str | None = None,
147
- pin: bool = False,
148
- labels: dict | None = None,
149
- metrics: dict | None = None,
150
- preview_uri: str | None = None,
151
- cleanup: bool = True,
152
- ) -> Artifact:
153
- """
154
- Save a file into content-addressed storage and return an Artifact record.
155
- Args:
156
- path (str): The file path to save.
157
- kind (str): The kind of artifact.
158
- run_id (str): The run ID.
159
- graph_id (str): The graph ID.
160
- node_id (str): The node ID.
161
- tool_name (str): The tool name.
162
- tool_version (str): The tool version.
163
- suggested_uri (Optional[str]): A suggested URI for the artifact.
164
- pin (bool): Whether to pin the artifact.
165
- labels (Optional[dict]): Labels to attach to the artifact.
166
- metrics (Optional[dict]): Metrics to attach to the artifact.
167
- preview_uri (Optional[str]): A preview URI for the artifact.
168
-
169
- Returns:
170
- Artifact: The created Artifact object.
171
-
172
- It computes the SHA-256 hash of the file, moves it into a content-addressed storage
173
- structure, and optionally creates a "pretty" symlink if a suggested URI is provided.
174
- """
175
- sha, nbytes = _sha256_file(path) # compute hash + size
176
- ext = os.path.splitext(path)[1]
177
- target = _content_addr_path(self.base_dir, sha, ext)
178
-
179
- if not os.path.exists(target):
180
- os.makedirs(os.path.dirname(target), exist_ok=True)
181
-
182
- src_path = os.path.abspath(path)
183
- src_parent = os.path.dirname(src_path)
184
-
185
- if cleanup:
186
- shutil.move(src_path, target)
187
- else:
188
- shutil.copy2(src_path, target)
189
- # 🔐 Only clean up if the source file lived DIRECTLY under _tmp (mkstemp case).
190
- # If it was inside a staged dir like _tmp/dir_xxx, DO NOT prune that dir here.
191
- if cleanup and os.path.normcase(os.path.abspath(src_parent)) == os.path.normcase(
192
- os.path.abspath(self._tmp_root)
193
- ):
194
- _maybe_cleanup_tmp_parent(self._tmp_root, src_path)
195
-
196
- mime, _ = mimetypes.guess_type(target)
197
- uri = _to_file_uri(target)
198
-
199
- # optional "pretty" mirror path (symlink) if suggested
200
- if suggested_uri:
201
- pretty = _normalize_pretty_path(self.base_dir, suggested_uri)
202
- os.makedirs(os.path.dirname(pretty), exist_ok=True)
203
- if not os.path.exists(pretty):
204
- try:
205
- os.symlink(target, pretty)
206
- except OSError:
207
- shutil.copy2(target, pretty)
208
-
209
- # ✅ Remove this unconditional cleanup (it could wipe staged dirs):
210
- # _maybe_cleanup_tmp_parent(self._tmp_root, path)
211
-
212
- # Ensure _tmp exists for future staging even if it was emptied
213
- os.makedirs(self._tmp_root, exist_ok=True)
214
-
215
- a = Artifact(
216
- artifact_id=sha,
217
- uri=uri,
218
- kind=kind,
219
- bytes=nbytes,
220
- sha256=sha,
221
- mime=mime,
222
- run_id=run_id,
223
- graph_id=graph_id,
224
- node_id=node_id,
225
- tool_name=tool_name,
226
- tool_version=tool_version,
227
- created_at=_now_iso(),
228
- labels=labels or {},
229
- metrics=metrics or {},
230
- preview_uri=preview_uri,
231
- pinned=pin,
232
- )
233
- self.last_artifact = a
234
- return a
235
-
236
- def save_text(self, payload: str, *, suggested_uri: str | None = None):
237
- """Save a text payload as an artifact."""
238
- staged_path = self.tmp_path(suffix=".txt")
239
- with open(staged_path, "w", encoding="utf-8") as f:
240
- f.write(payload)
241
- a = self.save_file(
242
- path=staged_path,
243
- kind="text",
244
- run_id="ad-hoc",
245
- graph_id="ad-hoc",
246
- node_id="ad-hoc",
247
- tool_name="fs_store.save_text",
248
- tool_version="0.1.0",
249
- suggested_uri=suggested_uri,
250
- cleanup=True,
251
- )
252
- return a
253
-
254
- def save_json(self, payload: str, *, suggested_uri: str | None = None):
255
- """Save a JSON payload as an artifact."""
256
- import json
257
-
258
- staged_path = self.tmp_path(suffix=".json")
259
- with open(staged_path, "w", encoding="utf-8") as f:
260
- json.dump(payload, f, indent=2)
261
- a = self.save_file(
262
- path=staged_path,
263
- kind="json",
264
- run_id="ad-hoc",
265
- graph_id="ad-hoc",
266
- node_id="ad-hoc",
267
- tool_name="fs_store.save_json",
268
- tool_version="0.1.0",
269
- suggested_uri=suggested_uri,
270
- cleanup=True,
271
- )
272
- return a
273
-
274
- @contextmanager
275
- def open_writer(
276
- self,
277
- *,
278
- kind: str,
279
- run_id: str,
280
- graph_id: str,
281
- node_id: str,
282
- tool_name: str,
283
- tool_version: str,
284
- planned_ext: str | None = None,
285
- pin: bool = False,
286
- ):
287
- """Context manager that yields a streaming ArtifactWriter."""
288
- w = _Writer(self._tmp_root, planned_ext)
289
- try:
290
- yield w
291
- w.close()
292
- sha, nbytes = _sha256_file(w.tmp_path)
293
- target = _content_addr_path(self.base_dir, sha, planned_ext)
294
- if not os.path.exists(target):
295
- shutil.move(w.tmp_path, target)
296
- _maybe_cleanup_tmp_parent(self._tmp_root, w.tmp_path)
297
- else:
298
- os.remove(w.tmp_path) # already present => dedup
299
- _maybe_cleanup_tmp_parent(self._tmp_root, w.tmp_path)
300
- mime, _ = mimetypes.guess_type(target)
301
- a = Artifact(
302
- artifact_id=sha,
303
- uri=_to_file_uri(target),
304
- kind=kind,
305
- bytes=nbytes,
306
- sha256=sha,
307
- mime=mime,
308
- run_id=run_id,
309
- graph_id=graph_id,
310
- node_id=node_id,
311
- tool_name=tool_name,
312
- tool_version=tool_version,
313
- created_at=_now_iso(),
314
- labels=w._labels,
315
- metrics=w._metrics,
316
- pinned=pin,
317
- )
318
- # stash on the writer so caller can grab it after context exits
319
- w._artifact = a
320
- self.last_artifact = a
321
- except Exception:
322
- try:
323
- w.close()
324
- if os.path.exists(w.tmp_path):
325
- os.remove(w.tmp_path)
326
- finally:
327
- raise
328
-
329
- @contextmanager
330
- def open_reader(self, uri: str):
331
- """Context manager that yields an ArtifactReader for a given URI."""
332
- path = _from_uri_or_path(uri)
333
- if os.path.isdir(path):
334
- raise IsADirectoryError(f"Expected file, got directory: {path}")
335
- # use a 'with' so the file is closed automatically even if yield is interrupted
336
- with open(path, "rb") as f:
337
- yield _Reader(str(path), f)
338
-
339
- # --------------------- advanced flow for external tools ------------------
340
- def plan_staging_path(self, planned_ext: str = "") -> str:
341
- """Return a temp path that an external tool can write to directly."""
342
- os.makedirs(self._tmp_root, exist_ok=True) # ensure _tmp exists
343
- return self.tmp_path(suffix=planned_ext)
344
-
345
- def ingest_staged_file(
346
- self,
347
- staged_path: str,
348
- *,
349
- kind: str,
350
- run_id: str,
351
- graph_id: str,
352
- node_id: str,
353
- tool_name: str,
354
- tool_version: str,
355
- pin: bool = False,
356
- labels: dict | None = None,
357
- metrics: dict | None = None,
358
- preview_uri: str | None = None,
359
- suggested_uri: str | None = None,
360
- ) -> Artifact:
361
- """Turn a staged file into a content-addressed artifact + (optional) pretty link."""
362
- return self.save_file(
363
- path=staged_path,
364
- kind=kind,
365
- run_id=run_id,
366
- graph_id=graph_id,
367
- node_id=node_id,
368
- tool_name=tool_name,
369
- tool_version=tool_version,
370
- suggested_uri=suggested_uri,
371
- pin=pin,
372
- labels=labels,
373
- metrics=metrics,
374
- preview_uri=preview_uri,
375
- )
376
-
377
- def plan_staging_dir(self, suffix: str = "") -> str:
378
- """Return an empty directory path that an external tool can write into."""
379
- # make a unique folder under _tmp
380
- d = tempfile.mkdtemp(prefix="dir_", suffix=suffix, dir=self._tmp_root)
381
- return d
382
-
383
- def ingest_directory(
384
- self,
385
- *,
386
- staged_dir: str,
387
- kind: str = "dataset",
388
- run_id: str,
389
- graph_id: str,
390
- node_id: str,
391
- tool_name: str,
392
- tool_version: str,
393
- include: list[str] | None = None,
394
- exclude: list[str] | None = None,
395
- index_children: bool = False,
396
- pin: bool = False,
397
- labels: dict | None = None,
398
- metrics: dict | None = None,
399
- suggested_uri: str | None = None,
400
- archive: bool = False,
401
- archive_name: str = "bundle.tar.gz",
402
- cleanup: bool = True,
403
- store: str
404
- | None = None, # NEW: "archive" | "copy" | "manifest"; None -> derive from 'archive'
405
- ) -> Artifact:
406
- if not os.path.isdir(staged_dir):
407
- raise ValueError(f"ingest_directory: not a directory: {staged_dir}")
408
-
409
- if store is None:
410
- store = "archive" if archive else "manifest" # previous default was manifest-only
411
-
412
- manifest_entries, tree_sha = _tree_manifest_and_hash(staged_dir, include, exclude)
413
- cas_dir = _content_addr_dir_path(self.base_dir, tree_sha)
414
- manifest_path = os.path.join(cas_dir, "manifest.json")
415
- if not os.path.exists(manifest_path):
416
- _write_json(
417
- manifest_path,
418
- {
419
- "files": manifest_entries,
420
- "created_at": _now_iso(),
421
- "tool_name": tool_name,
422
- "tool_version": tool_version,
423
- },
424
- )
425
-
426
- archive_uri = None
427
- if store == "archive":
428
- archive_path = os.path.join(cas_dir, archive_name)
429
- if not os.path.exists(archive_path):
430
- import tarfile
431
-
432
- with tarfile.open(archive_path, mode="w:gz") as tar:
433
- for e in sorted(manifest_entries, key=lambda x: x["path"]):
434
- abs_file = os.path.join(staged_dir, e["path"])
435
- tar.add(abs_file, arcname=e["path"])
436
- archive_uri = _to_file_uri(archive_path)
437
-
438
- elif store == "copy":
439
- dst_root = os.path.join(cas_dir, "tree")
440
- for e in manifest_entries:
441
- src = os.path.join(staged_dir, e["path"])
442
- dst = os.path.join(dst_root, e["path"])
443
- os.makedirs(os.path.dirname(dst), exist_ok=True)
444
- if not os.path.exists(dst):
445
- shutil.copy2(src, dst)
446
-
447
- elif store == "manifest":
448
- if cleanup:
449
- raise ValueError(
450
- "store='manifest' with cleanup=True would lose bytes; set cleanup=False or use store='archive'/'copy'."
451
- )
452
- else:
453
- raise ValueError(f"unknown store mode: {store}")
454
-
455
- # Pretty link: try to symlink the whole directory to CAS dir (best UX)
456
- if suggested_uri:
457
- pretty_dir = _normalize_pretty_path(self.base_dir, suggested_uri)
458
- parent = os.path.dirname(pretty_dir)
459
- os.makedirs(parent, exist_ok=True)
460
- try:
461
- # prefer a directory symlink if pretty path doesn't exist
462
- if not os.path.lexists(pretty_dir): # avoid overwriting
463
- os.symlink(cas_dir, pretty_dir, target_is_directory=True)
464
- except OSError:
465
- # Fallback: ensure dir exists and link/copy small files inside
466
- os.makedirs(pretty_dir, exist_ok=True)
467
- pm = os.path.join(pretty_dir, "manifest.json")
468
- if not os.path.exists(pm):
469
- try:
470
- os.symlink(manifest_path, pm)
471
- except OSError:
472
- shutil.copy2(manifest_path, pm)
473
-
474
- if store == "archive" and archive_uri:
475
- pa = os.path.join(pretty_dir, archive_name)
476
- if not os.path.exists(pa):
477
- src = archive_uri[len("file://") :]
478
- try:
479
- os.symlink(src, pa)
480
- except OSError:
481
- shutil.copy2(src, pa)
482
-
483
- elif store == "copy":
484
- # copy small files (under 1MB) for convenience
485
- for e in manifest_entries:
486
- if e["bytes"] <= 1024 * 1024:
487
- src = os.path.join(cas_dir, "tree", e["path"])
488
- dst = os.path.join(pretty_dir, e["path"])
489
- os.makedirs(os.path.dirname(dst), exist_ok=True)
490
- if not os.path.exists(dst):
491
- try:
492
- os.symlink(src, dst)
493
- except OSError:
494
- shutil.copy2(src, dst)
495
-
496
- total_bytes = sum(e["bytes"] for e in manifest_entries)
497
- a = Artifact(
498
- artifact_id=tree_sha,
499
- uri=_to_file_uri(cas_dir),
500
- kind=kind,
501
- bytes=total_bytes,
502
- sha256=tree_sha,
503
- mime="application/vnd.aethergraph.bundle+dir",
504
- run_id=run_id,
505
- graph_id=graph_id,
506
- node_id=node_id,
507
- tool_name=tool_name,
508
- tool_version=tool_version,
509
- created_at=_now_iso(),
510
- labels=labels or {},
511
- metrics=metrics or {},
512
- preview_uri=archive_uri,
513
- pinned=pin,
514
- )
515
-
516
- self.last_artifact = a
517
-
518
- if cleanup and store in ("archive", "copy"):
519
- try:
520
- shutil.rmtree(staged_dir, ignore_errors=True)
521
- except Exception:
522
- logger = logging.getLogger("aethergraph.services.artifacts.fs_store")
523
- logger.warning(f"ingest_directory: failed to cleanup staged dir: {staged_dir}")
524
-
525
- return a
526
-
527
- def cleanup_tmp(self, max_age_hours: int = 24):
528
- now = datetime.now(datetime.timezone.utc).timestamp()
529
- for p in Path(self._tmp_root).rglob("*"):
530
- try:
531
- age_h = (now - p.stat().st_mtime) / 3600.0
532
- if age_h > max_age_hours:
533
- if p.is_file():
534
- p.unlink(missing_ok=True)
535
- else:
536
- shutil.rmtree(p, ignore_errors=True)
537
- except Exception:
538
- pass
539
-
540
- def load_bytes(self, uri: str) -> bytes:
541
- path = _from_uri_or_path(uri)
542
- if os.path.isdir(path):
543
- raise IsADirectoryError(f"Expected file, got directory: {path}")
544
- with open(path, "rb") as f:
545
- return f.read()
546
-
547
- def load_text(
548
- self,
549
- uri: str,
550
- *,
551
- encoding: str = "utf-8",
552
- errors: str = "strict",
553
- ) -> str:
554
- data = self.load_bytes(uri)
555
- return data.decode(encoding, errors)
556
-
557
- def load_json(
558
- self,
559
- uri: str,
560
- *,
561
- encoding: str = "utf-8",
562
- errors: str = "strict",
563
- ) -> Any:
564
- text = self.load_text(uri, encoding=encoding, errors=errors)
565
- return json.loads(text)
566
-
567
- def load_artifact(self, uri: str) -> str | bytes:
568
- """Load an artifact by URI.
569
-
570
- - If it's a directory, return the directory path as a string.
571
- - If it's a file, return the file contents as bytes.
572
- """
573
- path = _from_uri_or_path(uri)
574
- if os.path.isdir(path):
575
- return path
576
- with open(path, "rb") as f:
577
- return f.read()
578
-
579
- def load_artifact_bytes(self, uri: str) -> bytes:
580
- """Load a file artifact and return its bytes.
581
-
582
- Raises:
583
- IsADirectoryError: if the URI points to a directory.
584
- """
585
- path = _from_uri_or_path(uri)
586
- if os.path.isdir(path):
587
- raise IsADirectoryError(f"Expected file, got directory: {path}")
588
- with open(path, "rb") as f:
589
- return f.read()
590
-
591
- def load_artifact_dir(self, uri: str) -> str:
592
- """Return the path when the artifact is a directory."""
593
- path = _from_uri_or_path(uri)
594
- if not os.path.isdir(path):
595
- raise NotADirectoryError(f"Expected directory, got file: {path}")
596
- return path
597
-
598
-
599
- class FSArtifactStore: # implements AsyncArtifactStore
600
- def __init__(self, base_dir: str):
601
- self._sync = FileArtifactStoreSync(base_dir)
602
-
603
- @property
604
- def base_uri(self) -> str:
605
- return self._sync.base_uri
606
-
607
- def tmp_path(self, suffix: str = "") -> str:
608
- return self._sync.tmp_path(suffix=suffix)
609
-
610
- async def save_file(self, **kw) -> Any:
611
- return await to_thread(self._sync.save_file, **kw)
612
-
613
- async def save_text(self, **kw) -> Any:
614
- return await to_thread(self._sync.save_text, **kw)
615
-
616
- async def save_json(self, **kw) -> Any:
617
- return await to_thread(self._sync.save_json, **kw)
618
-
619
- async def open_writer(self, **kw):
620
- # Wrap the sync contextmanager so 'with' usage in Facade stays the same.
621
- # Return the sync contextmanager directly; user code runs inside with-block
622
- # but all disk ops inside are already sync and cheap; or expose an async CM.
623
- return self._sync.open_writer(**kw)
624
-
625
- async def plan_staging_path(self, planned_ext: str = "") -> str:
626
- return await to_thread(self._sync.plan_staging_path, planned_ext)
627
-
628
- async def ingest_staged_file(self, **kw) -> Any:
629
- return await to_thread(self._sync.ingest_staged_file, **kw)
630
-
631
- async def plan_staging_dir(self, suffix: str = "") -> str:
632
- return await to_thread(self._sync.plan_staging_dir, suffix)
633
-
634
- async def ingest_directory(self, **kw) -> Any:
635
- return await to_thread(self._sync.ingest_directory, **kw)
636
-
637
- async def load_bytes(self, uri: str) -> bytes:
638
- return await to_thread(self._sync.load_bytes, uri)
639
-
640
- async def load_text(self, uri: str, *, encoding: str = "utf-8", errors: str = "strict") -> str:
641
- return await to_thread(self._sync.load_text, uri, encoding=encoding, errors=errors)
642
-
643
- async def load_json(self, uri: str, *, encoding: str = "utf-8", errors: str = "strict") -> Any:
644
- return await to_thread(self._sync.load_json, uri, encoding=encoding, errors=errors)
645
-
646
- async def load_artifact(self, uri: str):
647
- return await to_thread(self._sync.load_artifact, uri)
648
-
649
- async def load_artifact_bytes(self, uri: str) -> bytes:
650
- return await to_thread(self._sync.load_artifact_bytes, uri)
651
-
652
- async def load_artifact_dir(self, uri: str) -> str:
653
- return await to_thread(self._sync.load_artifact_dir, uri)
654
-
655
- async def cleanup_tmp(self, max_age_hours: int = 24):
656
- return await to_thread(self._sync.cleanup_tmp, max_age_hours)