aethergraph 0.1.0a1__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 (182) hide show
  1. aethergraph/__init__.py +49 -0
  2. aethergraph/config/__init__.py +0 -0
  3. aethergraph/config/config.py +121 -0
  4. aethergraph/config/context.py +16 -0
  5. aethergraph/config/llm.py +26 -0
  6. aethergraph/config/loader.py +60 -0
  7. aethergraph/config/runtime.py +9 -0
  8. aethergraph/contracts/errors/errors.py +44 -0
  9. aethergraph/contracts/services/artifacts.py +142 -0
  10. aethergraph/contracts/services/channel.py +72 -0
  11. aethergraph/contracts/services/continuations.py +23 -0
  12. aethergraph/contracts/services/eventbus.py +12 -0
  13. aethergraph/contracts/services/kv.py +24 -0
  14. aethergraph/contracts/services/llm.py +17 -0
  15. aethergraph/contracts/services/mcp.py +22 -0
  16. aethergraph/contracts/services/memory.py +108 -0
  17. aethergraph/contracts/services/resume.py +28 -0
  18. aethergraph/contracts/services/state_stores.py +33 -0
  19. aethergraph/contracts/services/wakeup.py +28 -0
  20. aethergraph/core/execution/base_scheduler.py +77 -0
  21. aethergraph/core/execution/forward_scheduler.py +777 -0
  22. aethergraph/core/execution/global_scheduler.py +634 -0
  23. aethergraph/core/execution/retry_policy.py +22 -0
  24. aethergraph/core/execution/step_forward.py +411 -0
  25. aethergraph/core/execution/step_result.py +18 -0
  26. aethergraph/core/execution/wait_types.py +72 -0
  27. aethergraph/core/graph/graph_builder.py +192 -0
  28. aethergraph/core/graph/graph_fn.py +219 -0
  29. aethergraph/core/graph/graph_io.py +67 -0
  30. aethergraph/core/graph/graph_refs.py +154 -0
  31. aethergraph/core/graph/graph_spec.py +115 -0
  32. aethergraph/core/graph/graph_state.py +59 -0
  33. aethergraph/core/graph/graphify.py +128 -0
  34. aethergraph/core/graph/interpreter.py +145 -0
  35. aethergraph/core/graph/node_handle.py +33 -0
  36. aethergraph/core/graph/node_spec.py +46 -0
  37. aethergraph/core/graph/node_state.py +63 -0
  38. aethergraph/core/graph/task_graph.py +747 -0
  39. aethergraph/core/graph/task_node.py +82 -0
  40. aethergraph/core/graph/utils.py +37 -0
  41. aethergraph/core/graph/visualize.py +239 -0
  42. aethergraph/core/runtime/ad_hoc_context.py +61 -0
  43. aethergraph/core/runtime/base_service.py +153 -0
  44. aethergraph/core/runtime/bind_adapter.py +42 -0
  45. aethergraph/core/runtime/bound_memory.py +69 -0
  46. aethergraph/core/runtime/execution_context.py +220 -0
  47. aethergraph/core/runtime/graph_runner.py +349 -0
  48. aethergraph/core/runtime/lifecycle.py +26 -0
  49. aethergraph/core/runtime/node_context.py +203 -0
  50. aethergraph/core/runtime/node_services.py +30 -0
  51. aethergraph/core/runtime/recovery.py +159 -0
  52. aethergraph/core/runtime/run_registration.py +33 -0
  53. aethergraph/core/runtime/runtime_env.py +157 -0
  54. aethergraph/core/runtime/runtime_registry.py +32 -0
  55. aethergraph/core/runtime/runtime_services.py +224 -0
  56. aethergraph/core/runtime/wakeup_watcher.py +40 -0
  57. aethergraph/core/tools/__init__.py +10 -0
  58. aethergraph/core/tools/builtins/channel_tools.py +194 -0
  59. aethergraph/core/tools/builtins/toolset.py +134 -0
  60. aethergraph/core/tools/toolkit.py +510 -0
  61. aethergraph/core/tools/waitable.py +109 -0
  62. aethergraph/plugins/channel/__init__.py +0 -0
  63. aethergraph/plugins/channel/adapters/__init__.py +0 -0
  64. aethergraph/plugins/channel/adapters/console.py +106 -0
  65. aethergraph/plugins/channel/adapters/file.py +102 -0
  66. aethergraph/plugins/channel/adapters/slack.py +285 -0
  67. aethergraph/plugins/channel/adapters/telegram.py +302 -0
  68. aethergraph/plugins/channel/adapters/webhook.py +104 -0
  69. aethergraph/plugins/channel/adapters/webui.py +134 -0
  70. aethergraph/plugins/channel/routes/__init__.py +0 -0
  71. aethergraph/plugins/channel/routes/console_routes.py +86 -0
  72. aethergraph/plugins/channel/routes/slack_routes.py +49 -0
  73. aethergraph/plugins/channel/routes/telegram_routes.py +26 -0
  74. aethergraph/plugins/channel/routes/webui_routes.py +136 -0
  75. aethergraph/plugins/channel/utils/__init__.py +0 -0
  76. aethergraph/plugins/channel/utils/slack_utils.py +278 -0
  77. aethergraph/plugins/channel/utils/telegram_utils.py +324 -0
  78. aethergraph/plugins/channel/websockets/slack_ws.py +68 -0
  79. aethergraph/plugins/channel/websockets/telegram_polling.py +151 -0
  80. aethergraph/plugins/mcp/fs_server.py +128 -0
  81. aethergraph/plugins/mcp/http_server.py +101 -0
  82. aethergraph/plugins/mcp/ws_server.py +180 -0
  83. aethergraph/plugins/net/http.py +10 -0
  84. aethergraph/plugins/utils/data_io.py +359 -0
  85. aethergraph/runner/__init__.py +5 -0
  86. aethergraph/runtime/__init__.py +62 -0
  87. aethergraph/server/__init__.py +3 -0
  88. aethergraph/server/app_factory.py +84 -0
  89. aethergraph/server/start.py +122 -0
  90. aethergraph/services/__init__.py +10 -0
  91. aethergraph/services/artifacts/facade.py +284 -0
  92. aethergraph/services/artifacts/factory.py +35 -0
  93. aethergraph/services/artifacts/fs_store.py +656 -0
  94. aethergraph/services/artifacts/jsonl_index.py +123 -0
  95. aethergraph/services/artifacts/paths.py +23 -0
  96. aethergraph/services/artifacts/sqlite_index.py +209 -0
  97. aethergraph/services/artifacts/utils.py +124 -0
  98. aethergraph/services/auth/dev.py +16 -0
  99. aethergraph/services/channel/channel_bus.py +293 -0
  100. aethergraph/services/channel/factory.py +44 -0
  101. aethergraph/services/channel/session.py +511 -0
  102. aethergraph/services/channel/wait_helpers.py +57 -0
  103. aethergraph/services/clock/clock.py +9 -0
  104. aethergraph/services/container/default_container.py +320 -0
  105. aethergraph/services/continuations/continuation.py +56 -0
  106. aethergraph/services/continuations/factory.py +34 -0
  107. aethergraph/services/continuations/stores/fs_store.py +264 -0
  108. aethergraph/services/continuations/stores/inmem_store.py +95 -0
  109. aethergraph/services/eventbus/inmem.py +21 -0
  110. aethergraph/services/features/static.py +10 -0
  111. aethergraph/services/kv/ephemeral.py +90 -0
  112. aethergraph/services/kv/factory.py +27 -0
  113. aethergraph/services/kv/layered.py +41 -0
  114. aethergraph/services/kv/sqlite_kv.py +128 -0
  115. aethergraph/services/llm/factory.py +157 -0
  116. aethergraph/services/llm/generic_client.py +542 -0
  117. aethergraph/services/llm/providers.py +3 -0
  118. aethergraph/services/llm/service.py +105 -0
  119. aethergraph/services/logger/base.py +36 -0
  120. aethergraph/services/logger/compat.py +50 -0
  121. aethergraph/services/logger/formatters.py +106 -0
  122. aethergraph/services/logger/std.py +203 -0
  123. aethergraph/services/mcp/helpers.py +23 -0
  124. aethergraph/services/mcp/http_client.py +70 -0
  125. aethergraph/services/mcp/mcp_tools.py +21 -0
  126. aethergraph/services/mcp/registry.py +14 -0
  127. aethergraph/services/mcp/service.py +100 -0
  128. aethergraph/services/mcp/stdio_client.py +70 -0
  129. aethergraph/services/mcp/ws_client.py +115 -0
  130. aethergraph/services/memory/bound.py +106 -0
  131. aethergraph/services/memory/distillers/episode.py +116 -0
  132. aethergraph/services/memory/distillers/rolling.py +74 -0
  133. aethergraph/services/memory/facade.py +633 -0
  134. aethergraph/services/memory/factory.py +78 -0
  135. aethergraph/services/memory/hotlog_kv.py +27 -0
  136. aethergraph/services/memory/indices.py +74 -0
  137. aethergraph/services/memory/io_helpers.py +72 -0
  138. aethergraph/services/memory/persist_fs.py +40 -0
  139. aethergraph/services/memory/resolver.py +152 -0
  140. aethergraph/services/metering/noop.py +4 -0
  141. aethergraph/services/prompts/file_store.py +41 -0
  142. aethergraph/services/rag/chunker.py +29 -0
  143. aethergraph/services/rag/facade.py +593 -0
  144. aethergraph/services/rag/index/base.py +27 -0
  145. aethergraph/services/rag/index/faiss_index.py +121 -0
  146. aethergraph/services/rag/index/sqlite_index.py +134 -0
  147. aethergraph/services/rag/index_factory.py +52 -0
  148. aethergraph/services/rag/parsers/md.py +7 -0
  149. aethergraph/services/rag/parsers/pdf.py +14 -0
  150. aethergraph/services/rag/parsers/txt.py +7 -0
  151. aethergraph/services/rag/utils/hybrid.py +39 -0
  152. aethergraph/services/rag/utils/make_fs_key.py +62 -0
  153. aethergraph/services/redactor/simple.py +16 -0
  154. aethergraph/services/registry/key_parsing.py +44 -0
  155. aethergraph/services/registry/registry_key.py +19 -0
  156. aethergraph/services/registry/unified_registry.py +185 -0
  157. aethergraph/services/resume/multi_scheduler_resume_bus.py +65 -0
  158. aethergraph/services/resume/router.py +73 -0
  159. aethergraph/services/schedulers/registry.py +41 -0
  160. aethergraph/services/secrets/base.py +7 -0
  161. aethergraph/services/secrets/env.py +8 -0
  162. aethergraph/services/state_stores/externalize.py +135 -0
  163. aethergraph/services/state_stores/graph_observer.py +131 -0
  164. aethergraph/services/state_stores/json_store.py +67 -0
  165. aethergraph/services/state_stores/resume_policy.py +119 -0
  166. aethergraph/services/state_stores/serialize.py +249 -0
  167. aethergraph/services/state_stores/utils.py +91 -0
  168. aethergraph/services/state_stores/validate.py +78 -0
  169. aethergraph/services/tracing/noop.py +18 -0
  170. aethergraph/services/waits/wait_registry.py +91 -0
  171. aethergraph/services/wakeup/memory_queue.py +57 -0
  172. aethergraph/services/wakeup/scanner_producer.py +56 -0
  173. aethergraph/services/wakeup/worker.py +31 -0
  174. aethergraph/tools/__init__.py +25 -0
  175. aethergraph/utils/optdeps.py +8 -0
  176. aethergraph-0.1.0a1.dist-info/METADATA +410 -0
  177. aethergraph-0.1.0a1.dist-info/RECORD +182 -0
  178. aethergraph-0.1.0a1.dist-info/WHEEL +5 -0
  179. aethergraph-0.1.0a1.dist-info/entry_points.txt +2 -0
  180. aethergraph-0.1.0a1.dist-info/licenses/LICENSE +176 -0
  181. aethergraph-0.1.0a1.dist-info/licenses/NOTICE +31 -0
  182. aethergraph-0.1.0a1.dist-info/top_level.txt +1 -0
@@ -0,0 +1,62 @@
1
+ # redirect runtime service imports for clean imports
2
+
3
+ from aethergraph.core.runtime.ad_hoc_context import open_session
4
+ from aethergraph.core.runtime.runtime_services import (
5
+ # logger service helpers
6
+ current_logger_factory,
7
+ current_services,
8
+ ensure_services_installed,
9
+ # channel service helpers
10
+ get_channel_service,
11
+ get_default_channel,
12
+ get_ext_context_service,
13
+ # llm service helpers
14
+ get_llm_service,
15
+ get_mcp_service,
16
+ # general service management
17
+ install_services,
18
+ list_ext_context_services,
19
+ list_mcp_clients,
20
+ register_channel_adapter,
21
+ # external context service helpers
22
+ register_context_service,
23
+ register_llm_client,
24
+ register_mcp_client,
25
+ set_channel_alias,
26
+ set_default_channel,
27
+ # mcp service helpers
28
+ set_mcp_service,
29
+ set_rag_index_backend,
30
+ set_rag_llm_client,
31
+ )
32
+
33
+ __all__ = [
34
+ # general service management
35
+ "install_services",
36
+ "ensure_services_installed",
37
+ "current_services",
38
+ # channel service helpers
39
+ "get_channel_service",
40
+ "set_default_channel",
41
+ "get_default_channel",
42
+ "set_channel_alias",
43
+ "register_channel_adapter",
44
+ # llm service helpers
45
+ "get_llm_service",
46
+ "register_llm_client",
47
+ "set_rag_llm_client",
48
+ "set_rag_index_backend",
49
+ # logger service helpers
50
+ "current_logger_factory",
51
+ # external context service helpers
52
+ "register_context_service",
53
+ "get_ext_context_service",
54
+ "list_ext_context_services",
55
+ # mcp service helpers
56
+ "set_mcp_service",
57
+ "get_mcp_service",
58
+ "register_mcp_client",
59
+ "list_mcp_clients",
60
+ # ad-hoc context
61
+ "open_session",
62
+ ]
@@ -0,0 +1,3 @@
1
+ from .start import start_server, start_server_async, stop_server
2
+
3
+ __all__ = ["start_server", "start_server_async", "stop_server"]
@@ -0,0 +1,84 @@
1
+ import asyncio
2
+ from typing import Optional
3
+
4
+ from fastapi import FastAPI
5
+ from fastapi.middleware.cors import CORSMiddleware
6
+
7
+ from aethergraph.config.config import AppSettings
8
+ from aethergraph.utils.optdeps import require
9
+
10
+ from ..core.runtime.runtime_services import install_services
11
+
12
+ # channel routes
13
+ from ..services.container.default_container import build_default_container
14
+
15
+
16
+ def create_app(
17
+ *,
18
+ workspace: str = "./aethergraph_data",
19
+ cfg: Optional["AppSettings"] = None,
20
+ log_level: str = "info",
21
+ ) -> FastAPI:
22
+ """
23
+ Builds the FastAPI app, registers routers, and installs all services
24
+ into app.state.container (and globally via install_services()).
25
+ """
26
+ app = FastAPI(title="AetherGraph Sidecar", version="0.1")
27
+
28
+ app.add_middleware(
29
+ CORSMiddleware,
30
+ allow_origins=["http://localhost:5173"], # dev UI origin
31
+ allow_credentials=True,
32
+ allow_methods=["*"],
33
+ allow_headers=["*"],
34
+ )
35
+
36
+ # Resolve settings early, so we can conditionally include routers
37
+ settings = cfg or AppSettings()
38
+ app.state.settings = settings
39
+
40
+ # --- Routers (HTTP transports) ---
41
+ # For now, we can just always include; or gate it with a flag like settings.slack.use_webhook.
42
+ # app.include_router(slack_router) # HTTP /slack/events + /slack/interact
43
+ # app.include_router(console_router)
44
+ # app.include_router(telegram_router)
45
+ # app.include_router(webui_router)
46
+
47
+ # override log level in config
48
+ settings.logging.level = log_level
49
+
50
+ # ---- Services container ----
51
+ container = build_default_container(root=workspace, cfg=settings)
52
+ app.state.container = container
53
+
54
+ # install globally so run()/tools see the same services
55
+ install_services(container)
56
+
57
+ # ---- External channel transports (Socket Mode, polling, etc.) ----
58
+ @app.on_event("startup")
59
+ async def start_external_transports():
60
+ slack_cfg = settings.slack
61
+ if (
62
+ slack_cfg
63
+ and slack_cfg.enabled
64
+ and slack_cfg.socket_mode_enabled
65
+ and slack_cfg.bot_token
66
+ and slack_cfg.app_token
67
+ ):
68
+ require("slack_sdk", "slack")
69
+ from ..plugins.channel.websockets.slack_ws import SlackSocketModeRunner
70
+
71
+ runner = SlackSocketModeRunner(container=container, settings=settings)
72
+ app.state.slack_socket_runner = runner
73
+ asyncio.create_task(runner.start())
74
+
75
+ # Telegram polling for local / dev
76
+ tg_cfg = settings.telegram
77
+ if tg_cfg and tg_cfg.enabled and tg_cfg.polling_enabled and tg_cfg.bot_token:
78
+ from ..plugins.channel.websockets.telegram_polling import TelegramPollingRunner
79
+
80
+ tg_runner = TelegramPollingRunner(container=container, settings=settings)
81
+ app.state.telegram_polling_runner = tg_runner
82
+ asyncio.create_task(tg_runner.start())
83
+
84
+ return app
@@ -0,0 +1,122 @@
1
+ # aethergraph/start.py
2
+ from __future__ import annotations
3
+
4
+ import asyncio
5
+ import contextlib
6
+ import socket
7
+ import threading
8
+
9
+ import uvicorn
10
+
11
+ from aethergraph.config.context import set_current_settings
12
+ from aethergraph.config.loader import load_settings
13
+
14
+ from ..plugins.channel.routes.webui_routes import install_web_channel
15
+ from .app_factory import create_app
16
+
17
+ _started = False
18
+ _server_thread: threading.Thread | None = None
19
+ _shutdown_flag = threading.Event()
20
+ _url: str | None = None
21
+
22
+
23
+ def _pick_free_port(p: int) -> int:
24
+ if p:
25
+ return p
26
+ s = socket.socket()
27
+ s.bind(("", 0))
28
+ port = s.getsockname()[1]
29
+ s.close()
30
+ return port
31
+
32
+
33
+ def _run_uvicorn_in_thread(app, host: str, port: int, log_level: str):
34
+ loop = asyncio.new_event_loop()
35
+ asyncio.set_event_loop(loop)
36
+ server = uvicorn.Server(
37
+ uvicorn.Config(app, host=host, port=port, log_level=log_level, loop="asyncio")
38
+ )
39
+
40
+ async def runner():
41
+ task = asyncio.create_task(server.serve())
42
+ while not _shutdown_flag.is_set():
43
+ await asyncio.sleep(0.2)
44
+ if not server.should_exit:
45
+ server.should_exit = True
46
+ await task
47
+
48
+ try:
49
+ loop.run_until_complete(runner())
50
+ finally:
51
+ loop.stop()
52
+ loop.close()
53
+
54
+
55
+ def start_server(
56
+ *,
57
+ workspace: str = "./aethergraph_data",
58
+ host: str = "127.0.0.1",
59
+ port: int = 8000, # 0 = auto free port
60
+ log_level: str = "warning",
61
+ unvicorn_log_level: str = "warning",
62
+ return_container: bool = False,
63
+ ) -> str:
64
+ """
65
+ Start the AetherGraph sidecar server in a background thread and install
66
+ services using the given workspace. Safe to call at top of any script
67
+ or notebook cell (no main() wrapper needed). Returns base URL.
68
+ """
69
+ global _started, _server_thread, _url
70
+ if _started:
71
+ return _url # type: ignore
72
+
73
+ # Build app (installs services inside create_app)
74
+ cfg = load_settings()
75
+ set_current_settings(cfg)
76
+
77
+ app = create_app(workspace=workspace, cfg=cfg, log_level=log_level)
78
+
79
+ picked_port = _pick_free_port(port)
80
+ t = threading.Thread(
81
+ target=_run_uvicorn_in_thread,
82
+ args=(app, host, picked_port, unvicorn_log_level),
83
+ name="aethergraph-sidecar",
84
+ daemon=True,
85
+ )
86
+ t.start()
87
+
88
+ _server_thread = t
89
+ _started = True
90
+ _url = f"http://{host}:{picked_port}"
91
+
92
+ install_web_channel(app)
93
+
94
+ if return_container:
95
+ return _url, app.state.container
96
+ return _url
97
+
98
+
99
+ async def start_server_async(**kw) -> str:
100
+ # Async-friendly wrapper; still uses a thread to avoid clashing with caller loop
101
+ return start_server(**kw)
102
+
103
+
104
+ def stop_server():
105
+ """Optional: stop the background server (useful in tests)."""
106
+ global _started, _server_thread, _url
107
+ if not _started:
108
+ return
109
+ _shutdown_flag.set()
110
+ if _server_thread and _server_thread.is_alive():
111
+ with contextlib.suppress(Exception):
112
+ _server_thread.join(timeout=5)
113
+ _started = False
114
+ _server_thread = None
115
+ _url = None
116
+ _shutdown_flag.clear()
117
+
118
+
119
+ # backward compatibility
120
+ start = start_server
121
+ stop = stop_server
122
+ start_async = start_server_async
@@ -0,0 +1,10 @@
1
+ # redirect runtime.Service imports for clean imports
2
+ from aethergraph.core.runtime.base_service import Service
3
+ from aethergraph.services.mcp.http_client import HttpMCPClient
4
+
5
+ # import mcp-related services
6
+ from aethergraph.services.mcp.service import MCPService
7
+ from aethergraph.services.mcp.stdio_client import StdioMCPClient
8
+ from aethergraph.services.mcp.ws_client import WsMCPClient
9
+
10
+ __all__ = ["HttpMCPClient", "MCPService", "Service", "StdioMCPClient", "WsMCPClient"]
@@ -0,0 +1,284 @@
1
+ from __future__ import annotations
2
+
3
+ import builtins
4
+ from contextlib import asynccontextmanager
5
+ from pathlib import Path
6
+ from typing import Any, Literal
7
+ from urllib.parse import urlparse
8
+
9
+ from aethergraph.contracts.services.artifacts import (
10
+ Artifact,
11
+ AsyncArtifactIndex,
12
+ AsyncArtifactStore,
13
+ )
14
+
15
+ from .paths import _from_uri_or_path
16
+
17
+ Scope = Literal["node", "run", "graph", "all"]
18
+
19
+
20
+ class ArtifactFacade:
21
+ """Facade for artifact storage and indexing operations within a specific context.
22
+ Provides async methods to stage, ingest, save, and write artifacts with automatic indexing.
23
+ """
24
+
25
+ def __init__(
26
+ self,
27
+ *,
28
+ run_id: str,
29
+ graph_id: str,
30
+ node_id: str,
31
+ tool_name: str,
32
+ tool_version: str,
33
+ store: AsyncArtifactStore,
34
+ index: AsyncArtifactIndex,
35
+ ):
36
+ self.run_id, self.graph_id, self.node_id = run_id, graph_id, node_id
37
+ self.tool_name, self.tool_version = tool_name, tool_version
38
+ self.store, self.index = store, index
39
+ self.last_artifact: Artifact | None = None
40
+
41
+ async def stage(self, ext: str = "") -> str:
42
+ return await self.store.plan_staging_path(ext)
43
+
44
+ async def ingest(
45
+ self,
46
+ staged_path: str,
47
+ *,
48
+ kind: str,
49
+ labels=None,
50
+ metrics=None,
51
+ suggested_uri: str | None = None,
52
+ pin: bool = False,
53
+ ):
54
+ a = await self.store.ingest_staged_file(
55
+ staged_path=staged_path,
56
+ kind=kind,
57
+ run_id=self.run_id,
58
+ graph_id=self.graph_id,
59
+ node_id=self.node_id,
60
+ tool_name=self.tool_name,
61
+ tool_version=self.tool_version,
62
+ labels=labels,
63
+ metrics=metrics,
64
+ suggested_uri=suggested_uri,
65
+ pin=pin,
66
+ )
67
+ await self.index.upsert(a)
68
+ await self.index.record_occurrence(a)
69
+ return a
70
+
71
+ async def save(
72
+ self,
73
+ path: str,
74
+ *,
75
+ kind: str,
76
+ labels=None,
77
+ metrics=None,
78
+ suggested_uri: str | None = None,
79
+ pin: bool = False,
80
+ ):
81
+ a = await self.store.save_file(
82
+ path=path,
83
+ kind=kind,
84
+ run_id=self.run_id,
85
+ graph_id=self.graph_id,
86
+ node_id=self.node_id,
87
+ tool_name=self.tool_name,
88
+ tool_version=self.tool_version,
89
+ labels=labels,
90
+ metrics=metrics,
91
+ suggested_uri=suggested_uri,
92
+ pin=pin,
93
+ )
94
+ await self.index.upsert(a)
95
+ await self.index.record_occurrence(a)
96
+ self.last_artifact = a
97
+ return a
98
+
99
+ async def save_text(self, payload: str, *, suggested_uri: str | None = None):
100
+ a = await self.store.save_text(payload=payload, suggested_uri=suggested_uri)
101
+ await self.index.upsert(a)
102
+ await self.index.record_occurrence(a)
103
+ self.last_artifact = a
104
+ return a
105
+
106
+ async def save_json(self, payload: dict, *, suggested_uri: str | None = None):
107
+ a = await self.store.save_json(payload=payload, suggested_uri=suggested_uri)
108
+ await self.index.upsert(a)
109
+ await self.index.record_occurrence(a)
110
+ self.last_artifact = a
111
+ return a
112
+
113
+ @asynccontextmanager
114
+ async def writer(self, *, kind: str, planned_ext: str | None = None, pin: bool = False):
115
+ # Use the store's (sync) contextmanager via async wrapper; user writes bytes
116
+ cm = await self.store.open_writer(
117
+ kind=kind,
118
+ run_id=self.run_id,
119
+ graph_id=self.graph_id,
120
+ node_id=self.node_id,
121
+ tool_name=self.tool_name,
122
+ tool_version=self.tool_version,
123
+ planned_ext=planned_ext,
124
+ pin=pin,
125
+ )
126
+ with cm as w:
127
+ yield w
128
+ a = getattr(w, "_artifact", None)
129
+ if a:
130
+ await self.index.upsert(a)
131
+ await self.index.record_occurrence(a)
132
+ self.last_artifact = a
133
+ else:
134
+ self.last_artifact = None
135
+
136
+ async def stage_dir(self, suffix: str = "") -> str:
137
+ return await self.store.plan_staging_dir(suffix)
138
+
139
+ async def ingest_dir(self, staged_dir: str, **kw):
140
+ a = await self.store.ingest_directory(
141
+ staged_dir=staged_dir,
142
+ run_id=self.run_id,
143
+ graph_id=self.graph_id,
144
+ node_id=self.node_id,
145
+ tool_name=self.tool_name,
146
+ tool_version=self.tool_version,
147
+ **kw,
148
+ )
149
+ await self.index.upsert(a)
150
+ await self.index.record_occurrence(a)
151
+ self.last_artifact = a
152
+ return a
153
+
154
+ async def tmp_path(self, suffix: str = "") -> str:
155
+ return await self.store.plan_staging_path(suffix)
156
+
157
+ async def load_bytes(self, uri: str) -> bytes:
158
+ return await self.store.load_bytes(uri)
159
+
160
+ async def load_text(self, uri: str, *, encoding: str = "utf-8", errors: str = "strict") -> str:
161
+ data = await self.store.load_text(uri)
162
+ return data
163
+
164
+ async def load_json(self, uri: str, *, encoding: str = "utf-8", errors: str = "strict") -> Any:
165
+ data = await self.store.load_json(uri, encoding=encoding, errors=errors)
166
+ return data
167
+
168
+ async def load_artifact(self, uri: str) -> Any:
169
+ return await self.store.load_artifact(uri)
170
+
171
+ async def load_artifact_bytes(self, uri: str) -> bytes:
172
+ return await self.store.load_artifact_bytes(uri)
173
+
174
+ # ------- indexing pass-throughs with scoping -------
175
+ async def list(self, *, scope: Scope = "run") -> builtins.list[Artifact]:
176
+ """
177
+ Quick listing scoped to current run/graph/node by default.
178
+ scope:
179
+ - "node": filter by (run_id, graph_id, node_id)
180
+ - "graph": filter by (run_id, graph_id)
181
+ - "run": filter by (run_id) [default]
182
+ - "all": no implicit filters (dangerous; use sparingly)
183
+ """
184
+ if scope == "node":
185
+ arts = await self.index.search(
186
+ labels={"graph_id": self.graph_id, "node_id": self.node_id}
187
+ )
188
+ return [a for a in arts if a.run_id == self.run_id]
189
+ if scope == "graph":
190
+ arts = await self.index.search(labels={"graph_id": self.graph_id})
191
+ return [a for a in arts if a.run_id == self.run_id]
192
+ if scope == "run":
193
+ return await self.index.list_for_run(self.run_id)
194
+ if scope == "all":
195
+ return await self.index.search()
196
+ return await self.index.search(labels=self._scope_labels(scope))
197
+
198
+ async def search(
199
+ self,
200
+ *,
201
+ kind: str | None = None,
202
+ labels: dict[str, Any] | None = None,
203
+ metric: str | None = None,
204
+ mode: Literal["max", "min"] | None = None,
205
+ scope: Scope = "run",
206
+ extra_scope_labels: dict[str, Any] | None = None,
207
+ ) -> builtins.list[Artifact]:
208
+ """Pass-through search with automatic scoping."""
209
+ eff_labels = dict(labels or {})
210
+ if scope in ("node", "graph", "project"):
211
+ eff_labels.update(self._scope_labels(scope))
212
+ if extra_scope_labels:
213
+ eff_labels.update(extra_scope_labels)
214
+ # Delegate heavy lifting to the index
215
+ return await self.index.search(kind=kind, labels=eff_labels, metric=metric, mode=mode)
216
+
217
+ async def best(
218
+ self,
219
+ *,
220
+ kind: str,
221
+ metric: str,
222
+ mode: Literal["max", "min"],
223
+ scope: Scope = "run",
224
+ filters: dict[str, Any] | None = None,
225
+ ) -> Artifact | None:
226
+ eff_filters = dict(filters or {})
227
+ if scope in ("node", "graph", "project"):
228
+ eff_filters.update(self._scope_labels(scope))
229
+ return await self.index.best(
230
+ kind=kind, metric=metric, mode=mode, filters=eff_filters or None
231
+ )
232
+
233
+ async def pin(self, artifact_id: str, pinned: bool = True) -> None:
234
+ await self.index.pin(artifact_id, pinned)
235
+
236
+ # -------- internal helpers --------
237
+ def _scope_labels(self, scope: Scope) -> dict[str, Any]:
238
+ if scope == "node":
239
+ return {"run_id": self.run_id, "graph_id": self.graph_id, "node_id": self.node_id}
240
+ if scope == "graph":
241
+ return {"run_id": self.run_id, "graph_id": self.graph_id}
242
+ if scope == "run":
243
+ return {"run_id": self.run_id}
244
+ return {} # "all"
245
+
246
+ def _project_id(self) -> str | None:
247
+ # This function is no longer used, but kept for possible future use.
248
+ return getattr(self, "project_id", None)
249
+
250
+ # ---------- convenience: URI -> local path (FS only) ----------
251
+ def to_local_path(self, uri_or_path: str | Path | Artifact, *, must_exist: bool = True) -> str:
252
+ """
253
+ Return an absolute native path string if input is a file:// URI or local path.
254
+ If given an Artifact, uses artifact.uri.
255
+ If the scheme is not file://, returns the string form unchanged (or raise in strict mode).
256
+ """
257
+ s = uri_or_path.uri or "" if isinstance(uri_or_path, Artifact) else str(uri_or_path)
258
+
259
+ p = _from_uri_or_path(s).resolve()
260
+
261
+ # If not a file:// (e.g., s3://, http://), _from_uri_or_path returns Path(s);
262
+ # detect that and either pass through or raise for clarity.
263
+ u = urlparse(s)
264
+ if "://" in s and (u.scheme or "").lower() != "file":
265
+ # Not a filesystem artifact; caller likely needs a downloader
266
+ return s # or: raise ValueError("Not a local filesystem URI")
267
+
268
+ if must_exist and not p.exists():
269
+ raise FileNotFoundError(f"Local path not found: {p}")
270
+ return str(p)
271
+
272
+ def to_local_file(self, uri_or_path: str | Path | Artifact, *, must_exist: bool = True) -> str:
273
+ """Same as to_local_path but asserts it's a file (not a dir)."""
274
+ p = Path(self.to_local_path(uri_or_path, must_exist=must_exist))
275
+ if must_exist and not p.is_file():
276
+ raise IsADirectoryError(f"Expected file, got directory: {p}")
277
+ return str(p)
278
+
279
+ def to_local_dir(self, uri_or_path: str | Path | Artifact, *, must_exist: bool = True) -> str:
280
+ """Same as to_local_path but asserts it's a directory."""
281
+ p = Path(self.to_local_path(uri_or_path, must_exist=must_exist))
282
+ if must_exist and not p.is_dir():
283
+ raise NotADirectoryError(f"Expected directory, got file: {p}")
284
+ return str(p)
@@ -0,0 +1,35 @@
1
+ import os
2
+
3
+ from .fs_store import FSArtifactStore
4
+ from .jsonl_index import JsonlArtifactIndex
5
+ from .sqlite_index import SqliteArtifactIndex # if present
6
+
7
+
8
+ def make_artifact_store() -> FSArtifactStore:
9
+ base = os.getenv("ARTIFACTS_DIR", "./artifacts")
10
+ return FSArtifactStore(base)
11
+
12
+
13
+ def make_artifact_index():
14
+ kind = (os.getenv("ARTIFACT_INDEX", "jsonl")).lower()
15
+ if kind == "sqlite":
16
+ path = os.getenv("ARTIFACT_INDEX_SQLITE", "./artifacts/index.sqlite")
17
+ return SqliteArtifactIndex(path)
18
+ path = os.getenv("ARTIFACT_INDEX_JSONL", "./artifacts/index.jsonl")
19
+ return JsonlArtifactIndex(path)
20
+
21
+
22
+ def make_facade_for_node(*, env, node, store=None, index=None):
23
+ store = store or make_artifact_store()
24
+ index = index or make_artifact_index()
25
+ from aethergraph.services.artifacts.facade import ArtifactFacade
26
+
27
+ return ArtifactFacade(
28
+ run_id=env.run_id,
29
+ graph_id=env.graph_id,
30
+ node_id=node.node_id,
31
+ tool_name=node.tool_name,
32
+ tool_version=node.tool_version,
33
+ store=store,
34
+ index=index,
35
+ )