aethergraph 0.1.0a3__py3-none-any.whl → 0.1.0a4__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 (113) hide show
  1. aethergraph/api/v1/artifacts.py +23 -4
  2. aethergraph/api/v1/schemas.py +7 -0
  3. aethergraph/api/v1/session.py +123 -4
  4. aethergraph/config/config.py +2 -0
  5. aethergraph/config/search.py +49 -0
  6. aethergraph/contracts/services/channel.py +18 -1
  7. aethergraph/contracts/services/execution.py +58 -0
  8. aethergraph/contracts/services/llm.py +26 -0
  9. aethergraph/contracts/services/memory.py +10 -4
  10. aethergraph/contracts/services/planning.py +53 -0
  11. aethergraph/contracts/storage/event_log.py +8 -0
  12. aethergraph/contracts/storage/search_backend.py +47 -0
  13. aethergraph/contracts/storage/vector_index.py +73 -0
  14. aethergraph/core/graph/action_spec.py +76 -0
  15. aethergraph/core/graph/graph_fn.py +75 -2
  16. aethergraph/core/graph/graphify.py +74 -2
  17. aethergraph/core/runtime/graph_runner.py +2 -1
  18. aethergraph/core/runtime/node_context.py +66 -3
  19. aethergraph/core/runtime/node_services.py +8 -0
  20. aethergraph/core/runtime/run_manager.py +263 -271
  21. aethergraph/core/runtime/run_types.py +54 -1
  22. aethergraph/core/runtime/runtime_env.py +35 -14
  23. aethergraph/core/runtime/runtime_services.py +308 -18
  24. aethergraph/plugins/agents/default_chat_agent.py +266 -74
  25. aethergraph/plugins/agents/default_chat_agent_v2.py +487 -0
  26. aethergraph/plugins/channel/adapters/webui.py +69 -21
  27. aethergraph/plugins/channel/routes/webui_routes.py +8 -48
  28. aethergraph/runtime/__init__.py +12 -0
  29. aethergraph/server/app_factory.py +3 -0
  30. aethergraph/server/ui_static/assets/index-CFktGdbW.js +4913 -0
  31. aethergraph/server/ui_static/assets/index-DcfkFlTA.css +1 -0
  32. aethergraph/server/ui_static/index.html +2 -2
  33. aethergraph/services/artifacts/facade.py +157 -21
  34. aethergraph/services/artifacts/types.py +35 -0
  35. aethergraph/services/artifacts/utils.py +42 -0
  36. aethergraph/services/channel/channel_bus.py +3 -1
  37. aethergraph/services/channel/event_hub copy.py +55 -0
  38. aethergraph/services/channel/event_hub.py +81 -0
  39. aethergraph/services/channel/factory.py +3 -2
  40. aethergraph/services/channel/session.py +709 -74
  41. aethergraph/services/container/default_container.py +69 -7
  42. aethergraph/services/execution/__init__.py +0 -0
  43. aethergraph/services/execution/local_python.py +118 -0
  44. aethergraph/services/indices/__init__.py +0 -0
  45. aethergraph/services/indices/global_indices.py +21 -0
  46. aethergraph/services/indices/scoped_indices.py +292 -0
  47. aethergraph/services/llm/generic_client.py +342 -46
  48. aethergraph/services/llm/generic_embed_client.py +359 -0
  49. aethergraph/services/llm/types.py +3 -1
  50. aethergraph/services/memory/distillers/llm_long_term.py +60 -109
  51. aethergraph/services/memory/distillers/llm_long_term_v1.py +180 -0
  52. aethergraph/services/memory/distillers/llm_meta_summary.py +57 -266
  53. aethergraph/services/memory/distillers/llm_meta_summary_v1.py +342 -0
  54. aethergraph/services/memory/distillers/long_term.py +48 -131
  55. aethergraph/services/memory/distillers/long_term_v1.py +170 -0
  56. aethergraph/services/memory/facade/chat.py +18 -8
  57. aethergraph/services/memory/facade/core.py +159 -19
  58. aethergraph/services/memory/facade/distillation.py +86 -31
  59. aethergraph/services/memory/facade/retrieval.py +100 -1
  60. aethergraph/services/memory/factory.py +4 -1
  61. aethergraph/services/planning/__init__.py +0 -0
  62. aethergraph/services/planning/action_catalog.py +271 -0
  63. aethergraph/services/planning/bindings.py +56 -0
  64. aethergraph/services/planning/dependency_index.py +65 -0
  65. aethergraph/services/planning/flow_validator.py +263 -0
  66. aethergraph/services/planning/graph_io_adapter.py +150 -0
  67. aethergraph/services/planning/input_parser.py +312 -0
  68. aethergraph/services/planning/missing_inputs.py +28 -0
  69. aethergraph/services/planning/node_planner.py +613 -0
  70. aethergraph/services/planning/orchestrator.py +112 -0
  71. aethergraph/services/planning/plan_executor.py +506 -0
  72. aethergraph/services/planning/plan_types.py +321 -0
  73. aethergraph/services/planning/planner.py +617 -0
  74. aethergraph/services/planning/planner_service.py +369 -0
  75. aethergraph/services/planning/planning_context_builder.py +43 -0
  76. aethergraph/services/planning/quick_actions.py +29 -0
  77. aethergraph/services/planning/routers/__init__.py +0 -0
  78. aethergraph/services/planning/routers/simple_router.py +26 -0
  79. aethergraph/services/rag/facade.py +0 -3
  80. aethergraph/services/scope/scope.py +30 -30
  81. aethergraph/services/scope/scope_factory.py +15 -7
  82. aethergraph/services/skills/__init__.py +0 -0
  83. aethergraph/services/skills/skill_registry.py +465 -0
  84. aethergraph/services/skills/skills.py +220 -0
  85. aethergraph/services/skills/utils.py +194 -0
  86. aethergraph/storage/artifacts/artifact_index_jsonl.py +16 -10
  87. aethergraph/storage/artifacts/artifact_index_sqlite.py +12 -2
  88. aethergraph/storage/docstore/sqlite_doc_sync.py +1 -1
  89. aethergraph/storage/memory/event_persist.py +42 -2
  90. aethergraph/storage/memory/fs_persist.py +32 -2
  91. aethergraph/storage/search_backend/__init__.py +0 -0
  92. aethergraph/storage/search_backend/generic_vector_backend.py +230 -0
  93. aethergraph/storage/search_backend/null_backend.py +34 -0
  94. aethergraph/storage/search_backend/sqlite_lexical_backend.py +387 -0
  95. aethergraph/storage/search_backend/utils.py +31 -0
  96. aethergraph/storage/search_factory.py +75 -0
  97. aethergraph/storage/vector_index/faiss_index.py +72 -4
  98. aethergraph/storage/vector_index/sqlite_index.py +521 -52
  99. aethergraph/storage/vector_index/sqlite_index_vanila.py +311 -0
  100. aethergraph/storage/vector_index/utils.py +22 -0
  101. {aethergraph-0.1.0a3.dist-info → aethergraph-0.1.0a4.dist-info}/METADATA +1 -1
  102. {aethergraph-0.1.0a3.dist-info → aethergraph-0.1.0a4.dist-info}/RECORD +107 -63
  103. {aethergraph-0.1.0a3.dist-info → aethergraph-0.1.0a4.dist-info}/WHEEL +1 -1
  104. aethergraph/plugins/agents/default_chat_agent copy.py +0 -90
  105. aethergraph/server/ui_static/assets/index-BR5GtXcZ.css +0 -1
  106. aethergraph/server/ui_static/assets/index-CQ0HZZ83.js +0 -400
  107. aethergraph/services/eventhub/event_hub.py +0 -76
  108. aethergraph/services/llm/generic_client copy.py +0 -691
  109. aethergraph/services/prompts/file_store.py +0 -41
  110. {aethergraph-0.1.0a3.dist-info → aethergraph-0.1.0a4.dist-info}/entry_points.txt +0 -0
  111. {aethergraph-0.1.0a3.dist-info → aethergraph-0.1.0a4.dist-info}/licenses/LICENSE +0 -0
  112. {aethergraph-0.1.0a3.dist-info → aethergraph-0.1.0a4.dist-info}/licenses/NOTICE +0 -0
  113. {aethergraph-0.1.0a3.dist-info → aethergraph-0.1.0a4.dist-info}/top_level.txt +0 -0
@@ -4,7 +4,7 @@ from __future__ import annotations
4
4
  import asyncio
5
5
  from collections.abc import AsyncIterator
6
6
  from contextlib import asynccontextmanager
7
- from datetime import datetime
7
+ from datetime import datetime, timezone
8
8
  import json
9
9
  from pathlib import Path
10
10
  from typing import Any, Literal
@@ -12,12 +12,15 @@ from urllib.parse import urlparse
12
12
 
13
13
  from aethergraph.contracts.services.artifacts import Artifact, AsyncArtifactStore
14
14
  from aethergraph.contracts.storage.artifact_index import AsyncArtifactIndex
15
+ from aethergraph.contracts.storage.search_backend import ScoredItem
15
16
  from aethergraph.core.runtime.runtime_metering import current_metering
16
17
  from aethergraph.core.runtime.runtime_services import current_services
17
18
  from aethergraph.services.artifacts.paths import _from_uri_or_path
19
+ from aethergraph.services.artifacts.types import ArtifactContent, ArtifactSearchResult, ArtifactView
20
+ from aethergraph.services.artifacts.utils import _infer_content_mode
21
+ from aethergraph.services.indices.scoped_indices import ScopedIndices
18
22
  from aethergraph.services.scope.scope import Scope
19
-
20
- ArtifactView = Literal["node", "graph", "run", "all"]
23
+ from aethergraph.storage.vector_index.utils import build_index_meta_from_scope
21
24
 
22
25
 
23
26
  class ArtifactFacade:
@@ -37,8 +40,9 @@ class ArtifactFacade:
37
40
  node_id: str,
38
41
  tool_name: str,
39
42
  tool_version: str,
40
- store: AsyncArtifactStore,
41
- index: AsyncArtifactIndex,
43
+ art_store: AsyncArtifactStore,
44
+ art_index: AsyncArtifactIndex,
45
+ scoped_indices: ScopedIndices | None = None,
42
46
  scope: Scope | None = None,
43
47
  ) -> None:
44
48
  self.run_id = run_id
@@ -46,8 +50,9 @@ class ArtifactFacade:
46
50
  self.node_id = node_id
47
51
  self.tool_name = tool_name
48
52
  self.tool_version = tool_version
49
- self.store = store
50
- self.index = index
53
+ self.store = art_store
54
+ self.index = art_index
55
+ self.scoped_indices = scoped_indices
51
56
 
52
57
  # set scope -- this should be done outside in NodeContext and passed in, but here is a fallback
53
58
  self.scope = scope
@@ -124,11 +129,94 @@ class ArtifactFacade:
124
129
  a.session_id = a.session_id or dims.get("session_id")
125
130
  # run_id / graph_id / node_id are already set
126
131
 
132
+ # 1.1) Normalize timestamp: ensure a.created_at is a UTC datetime
133
+ created_dt: datetime
134
+ if isinstance(a.created_at, datetime):
135
+ if a.created_at.tzinfo is None:
136
+ created_dt = a.created_at.replace(tzinfo=timezone.utc)
137
+ else:
138
+ created_dt = a.created_at.astimezone(timezone.utc)
139
+ elif isinstance(a.created_at, str):
140
+ # Best-effort parse; if it fails, fall back to "now"
141
+ try:
142
+ # Accept either Z-suffixed or offset ISO
143
+ if a.created_at.endswith("Z"):
144
+ created_dt = datetime.fromisoformat(a.created_at.replace("Z", "+00:00"))
145
+ else:
146
+ created_dt = datetime.fromisoformat(a.created_at)
147
+ if created_dt.tzinfo is None:
148
+ created_dt = created_dt.replace(tzinfo=timezone.utc)
149
+ else:
150
+ created_dt = created_dt.astimezone(timezone.utc)
151
+ except Exception:
152
+ created_dt = datetime.now(timezone.utc)
153
+ else:
154
+ # No timestamp provided → use now
155
+ created_dt = datetime.now(timezone.utc)
156
+
157
+ # Persist normalized timestamp back onto the artifact
158
+ a.created_at = created_dt
159
+ # Canonical forms:
160
+ ts_iso = created_dt.replace(microsecond=0).isoformat().replace("+00:00", "Z")
161
+ created_at_ts = created_dt.timestamp()
162
+
127
163
  # 2) Record in index + occurrence log
128
164
  await self.index.upsert(a)
129
165
  await self.index.record_occurrence(a)
130
166
  self.last_artifact = a
131
167
 
168
+ # 2.1) Wire artifact text into ScopedIndices for searchability
169
+ if self.scoped_indices is not None and self.scoped_indices.backend is not None:
170
+ try:
171
+ # Build a simple text blob from labels, metrics, and kind
172
+ # Build text blob
173
+ parts: list[str] = []
174
+ if a.kind:
175
+ parts.append(str(a.kind))
176
+ if a.labels:
177
+ parts.append("; ".join(f"{k}: {v}" for k, v in a.labels.items()))
178
+ if a.metrics:
179
+ parts.append("; ".join(f"{k}: {v}" for k, v in a.metrics.items()))
180
+ text = " ".join(parts).strip()
181
+
182
+ extra_meta: dict[str, Any] = {
183
+ "run_id": a.run_id,
184
+ "graph_id": a.graph_id,
185
+ "node_id": a.node_id,
186
+ "tool_name": a.tool_name,
187
+ "tool_version": a.tool_version,
188
+ "pinned": a.pinned,
189
+ }
190
+
191
+ # Add artifact kind as explicit metadata field
192
+ if a.kind:
193
+ extra_meta["artifact_kind"] = a.kind
194
+
195
+ # Flatten labels last so they can override if needed
196
+ if a.labels:
197
+ extra_meta.update(a.labels)
198
+
199
+ meta = build_index_meta_from_scope(
200
+ kind="artifact",
201
+ source="artifact_index",
202
+ ts=ts_iso, # human-readable ISO
203
+ created_at_ts=created_at_ts, # numeric timestamp
204
+ extra=extra_meta,
205
+ )
206
+
207
+ await self.scoped_indices.upsert(
208
+ corpus="artifact",
209
+ item_id=a.artifact_id,
210
+ text=text,
211
+ metadata=meta,
212
+ )
213
+ except Exception:
214
+ import logging
215
+
216
+ logging.getLogger("aethergraph.indices").exception(
217
+ "scoped_indices_artifact_upsert_failed"
218
+ )
219
+
132
220
  # 3) Metering hook for artifact writes
133
221
  try:
134
222
  meter = current_metering()
@@ -158,18 +246,6 @@ class ArtifactFacade:
158
246
  except Exception:
159
247
  return # outside runtime context, nothing to do
160
248
 
161
- # Normalize timestamp
162
- ts: datetime | None
163
- if isinstance(a.created_at, datetime):
164
- ts = a.created_at
165
- elif isinstance(a.created_at, str):
166
- try:
167
- ts = datetime.fromisoformat(a.created_at)
168
- except Exception:
169
- ts = None
170
- else:
171
- ts = None
172
-
173
249
  # Update run metadata
174
250
  run_store = getattr(services, "run_store", None)
175
251
  if run_store is not None and a.run_id:
@@ -178,7 +254,7 @@ class ArtifactFacade:
178
254
  await record_artifact(
179
255
  a.run_id,
180
256
  artifact_id=a.artifact_id,
181
- created_at=ts,
257
+ created_at=created_dt,
182
258
  )
183
259
 
184
260
  # Update session metadata
@@ -189,7 +265,7 @@ class ArtifactFacade:
189
265
  if callable(sess_record_artifact):
190
266
  await sess_record_artifact(
191
267
  session_id,
192
- created_at=ts,
268
+ created_at=created_dt,
193
269
  )
194
270
 
195
271
  # ---------- core staging/ingest ----------
@@ -823,6 +899,53 @@ class ArtifactFacade:
823
899
  text = await self.load_text_by_id(artifact_id, encoding=encoding, errors=errors)
824
900
  return json.loads(text)
825
901
 
902
+ async def load_content(
903
+ self,
904
+ artifact_id: str,
905
+ *,
906
+ encoding: str = "utf-8",
907
+ errors: str = "strict",
908
+ max_bytes: int | None = None,
909
+ ) -> ArtifactContent:
910
+ """
911
+ Docstring for load_content
912
+
913
+ :param self: Description
914
+ :param artifact_id: Description
915
+ :type artifact_id: str
916
+ :param encoding: Description
917
+ :type encoding: str
918
+ :param errors: Description
919
+ :type errors: str
920
+ :param max_bytes: Description
921
+ :type max_bytes: int | None
922
+ :return: Description
923
+ :rtype: ArtifactContent
924
+ """
925
+ art = await self.get_by_id(artifact_id=artifact_id)
926
+ if art is None:
927
+ raise FileNotFoundError(f"Artifact {artifact_id} not found")
928
+
929
+ mode = _infer_content_mode(art)
930
+
931
+ def maybe_truncate(data: bytes) -> bytes:
932
+ if max_bytes is not None and len(data) > max_bytes:
933
+ return data[:max_bytes]
934
+ return data
935
+
936
+ if mode == "json":
937
+ data = await self.load_json_by_id(artifact_id, encoding=encoding, errors=errors)
938
+ return ArtifactContent(artifact=art, mode=mode, json=data)
939
+
940
+ if mode == "text":
941
+ text = await self.load_text_by_id(artifact_id, encoding=encoding, errors=errors)
942
+ return ArtifactContent(artifact=art, mode=mode, text=text)
943
+
944
+ # raw bytes
945
+ raw = await self.load_bytes_by_id(artifact_id)
946
+ raw = maybe_truncate(raw)
947
+ return ArtifactContent(artifact=art, mode="bytes", data=raw)
948
+
826
949
  async def as_local_file_by_id(
827
950
  self,
828
951
  artifact_id: str,
@@ -1279,6 +1402,19 @@ class ArtifactFacade:
1279
1402
  """
1280
1403
  await self.index.pin(artifact_id, pinned=pinned)
1281
1404
 
1405
+ # ---------- search result hydration ----------
1406
+ async def fetch_artifacts_for_search_results(
1407
+ self,
1408
+ scored_items: list[ScoredItem],
1409
+ corpus: str = "artifact",
1410
+ ) -> list[ArtifactSearchResult]:
1411
+ artifact_items = [it for it in scored_items if it.corpus == corpus]
1412
+ results: list[ArtifactSearchResult] = []
1413
+ for it in artifact_items:
1414
+ art = await self.get_by_id(it.item_id)
1415
+ results.append(ArtifactSearchResult(item=it, artifact=art))
1416
+ return results
1417
+
1282
1418
  # ---------- internal helpers ----------
1283
1419
  async def _record_simple(self, a: Artifact) -> None:
1284
1420
  """Record artifact in index and occurrence log."""
@@ -0,0 +1,35 @@
1
+ from dataclasses import dataclass
2
+ from typing import Any, Literal, NamedTuple
3
+
4
+ from aethergraph.contracts.services.artifacts import Artifact
5
+ from aethergraph.contracts.storage.search_backend import ScoredItem
6
+
7
+ ContentMode = Literal["json", "text", "bytes"]
8
+
9
+ ArtifactView = Literal["node", "graph", "run", "all"]
10
+
11
+
12
+ class ArtifactSearchResult(NamedTuple):
13
+ item: ScoredItem # raw search result (score, metadata, etc.)
14
+ artifact: Artifact | None # hydrated Artifact, or None if missing
15
+
16
+ @property
17
+ def id(self) -> str:
18
+ return self.item.item_id
19
+
20
+ @property
21
+ def score(self) -> float:
22
+ return self.item.score
23
+
24
+ @property
25
+ def meta(self) -> dict[str, Any]:
26
+ return self.item.metadata
27
+
28
+
29
+ @dataclass
30
+ class ArtifactContent:
31
+ artifact: Artifact
32
+ mode: ContentMode # "json", "text", or "bytes"
33
+ text: str | None = None
34
+ json: Any | None = None
35
+ data: bytes | None = None
@@ -6,6 +6,9 @@ import json
6
6
  import os
7
7
  from pathlib import Path
8
8
 
9
+ from aethergraph.contracts.services.artifacts import Artifact
10
+ from aethergraph.services.artifacts.types import ContentMode
11
+
9
12
 
10
13
  def now_iso() -> str:
11
14
  return datetime.now(timezone.utc).isoformat()
@@ -122,3 +125,42 @@ def _maybe_cleanup_tmp_parent(tmp_root: str, path: str):
122
125
  parent = os.path.dirname(parent)
123
126
  except Exception:
124
127
  pass
128
+
129
+
130
+ def _infer_content_mode(artifact: Artifact) -> ContentMode:
131
+ """
132
+ Decide how to load this artifact: as JSON, text, or raw bytes.
133
+
134
+ Priority:
135
+ 1) artifact.kind
136
+ 2) filename extension
137
+ 3) default to bytes
138
+ """
139
+ k = (getattr(artifact, "kind", None) or "").lower()
140
+
141
+ # 1) Use explicit kind when we know what to do
142
+ if k == "json":
143
+ return "json"
144
+ if k in {"text", "log", "note"}:
145
+ return "text"
146
+ if k in {"image", "plot", "figure"}:
147
+ return "bytes" # bytes → caller decides what to do with an image
148
+
149
+ # 2) Fall back to filename extension
150
+ filename = None
151
+ if getattr(artifact, "labels", None):
152
+ filename = artifact.labels.get("filename")
153
+ if not filename:
154
+ filename = getattr(artifact, "name", None)
155
+
156
+ if filename:
157
+ ext = Path(filename).suffix.lower()
158
+ if ext == ".json":
159
+ return "json"
160
+ if ext in {".txt", ".md", ".log", ".csv"}:
161
+ return "text"
162
+ if ext in {".png", ".jpg", ".jpeg", ".gif", ".svg", ".webp"}:
163
+ return "bytes"
164
+
165
+ # 3) Unknown → safest is bytes
166
+ return "bytes"
@@ -47,7 +47,9 @@ class ChannelBus:
47
47
  def register_alias(self, alias: str, target: str) -> None:
48
48
  """Register or overwrite a human-friendly alias -> canonical key."""
49
49
  if self._prefix(target) not in self.adapters:
50
- raise RuntimeError(f"Cannot alias to unknown channel prefix: {self._prefix(target)}")
50
+ raise RuntimeError(
51
+ f"Cannot alias to unknown channel prefix: {self._prefix(target)}. Please check if you have enabled the required channel service in .env and registered the adapter."
52
+ )
51
53
 
52
54
  self.channel_aliases[alias] = target
53
55
 
@@ -0,0 +1,55 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ from collections import defaultdict
5
+ from collections.abc import AsyncIterator
6
+ from typing import Any
7
+
8
+
9
+ class EventHub:
10
+ """
11
+ In-memory pub/sub for UI events.
12
+
13
+ - Keys by scope_id (session_id or run_id).
14
+ - Optionally filters by kind (e.g. "session_chat", "run_channel").
15
+ """
16
+
17
+ def __init__(self) -> None:
18
+ self._subscribers: dict[str, set[asyncio.Queue]] = defaultdict(set)
19
+ self._lock = asyncio.Lock()
20
+
21
+ async def subscribe(self, scope_id: str) -> AsyncIterator[dict[str, Any]]:
22
+ """
23
+ Async generator: yields raw EventLog-like rows with keys:
24
+ { "id", "ts", "scope_id", "kind", "payload": {...} }
25
+ """
26
+ q: asyncio.Queue = asyncio.Queue()
27
+ async with self._lock:
28
+ self._subscribers[scope_id].add(q)
29
+
30
+ try:
31
+ while True:
32
+ row = await q.get()
33
+ yield row
34
+ finally:
35
+ async with self._lock:
36
+ self._subscribers[scope_id].discard(q)
37
+ if not self._subscribers[scope_id]:
38
+ self._subscribers.pop(scope_id, None)
39
+
40
+ async def broadcast(self, row: dict[str, Any]) -> None:
41
+ scope_id = row.get("scope_id")
42
+ if not scope_id:
43
+ return
44
+ async with self._lock:
45
+ subs = list(self._subscribers.get(scope_id, []))
46
+ for q in subs:
47
+ # Best-effort; if queue is full we drop rather than block worker
48
+ try:
49
+ q.put_nowait(row)
50
+ except asyncio.QueueFull:
51
+ # log if you want
52
+ import logging
53
+
54
+ logger = logging.getLogger(__name__)
55
+ logger.warning(f"EventHub queue full for scope_id={scope_id}, dropping event")
@@ -0,0 +1,81 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ from collections import defaultdict
5
+ from collections.abc import AsyncIterator
6
+ from contextlib import suppress
7
+ from typing import Any
8
+
9
+ _SENTINEL = object()
10
+
11
+
12
+ class EventHub:
13
+ """
14
+ In-memory pub/sub for UI events.
15
+ Keys by (scope_id, kind).
16
+ """
17
+
18
+ def __init__(self) -> None:
19
+ self._subscribers: dict[tuple[str, str], set[asyncio.Queue]] = defaultdict(set)
20
+ self._lock = asyncio.Lock()
21
+ self._closed = False
22
+
23
+ async def subscribe(
24
+ self,
25
+ *,
26
+ scope_id: str,
27
+ kind: str,
28
+ max_queue: int = 256,
29
+ ) -> AsyncIterator[dict[str, Any]]:
30
+ """
31
+ Async generator that yields rows for (scope_id, kind).
32
+ """
33
+ q: asyncio.Queue = asyncio.Queue(maxsize=max_queue)
34
+
35
+ async with self._lock:
36
+ if self._closed:
37
+ return
38
+ self._subscribers[(scope_id, kind)].add(q)
39
+
40
+ try:
41
+ while True:
42
+ item = await q.get()
43
+ if item is _SENTINEL:
44
+ return
45
+ yield item
46
+ finally:
47
+ async with self._lock:
48
+ self._subscribers[(scope_id, kind)].discard(q)
49
+ if not self._subscribers[(scope_id, kind)]:
50
+ self._subscribers.pop((scope_id, kind), None)
51
+
52
+ async def broadcast(self, row: dict[str, Any]) -> None:
53
+ scope_id = row.get("scope_id")
54
+ kind = row.get("kind")
55
+ if not scope_id or not kind:
56
+ return
57
+
58
+ async with self._lock:
59
+ subs = list(self._subscribers.get((scope_id, kind), []))
60
+
61
+ for q in subs:
62
+ try:
63
+ q.put_nowait(row)
64
+ except asyncio.QueueFull:
65
+ # Drop instead of blocking producer
66
+ continue
67
+
68
+ async def close(self) -> None:
69
+ """
70
+ Wake and detach all subscribers (useful on shutdown/reload).
71
+ """
72
+ async with self._lock:
73
+ self._closed = True
74
+ all_queues = []
75
+ for qs in self._subscribers.values():
76
+ all_queues.extend(list(qs))
77
+ self._subscribers.clear()
78
+
79
+ for q in all_queues:
80
+ with suppress(Exception):
81
+ q.put_nowait(_SENTINEL)
@@ -12,10 +12,11 @@ from aethergraph.plugins.channel.adapters.slack import SlackChannelAdapter
12
12
  from aethergraph.plugins.channel.adapters.telegram import TelegramChannelAdapter
13
13
  from aethergraph.plugins.channel.adapters.webhook import WebhookChannelAdapter
14
14
  from aethergraph.services.channel.channel_bus import ChannelBus
15
+ from aethergraph.services.channel.event_hub import EventHub
15
16
 
16
17
 
17
18
  def make_channel_adapters_from_env(
18
- cfg: AppSettings, event_log: EventLog | None = None
19
+ cfg: AppSettings, event_log: EventLog | None = None, event_hub: EventHub | None = None
19
20
  ) -> dict[str, Any]:
20
21
  # Always include console adapter
21
22
  adapters = {"console": ConsoleChannelAdapter()}
@@ -40,7 +41,7 @@ def make_channel_adapters_from_env(
40
41
 
41
42
  if event_log is None:
42
43
  raise ValueError("event_log must be provided to create WebUIChannelAdapter")
43
- adapters["ui"] = WebUIChannelAdapter(event_log=event_log)
44
+ adapters["ui"] = WebUIChannelAdapter(event_log=event_log, event_hub=event_hub)
44
45
  return adapters
45
46
 
46
47