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,320 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, field
4
+ import os
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ # ---- core services ----
9
+ from aethergraph.config.config import AppSettings
10
+
11
+ # ---- optional services (not used by default) ----
12
+ from aethergraph.contracts.services.llm import LLMClientProtocol
13
+
14
+ # ---- scheduler ---- TODO: move to a separate server to handle scheduling across threads/processes
15
+ from aethergraph.contracts.services.state_stores import GraphStateStore
16
+ from aethergraph.core.execution.global_scheduler import GlobalForwardScheduler
17
+
18
+ # ---- artifact services ----
19
+ from aethergraph.services.artifacts.fs_store import FSArtifactStore # AsyncArtifactStore
20
+ from aethergraph.services.artifacts.jsonl_index import JsonlArtifactIndex # AsyncArtifactIndex
21
+ from aethergraph.services.auth.dev import AllowAllAuthz, DevTokenAuthn
22
+ from aethergraph.services.channel.channel_bus import ChannelBus
23
+
24
+ # ---- channel services ----
25
+ from aethergraph.services.channel.factory import build_bus, make_channel_adapters_from_env
26
+ from aethergraph.services.clock.clock import SystemClock
27
+ from aethergraph.services.continuations.stores.fs_store import (
28
+ FSContinuationStore, # AsyncContinuationStore
29
+ )
30
+ from aethergraph.services.eventbus.inmem import InMemoryEventBus
31
+
32
+ # ---- kv services ----
33
+ from aethergraph.services.kv.ephemeral import EphemeralKV
34
+ from aethergraph.services.kv.sqlite_kv import SQLiteKV
35
+ from aethergraph.services.llm.factory import build_llm_clients
36
+ from aethergraph.services.llm.service import LLMService
37
+ from aethergraph.services.logger.std import LoggingConfig, StdLoggerService
38
+ from aethergraph.services.mcp.service import MCPService
39
+
40
+ # ---- memory services ----
41
+ from aethergraph.services.memory.factory import MemoryFactory
42
+ from aethergraph.services.memory.hotlog_kv import KVHotLog
43
+ from aethergraph.services.memory.indices import KVIndices
44
+ from aethergraph.services.memory.persist_fs import FSPersistence
45
+ from aethergraph.services.metering.noop import NoopMetering
46
+ from aethergraph.services.prompts.file_store import FilePromptStore
47
+ from aethergraph.services.rag.chunker import TextSplitter
48
+ from aethergraph.services.rag.facade import RAGFacade
49
+
50
+ # ---- RAG components ----
51
+ from aethergraph.services.rag.index_factory import create_vector_index
52
+ from aethergraph.services.redactor.simple import RegexRedactor # Simple PII redactor
53
+ from aethergraph.services.registry.unified_registry import UnifiedRegistry
54
+ from aethergraph.services.resume.multi_scheduler_resume_bus import MultiSchedulerResumeBus
55
+ from aethergraph.services.resume.router import ResumeRouter
56
+ from aethergraph.services.schedulers.registry import SchedulerRegistry
57
+ from aethergraph.services.secrets.env import EnvSecrets
58
+ from aethergraph.services.state_stores.json_store import JsonGraphStateStore
59
+ from aethergraph.services.tracing.noop import NoopTracer
60
+ from aethergraph.services.waits.wait_registry import WaitRegistry
61
+ from aethergraph.services.wakeup.memory_queue import ThreadSafeWakeupQueue
62
+
63
+ SERVICE_KEYS = [
64
+ # core
65
+ "registry",
66
+ "logger",
67
+ "clock",
68
+ "channels",
69
+ # continuations and resume
70
+ "cont_store",
71
+ "sched_registry",
72
+ "wait_registry",
73
+ "resume_bus",
74
+ "resume_router",
75
+ "wakeup_queue",
76
+ # storage and artifacts
77
+ "kv_hot",
78
+ "kv_durable",
79
+ "artifacts",
80
+ "artifact_index",
81
+ # memory
82
+ "memory_factory",
83
+ # optional
84
+ "llm",
85
+ "event_bus",
86
+ "prompts",
87
+ "authn",
88
+ "authz",
89
+ "redactor",
90
+ "metering",
91
+ "tracer",
92
+ "secrets",
93
+ ]
94
+
95
+
96
+ @dataclass
97
+ class DefaultContainer:
98
+ # root
99
+ root: str
100
+
101
+ # schedulers
102
+ schedulers: dict[str, Any]
103
+
104
+ # core
105
+ registry: UnifiedRegistry
106
+ logger: StdLoggerService
107
+ clock: SystemClock
108
+
109
+ # channels and interactions
110
+ channels: ChannelBus
111
+
112
+ # continuations and resume
113
+ cont_store: FSContinuationStore
114
+ sched_registry: SchedulerRegistry
115
+ wait_registry: WaitRegistry
116
+ resume_bus: MultiSchedulerResumeBus
117
+ resume_router: ResumeRouter
118
+ wakeup_queue: ThreadSafeWakeupQueue
119
+ state_store: GraphStateStore
120
+
121
+ # storage and artifacts
122
+ kv_hot: EphemeralKV
123
+ kv_durable: SQLiteKV
124
+ artifacts: FSArtifactStore
125
+ artifact_index: JsonlArtifactIndex
126
+
127
+ # memory
128
+ memory_factory: MemoryFactory
129
+
130
+ # optional llm service
131
+ llm: LLMClientProtocol | None = None
132
+ rag: RAGFacade | None = None
133
+ mcp: MCPService | None = None
134
+
135
+ # optional services (not used by default)
136
+ event_bus: InMemoryEventBus | None = None
137
+ prompts: FilePromptStore | None = None
138
+ authn: DevTokenAuthn | None = None
139
+ authz: AllowAllAuthz | None = None
140
+ redactor: RegexRedactor | None = None
141
+ metering: NoopMetering | None = None
142
+ tracer: NoopTracer | None = None
143
+ secrets: EnvSecrets | None = None
144
+
145
+ # extensible services
146
+ ext_services: dict[str, Any] = field(default_factory=dict)
147
+
148
+ # settings -- not a service, but useful to have around
149
+ settings: AppSettings | None = None
150
+
151
+
152
+ def build_default_container(
153
+ *,
154
+ root: str | None = None,
155
+ cfg: AppSettings | None = None,
156
+ ) -> DefaultContainer:
157
+ """Build the default service container with standard services.
158
+ if "root" is provided, use it as the base directory for storage; else use from cfg/root.
159
+ if cfg is not provided, load from default AppSettings.
160
+ """
161
+ if cfg is None:
162
+ from aethergraph.config.context import set_current_settings
163
+ from aethergraph.config.loader import load_settings
164
+
165
+ cfg = load_settings()
166
+ set_current_settings(cfg)
167
+
168
+ root = root or cfg.root
169
+ # override root in cfg to match
170
+ cfg.root = root
171
+
172
+ # we use user specified root if provided, else from config/env
173
+ root_p = Path(root).resolve() if root else Path(cfg.root).resolve()
174
+ (root_p / "kv").mkdir(parents=True, exist_ok=True)
175
+ (root_p / "continuations").mkdir(parents=True, exist_ok=True)
176
+ (root_p / "index").mkdir(parents=True, exist_ok=True)
177
+ (root_p / "memory").mkdir(parents=True, exist_ok=True)
178
+ (root_p / "graph_states").mkdir(parents=True, exist_ok=True)
179
+
180
+ # core services
181
+ logger_factory = StdLoggerService.build(
182
+ LoggingConfig.from_cfg(cfg, log_dir=str(root_p / "logs"))
183
+ )
184
+ clock = SystemClock()
185
+ registry = UnifiedRegistry()
186
+
187
+ # continuations and resume
188
+ cont_store = FSContinuationStore(root=str(root_p / "continuations"), secret=os.urandom(32))
189
+ sched_registry = SchedulerRegistry()
190
+ wait_registry = WaitRegistry()
191
+ resume_bus = MultiSchedulerResumeBus(
192
+ registry=sched_registry, store=cont_store, logger=logger_factory.for_run()
193
+ )
194
+ resume_router = ResumeRouter(
195
+ store=cont_store,
196
+ runner=resume_bus,
197
+ logger=logger_factory.for_run(),
198
+ wait_registry=wait_registry,
199
+ )
200
+ wakeup_queue = ThreadSafeWakeupQueue() # TODO: this is a placeholder, not fully implemented
201
+ state_store = JsonGraphStateStore(root=str(root_p / "graph_states"))
202
+
203
+ # global scheduler
204
+ global_sched = GlobalForwardScheduler(
205
+ registry=sched_registry,
206
+ global_max_concurrency=None, # TODO: make configurable
207
+ logger=logger_factory.for_scheduler(),
208
+ )
209
+ schedulers = {
210
+ "global": global_sched,
211
+ "registry": sched_registry,
212
+ }
213
+
214
+ # channels
215
+ channel_adapters = make_channel_adapters_from_env(cfg)
216
+ channels = build_bus(
217
+ channel_adapters,
218
+ default="console:stdin",
219
+ logger=logger_factory.for_run(),
220
+ resume_router=resume_router,
221
+ cont_store=cont_store,
222
+ )
223
+
224
+ # storage and artifacts
225
+ kv_hot = EphemeralKV()
226
+ kv_durable = SQLiteKV(str(root_p / "kv" / "kv.sqlite"))
227
+ artifacts = FSArtifactStore(
228
+ str(root_p / "artifacts")
229
+ ) # async wrapper over FileArtifactStoreSync
230
+ artifact_index = JsonlArtifactIndex(str(root_p / "index" / "artifacts.jsonl"))
231
+
232
+ # memory
233
+ hotlog = KVHotLog(kv=kv_hot)
234
+ persistence = FSPersistence(base_dir=str(root_p / "memory"))
235
+ indices = KVIndices(kv=kv_durable, hot_ttl_s=7 * 24 * 3600)
236
+
237
+ # optional services
238
+ secrets = (
239
+ EnvSecrets()
240
+ ) # get secrets from env vars -- for local development; in prod, use a proper secrets manager
241
+ llm_clients = build_llm_clients(cfg.llm, secrets) # return {profile: GenericLLMClient}
242
+ llm_service = LLMService(clients=llm_clients) if llm_clients else None
243
+
244
+ rag_cfg = cfg.rag
245
+ vec_index = create_vector_index(
246
+ backend=rag_cfg.backend, index_path=str(root_p / "rag" / "rag_index"), dim=rag_cfg.dim
247
+ )
248
+
249
+ rag_facade = RAGFacade(
250
+ corpus_root=str(root_p / "rag" / "rag_corpora"),
251
+ artifacts=artifacts,
252
+ embed_client=llm_service.get("default"),
253
+ llm_client=llm_service.get("default"),
254
+ index_backend=vec_index,
255
+ chunker=TextSplitter(),
256
+ logger=logger_factory.for_run(),
257
+ )
258
+ mcp = MCPService() # empty MCP service; users can register clients as needed
259
+
260
+ memory_factory = MemoryFactory(
261
+ hotlog=hotlog,
262
+ persistence=persistence,
263
+ indices=indices,
264
+ artifacts=artifacts,
265
+ hot_limit=int(cfg.memory.hot_limit),
266
+ hot_ttl_s=int(cfg.memory.hot_ttl_s),
267
+ default_signal_threshold=float(cfg.memory.signal_threshold),
268
+ logger=logger_factory.for_run(),
269
+ llm_service=llm_service.get("default") if llm_service else None,
270
+ rag_facade=rag_facade,
271
+ )
272
+
273
+ return DefaultContainer(
274
+ root=str(root_p),
275
+ schedulers=schedulers,
276
+ registry=registry,
277
+ logger=logger_factory,
278
+ clock=clock,
279
+ channels=channels,
280
+ cont_store=cont_store,
281
+ sched_registry=sched_registry,
282
+ wait_registry=wait_registry,
283
+ resume_bus=resume_bus,
284
+ resume_router=resume_router,
285
+ wakeup_queue=wakeup_queue,
286
+ kv_hot=kv_hot,
287
+ kv_durable=kv_durable,
288
+ state_store=state_store,
289
+ artifacts=artifacts,
290
+ artifact_index=artifact_index,
291
+ memory_factory=memory_factory,
292
+ llm=llm_service,
293
+ rag=rag_facade,
294
+ mcp=mcp,
295
+ secrets=secrets,
296
+ event_bus=None,
297
+ prompts=None,
298
+ authn=None,
299
+ authz=None,
300
+ redactor=None,
301
+ metering=None,
302
+ tracer=None,
303
+ settings=cfg,
304
+ )
305
+
306
+
307
+ # Singleton (used unless the host sets their own)
308
+ DEFAULT_CONTAINER: DefaultContainer | None = None
309
+
310
+
311
+ def get_container() -> DefaultContainer:
312
+ global DEFAULT_CONTAINER
313
+ if DEFAULT_CONTAINER is None:
314
+ DEFAULT_CONTAINER = build_default_container()
315
+ return DEFAULT_CONTAINER
316
+
317
+
318
+ def set_container(c: DefaultContainer) -> None:
319
+ global DEFAULT_CONTAINER
320
+ DEFAULT_CONTAINER = c
@@ -0,0 +1,56 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from datetime import datetime
5
+ from typing import Any
6
+
7
+
8
+ @dataclass(frozen=True)
9
+ class Correlator:
10
+ """Platform-agnostic correlation key for continuations."""
11
+
12
+ scheme: str # e.g., "slack", "web", "email"
13
+ channel: str # e.g., channel ID, email address
14
+ thread: str # e.g., thread ID, conversation ID
15
+ message: str # e.g., message ID, timestamp
16
+
17
+ def key(self) -> tuple[str, str, str, str]:
18
+ return (self.scheme, self.channel, self.thread or "", self.message or "")
19
+
20
+
21
+ @dataclass
22
+ class Continuation:
23
+ """Represents a continuation of a process or workflow."""
24
+
25
+ run_id: str
26
+ node_id: str
27
+ kind: str
28
+ token: str
29
+ prompt: str | None = None
30
+ resume_schema: dict | None = None
31
+ deadline: datetime | None = None
32
+ poll: dict | None = None
33
+ next_wakeup_at: datetime | None = None
34
+ attempts: int = 0
35
+ channel: str | None = None
36
+ created_at: datetime = datetime.utcnow()
37
+ closed: bool = False # ← NEW
38
+ payload: dict[str, Any] | None = None # set at creation time
39
+
40
+ def to_dict(self) -> dict[str, Any]:
41
+ return {
42
+ "run_id": self.run_id,
43
+ "node_id": self.node_id,
44
+ "kind": self.kind,
45
+ "token": self.token,
46
+ "prompt": self.prompt,
47
+ "resume_schema": self.resume_schema,
48
+ "deadline": self.deadline.isoformat() if self.deadline else None,
49
+ "poll": self.poll,
50
+ "next_wakeup_at": self.next_wakeup_at.isoformat() if self.next_wakeup_at else None,
51
+ "attempts": self.attempts,
52
+ "channel": self.channel,
53
+ "created_at": self.created_at.isoformat(),
54
+ "closed": self.closed,
55
+ "payload": self.payload,
56
+ }
@@ -0,0 +1,34 @@
1
+ import os
2
+
3
+ from stores.fs_store import FSContinuationStore
4
+ from stores.inmem_store import InMemoryContinuationStore
5
+
6
+ from aethergraph.contracts.services.continuations import AsyncContinuationStore
7
+
8
+
9
+ def make_continuation_store() -> AsyncContinuationStore:
10
+ """Factory to create a continuation store based on environment configuration.
11
+ Returns:
12
+ An instance of AsyncContinuationStore.
13
+
14
+ Currently supports:
15
+ - InMemoryContinuationStore (CONT_STORE="inmem")
16
+ - FSContinuationStore (CONT_STORE="fs")
17
+
18
+ We need env vars:
19
+ - CONT_STORE: "inmem" or "fs" (default: "fs")
20
+ - CONT_SECRET: optional secret for HMAC token generation
21
+ - CONT_ROOT: for FS store, root directory to store continuations (default: "./artifacts/continuations")
22
+ """
23
+ kind = (os.getenv("CONT_STORE", "fs")).lower()
24
+ secret = os.getenv("CONT_SECRET")
25
+ secret_bytes = secret.encode("utf-8") if secret else os.urandom(32)
26
+
27
+ if kind == "inmem":
28
+ return InMemoryContinuationStore(secret=secret_bytes)
29
+ elif kind == "fs":
30
+ return FSContinuationStore(
31
+ root=os.getenv("CONT_ROOT", "./artifacts/continuations"), secret=secret_bytes
32
+ )
33
+ else:
34
+ raise ValueError(f"Unknown continuation store kind: {kind}")
@@ -0,0 +1,264 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ from dataclasses import asdict
5
+ from datetime import datetime, timezone
6
+ import hashlib
7
+ import hmac
8
+ import json
9
+ import logging
10
+ import os
11
+ from pathlib import Path
12
+ import re
13
+ import threading
14
+ from typing import Any
15
+
16
+ from ..continuation import Continuation, Correlator
17
+
18
+
19
+ class FSContinuationStore: # implements AsyncContinuationStore
20
+ def __init__(self, root: str | Path, secret: bytes):
21
+ self.root = Path(root)
22
+ self.secret = secret
23
+ self._lock = threading.RLock()
24
+
25
+ # ---------- helpers ----------
26
+ def _cont_path(self, run_id: str, node_id: str) -> Path:
27
+ return self.root / "runs" / run_id / "nodes" / node_id / "continuation.json"
28
+
29
+ def _token_idx_path(self, token: str) -> Path:
30
+ return self.root / "index" / "tokens" / f"{token}.json"
31
+
32
+ def _rev_idx_path(self, token: str) -> Path:
33
+ return self.root / "index" / "rev" / f"{token}.json"
34
+
35
+ def _safe(self, s: str) -> str:
36
+ s = (s or "").replace("\\", "_").replace("/", "_").replace(":", "_")
37
+ s = re.sub(r"[^A-Za-z0-9._@-]", "_", s)
38
+ s = re.sub(r"_+", "_", s).strip("._ ") or "x"
39
+ if len(s) > 100:
40
+ h = hashlib.sha1(s.encode()).hexdigest()[:8]
41
+ s = f"{s[:92]}_{h}"
42
+ return s
43
+
44
+ def _corr_dir(self, scheme: str, channel: str, thread: str, message: str) -> Path:
45
+ return (
46
+ self.root
47
+ / "index"
48
+ / "corr"
49
+ / self._safe(scheme)
50
+ / self._safe(channel)
51
+ / self._safe(thread)
52
+ / self._safe(message)
53
+ )
54
+
55
+ def _corr_tokens_path(self, scheme: str, channel: str, thread: str, message: str) -> Path:
56
+ return self._corr_dir(scheme, channel, thread, message) / "tokens.json"
57
+
58
+ def _hmac(self, *parts: str) -> str:
59
+ h = hmac.new(self.secret, digestmod=hashlib.sha256)
60
+ for p in parts:
61
+ h.update(p.encode("utf-8"))
62
+ return h.hexdigest()
63
+
64
+ async def mint_token(self, run_id: str, node_id: str, attempts: int) -> str:
65
+ return self._hmac(run_id, node_id, str(attempts), os.urandom(8).hex())
66
+
67
+ # ---------- core ----------
68
+ async def save(self, cont: Continuation) -> None:
69
+ def _write():
70
+ path = self._cont_path(cont.run_id, cont.node_id)
71
+ path.parent.mkdir(parents=True, exist_ok=True)
72
+ payload = cont.to_dict() if hasattr(cont, "to_dict") else asdict(cont)
73
+ for k in ("deadline", "next_wakeup_at", "created_at"):
74
+ v = payload.get(k)
75
+ if isinstance(v, datetime):
76
+ payload[k] = v.astimezone(timezone.utc).isoformat()
77
+ tmp = path.with_suffix(".tmp")
78
+ tmp.write_text(json.dumps(payload, indent=2), encoding="utf-8")
79
+ tmp.replace(path)
80
+ self._write_token_index(cont.run_id, cont.node_id, cont.token)
81
+
82
+ await asyncio.to_thread(_write)
83
+
84
+ async def get(self, run_id: str, node_id: str) -> Continuation | None:
85
+ def _read():
86
+ path = self._cont_path(run_id, node_id)
87
+ if not path.exists():
88
+ return None
89
+ raw = json.loads(path.read_text(encoding="utf-8"))
90
+ for k in ("deadline", "next_wakeup_at", "created_at"):
91
+ if raw.get(k):
92
+ raw[k] = datetime.fromisoformat(raw[k])
93
+ raw["closed"] = bool(raw.get("closed", False))
94
+ return Continuation(**raw)
95
+
96
+ return await asyncio.to_thread(_read)
97
+
98
+ async def delete(self, run_id: str, node_id: str) -> None:
99
+ def _del():
100
+ p = self._cont_path(run_id, node_id)
101
+ if p.exists():
102
+ p.unlink()
103
+
104
+ await asyncio.to_thread(_del)
105
+
106
+ # ---------- token helpers ----------
107
+ def _write_token_index(self, run_id: str, node_id: str, token: str) -> None:
108
+ with self._lock:
109
+ p = self._token_idx_path(token)
110
+ p.parent.mkdir(parents=True, exist_ok=True)
111
+ p.write_text(
112
+ json.dumps({"run_id": run_id, "node_id": node_id}, indent=2), encoding="utf-8"
113
+ )
114
+
115
+ async def get_by_token(self, token: str) -> Continuation | None:
116
+ def _lookup():
117
+ p = self._token_idx_path(token)
118
+ if not p.exists():
119
+ return None
120
+ ref = json.loads(p.read_text(encoding="utf-8"))
121
+ return ref["run_id"], ref["node_id"]
122
+
123
+ ref = await asyncio.to_thread(_lookup)
124
+ if not ref:
125
+ return None
126
+ run_id, node_id = ref
127
+ return await self.get(run_id, node_id)
128
+
129
+ async def mark_closed(self, token: str) -> None:
130
+ c = await self.get_by_token(token)
131
+ if not c:
132
+ return
133
+ if not c.closed:
134
+ c.closed = True
135
+ await self.save(c)
136
+
137
+ async def verify_token(self, run_id: str, node_id: str, token: str) -> bool:
138
+ c = await self.get(run_id, node_id)
139
+ return bool(c and hmac.compare_digest(token, c.token))
140
+
141
+ # ---------- correlators ----------
142
+ async def bind_correlator(self, *, token: str, corr: Correlator) -> None:
143
+ def _bind():
144
+ scheme, channel, thread, message = corr.key()
145
+ tokens_path = self._corr_tokens_path(scheme, channel, thread, message)
146
+ tokens_path.parent.mkdir(parents=True, exist_ok=True)
147
+ toks: list[str] = []
148
+ if tokens_path.exists():
149
+ try:
150
+ toks = json.loads(tokens_path.read_text(encoding="utf-8"))
151
+ except Exception:
152
+ toks = []
153
+ if token not in toks:
154
+ toks.append(token)
155
+ tokens_path.write_text(json.dumps(toks, indent=2), encoding="utf-8")
156
+ # reverse index
157
+ r = self._rev_idx_path(token)
158
+ r.parent.mkdir(parents=True, exist_ok=True)
159
+ paths = []
160
+ if r.exists():
161
+ try:
162
+ paths = json.loads(r.read_text(encoding="utf-8"))
163
+ except Exception:
164
+ paths = []
165
+ key_path = str(tokens_path.relative_to(self.root))
166
+ if key_path not in paths:
167
+ paths.append(key_path)
168
+ r.write_text(json.dumps(paths, indent=2), encoding="utf-8")
169
+
170
+ await asyncio.to_thread(_bind)
171
+
172
+ async def find_by_correlator(self, *, corr: Correlator) -> Continuation | None:
173
+ def _read_toks():
174
+ scheme, channel, thread, message = corr.key()
175
+ p = self._corr_tokens_path(scheme, channel, thread, message)
176
+ if not p.exists():
177
+ return []
178
+ try:
179
+ return json.loads(p.read_text(encoding="utf-8")) or []
180
+ except Exception:
181
+ return []
182
+
183
+ toks = await asyncio.to_thread(_read_toks)
184
+ for tok in reversed(toks):
185
+ c = await self.get_by_token(tok)
186
+ if c and not c.closed:
187
+ if c.deadline and datetime.now(timezone.utc) > c.deadline.astimezone(timezone.utc):
188
+ continue
189
+ return c
190
+ return None
191
+
192
+ async def last_open(self, *, channel: str, kind: str) -> Continuation | None:
193
+ # Optional slow scan (dev only)
194
+ def _scan():
195
+ waits = []
196
+ runs_path = self.root / "runs"
197
+ if not runs_path.exists():
198
+ return waits
199
+ for run_dir in runs_path.iterdir():
200
+ nodes_dir = run_dir / "nodes"
201
+ if not nodes_dir.exists():
202
+ continue
203
+ for node_dir in nodes_dir.iterdir():
204
+ cont_path = node_dir / "continuation.json"
205
+ if cont_path.exists():
206
+ waits.append(json.loads(cont_path.read_text(encoding="utf-8")))
207
+ return waits
208
+
209
+ waits = await asyncio.to_thread(_scan)
210
+ for raw in reversed(waits):
211
+ if raw.get("closed"):
212
+ continue
213
+ if raw.get("channel") == channel and raw.get("kind") == kind:
214
+ for k in ("deadline", "next_wakeup_at", "created_at"):
215
+ if raw.get(k):
216
+ raw[k] = datetime.fromisoformat(raw[k])
217
+ return Continuation(**raw)
218
+ return None
219
+
220
+ async def list_waits(self) -> list[dict[str, Any]]:
221
+ def _scan():
222
+ out = []
223
+ runs_path = self.root / "runs"
224
+ if not runs_path.exists():
225
+ return out
226
+ for run_dir in runs_path.iterdir():
227
+ nodes_dir = run_dir / "nodes"
228
+ if not nodes_dir.exists():
229
+ continue
230
+ for node_dir in nodes_dir.iterdir():
231
+ cont_path = node_dir / "continuation.json"
232
+ if cont_path.exists():
233
+ out.append(json.loads(cont_path.read_text(encoding="utf-8")))
234
+ return out
235
+
236
+ return await asyncio.to_thread(_scan)
237
+
238
+ async def clear(self) -> None:
239
+ def _clear():
240
+ for sub in ("runs", "index"):
241
+ p = self.root / sub
242
+ if p.exists():
243
+ for root, dirs, files in os.walk(p, topdown=False):
244
+ for f in files:
245
+ try:
246
+ os.remove(Path(root) / f)
247
+ except Exception:
248
+ logger = logging.getLogger(
249
+ "aethergraph.services.continuations.stores.fs_store"
250
+ )
251
+ logger.warning("Failed to remove file: %s", Path(root) / f)
252
+ for d in dirs:
253
+ try:
254
+ os.rmdir(Path(root) / d)
255
+ except Exception:
256
+ logger = logging.getLogger(
257
+ "aethergraph.services.continuations.stores.fs_store"
258
+ )
259
+ logger.warning("Failed to remove dir: %s", Path(root) / d)
260
+
261
+ await asyncio.to_thread(_clear)
262
+
263
+ async def alias_for(self, token: str) -> str | None:
264
+ return token[:24]