aethergraph 0.1.0a1__py3-none-any.whl → 0.1.0a3__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.
- aethergraph/__init__.py +4 -10
- aethergraph/__main__.py +296 -0
- aethergraph/api/v1/__init__.py +0 -0
- aethergraph/api/v1/agents.py +46 -0
- aethergraph/api/v1/apps.py +70 -0
- aethergraph/api/v1/artifacts.py +415 -0
- aethergraph/api/v1/channels.py +89 -0
- aethergraph/api/v1/deps.py +168 -0
- aethergraph/api/v1/graphs.py +259 -0
- aethergraph/api/v1/identity.py +25 -0
- aethergraph/api/v1/memory.py +353 -0
- aethergraph/api/v1/misc.py +47 -0
- aethergraph/api/v1/pagination.py +29 -0
- aethergraph/api/v1/runs.py +568 -0
- aethergraph/api/v1/schemas.py +535 -0
- aethergraph/api/v1/session.py +323 -0
- aethergraph/api/v1/stats.py +201 -0
- aethergraph/api/v1/viz.py +152 -0
- aethergraph/config/config.py +22 -0
- aethergraph/config/loader.py +3 -2
- aethergraph/config/storage.py +209 -0
- aethergraph/contracts/__init__.py +0 -0
- aethergraph/contracts/services/__init__.py +0 -0
- aethergraph/contracts/services/artifacts.py +27 -14
- aethergraph/contracts/services/memory.py +45 -17
- aethergraph/contracts/services/metering.py +129 -0
- aethergraph/contracts/services/runs.py +50 -0
- aethergraph/contracts/services/sessions.py +87 -0
- aethergraph/contracts/services/state_stores.py +3 -0
- aethergraph/contracts/services/viz.py +44 -0
- aethergraph/contracts/storage/artifact_index.py +88 -0
- aethergraph/contracts/storage/artifact_store.py +99 -0
- aethergraph/contracts/storage/async_kv.py +34 -0
- aethergraph/contracts/storage/blob_store.py +50 -0
- aethergraph/contracts/storage/doc_store.py +35 -0
- aethergraph/contracts/storage/event_log.py +31 -0
- aethergraph/contracts/storage/vector_index.py +48 -0
- aethergraph/core/__init__.py +0 -0
- aethergraph/core/execution/forward_scheduler.py +13 -2
- aethergraph/core/execution/global_scheduler.py +21 -15
- aethergraph/core/execution/step_forward.py +10 -1
- aethergraph/core/graph/__init__.py +0 -0
- aethergraph/core/graph/graph_builder.py +8 -4
- aethergraph/core/graph/graph_fn.py +156 -15
- aethergraph/core/graph/graph_spec.py +8 -0
- aethergraph/core/graph/graphify.py +146 -27
- aethergraph/core/graph/node_spec.py +0 -2
- aethergraph/core/graph/node_state.py +3 -0
- aethergraph/core/graph/task_graph.py +39 -1
- aethergraph/core/runtime/__init__.py +0 -0
- aethergraph/core/runtime/ad_hoc_context.py +64 -4
- aethergraph/core/runtime/base_service.py +28 -4
- aethergraph/core/runtime/execution_context.py +13 -15
- aethergraph/core/runtime/graph_runner.py +222 -37
- aethergraph/core/runtime/node_context.py +510 -6
- aethergraph/core/runtime/node_services.py +12 -5
- aethergraph/core/runtime/recovery.py +15 -1
- aethergraph/core/runtime/run_manager.py +783 -0
- aethergraph/core/runtime/run_manager_local.py +204 -0
- aethergraph/core/runtime/run_registration.py +2 -2
- aethergraph/core/runtime/run_types.py +89 -0
- aethergraph/core/runtime/runtime_env.py +136 -7
- aethergraph/core/runtime/runtime_metering.py +71 -0
- aethergraph/core/runtime/runtime_registry.py +36 -13
- aethergraph/core/runtime/runtime_services.py +194 -6
- aethergraph/core/tools/builtins/toolset.py +1 -1
- aethergraph/core/tools/toolkit.py +5 -0
- aethergraph/plugins/agents/default_chat_agent copy.py +90 -0
- aethergraph/plugins/agents/default_chat_agent.py +171 -0
- aethergraph/plugins/agents/shared.py +81 -0
- aethergraph/plugins/channel/adapters/webui.py +112 -112
- aethergraph/plugins/channel/routes/webui_routes.py +367 -102
- aethergraph/plugins/channel/utils/slack_utils.py +115 -59
- aethergraph/plugins/channel/utils/telegram_utils.py +88 -47
- aethergraph/plugins/channel/websockets/weibui_ws.py +172 -0
- aethergraph/runtime/__init__.py +15 -0
- aethergraph/server/app_factory.py +196 -34
- aethergraph/server/clients/channel_client.py +202 -0
- aethergraph/server/http/channel_http_routes.py +116 -0
- aethergraph/server/http/channel_ws_routers.py +45 -0
- aethergraph/server/loading.py +117 -0
- aethergraph/server/server.py +131 -0
- aethergraph/server/server_state.py +240 -0
- aethergraph/server/start.py +227 -66
- aethergraph/server/ui_static/assets/KaTeX_AMS-Regular-BQhdFMY1.woff2 +0 -0
- aethergraph/server/ui_static/assets/KaTeX_AMS-Regular-DMm9YOAa.woff +0 -0
- aethergraph/server/ui_static/assets/KaTeX_AMS-Regular-DRggAlZN.ttf +0 -0
- aethergraph/server/ui_static/assets/KaTeX_Caligraphic-Bold-ATXxdsX0.ttf +0 -0
- aethergraph/server/ui_static/assets/KaTeX_Caligraphic-Bold-BEiXGLvX.woff +0 -0
- aethergraph/server/ui_static/assets/KaTeX_Caligraphic-Bold-Dq_IR9rO.woff2 +0 -0
- aethergraph/server/ui_static/assets/KaTeX_Caligraphic-Regular-CTRA-rTL.woff +0 -0
- aethergraph/server/ui_static/assets/KaTeX_Caligraphic-Regular-Di6jR-x-.woff2 +0 -0
- aethergraph/server/ui_static/assets/KaTeX_Caligraphic-Regular-wX97UBjC.ttf +0 -0
- aethergraph/server/ui_static/assets/KaTeX_Fraktur-Bold-BdnERNNW.ttf +0 -0
- aethergraph/server/ui_static/assets/KaTeX_Fraktur-Bold-BsDP51OF.woff +0 -0
- aethergraph/server/ui_static/assets/KaTeX_Fraktur-Bold-CL6g_b3V.woff2 +0 -0
- aethergraph/server/ui_static/assets/KaTeX_Fraktur-Regular-CB_wures.ttf +0 -0
- aethergraph/server/ui_static/assets/KaTeX_Fraktur-Regular-CTYiF6lA.woff2 +0 -0
- aethergraph/server/ui_static/assets/KaTeX_Fraktur-Regular-Dxdc4cR9.woff +0 -0
- aethergraph/server/ui_static/assets/KaTeX_Main-Bold-Cx986IdX.woff2 +0 -0
- aethergraph/server/ui_static/assets/KaTeX_Main-Bold-Jm3AIy58.woff +0 -0
- aethergraph/server/ui_static/assets/KaTeX_Main-Bold-waoOVXN0.ttf +0 -0
- aethergraph/server/ui_static/assets/KaTeX_Main-BoldItalic-DxDJ3AOS.woff2 +0 -0
- aethergraph/server/ui_static/assets/KaTeX_Main-BoldItalic-DzxPMmG6.ttf +0 -0
- aethergraph/server/ui_static/assets/KaTeX_Main-BoldItalic-SpSLRI95.woff +0 -0
- aethergraph/server/ui_static/assets/KaTeX_Main-Italic-3WenGoN9.ttf +0 -0
- aethergraph/server/ui_static/assets/KaTeX_Main-Italic-BMLOBm91.woff +0 -0
- aethergraph/server/ui_static/assets/KaTeX_Main-Italic-NWA7e6Wa.woff2 +0 -0
- aethergraph/server/ui_static/assets/KaTeX_Main-Regular-B22Nviop.woff2 +0 -0
- aethergraph/server/ui_static/assets/KaTeX_Main-Regular-Dr94JaBh.woff +0 -0
- aethergraph/server/ui_static/assets/KaTeX_Main-Regular-ypZvNtVU.ttf +0 -0
- aethergraph/server/ui_static/assets/KaTeX_Math-BoldItalic-B3XSjfu4.ttf +0 -0
- aethergraph/server/ui_static/assets/KaTeX_Math-BoldItalic-CZnvNsCZ.woff2 +0 -0
- aethergraph/server/ui_static/assets/KaTeX_Math-BoldItalic-iY-2wyZ7.woff +0 -0
- aethergraph/server/ui_static/assets/KaTeX_Math-Italic-DA0__PXp.woff +0 -0
- aethergraph/server/ui_static/assets/KaTeX_Math-Italic-flOr_0UB.ttf +0 -0
- aethergraph/server/ui_static/assets/KaTeX_Math-Italic-t53AETM-.woff2 +0 -0
- aethergraph/server/ui_static/assets/KaTeX_SansSerif-Bold-CFMepnvq.ttf +0 -0
- aethergraph/server/ui_static/assets/KaTeX_SansSerif-Bold-D1sUS0GD.woff2 +0 -0
- aethergraph/server/ui_static/assets/KaTeX_SansSerif-Bold-DbIhKOiC.woff +0 -0
- aethergraph/server/ui_static/assets/KaTeX_SansSerif-Italic-C3H0VqGB.woff2 +0 -0
- aethergraph/server/ui_static/assets/KaTeX_SansSerif-Italic-DN2j7dab.woff +0 -0
- aethergraph/server/ui_static/assets/KaTeX_SansSerif-Italic-YYjJ1zSn.ttf +0 -0
- aethergraph/server/ui_static/assets/KaTeX_SansSerif-Regular-BNo7hRIc.ttf +0 -0
- aethergraph/server/ui_static/assets/KaTeX_SansSerif-Regular-CS6fqUqJ.woff +0 -0
- aethergraph/server/ui_static/assets/KaTeX_SansSerif-Regular-DDBCnlJ7.woff2 +0 -0
- aethergraph/server/ui_static/assets/KaTeX_Script-Regular-C5JkGWo-.ttf +0 -0
- aethergraph/server/ui_static/assets/KaTeX_Script-Regular-D3wIWfF6.woff2 +0 -0
- aethergraph/server/ui_static/assets/KaTeX_Script-Regular-D5yQViql.woff +0 -0
- aethergraph/server/ui_static/assets/KaTeX_Size1-Regular-C195tn64.woff +0 -0
- aethergraph/server/ui_static/assets/KaTeX_Size1-Regular-Dbsnue_I.ttf +0 -0
- aethergraph/server/ui_static/assets/KaTeX_Size1-Regular-mCD8mA8B.woff2 +0 -0
- aethergraph/server/ui_static/assets/KaTeX_Size2-Regular-B7gKUWhC.ttf +0 -0
- aethergraph/server/ui_static/assets/KaTeX_Size2-Regular-Dy4dx90m.woff2 +0 -0
- aethergraph/server/ui_static/assets/KaTeX_Size2-Regular-oD1tc_U0.woff +0 -0
- aethergraph/server/ui_static/assets/KaTeX_Size3-Regular-CTq5MqoE.woff +0 -0
- aethergraph/server/ui_static/assets/KaTeX_Size3-Regular-DgpXs0kz.ttf +0 -0
- aethergraph/server/ui_static/assets/KaTeX_Size4-Regular-BF-4gkZK.woff +0 -0
- aethergraph/server/ui_static/assets/KaTeX_Size4-Regular-DWFBv043.ttf +0 -0
- aethergraph/server/ui_static/assets/KaTeX_Size4-Regular-Dl5lxZxV.woff2 +0 -0
- aethergraph/server/ui_static/assets/KaTeX_Typewriter-Regular-C0xS9mPB.woff +0 -0
- aethergraph/server/ui_static/assets/KaTeX_Typewriter-Regular-CO6r4hn1.woff2 +0 -0
- aethergraph/server/ui_static/assets/KaTeX_Typewriter-Regular-D3Ib7_Hf.ttf +0 -0
- aethergraph/server/ui_static/assets/index-BR5GtXcZ.css +1 -0
- aethergraph/server/ui_static/assets/index-CQ0HZZ83.js +400 -0
- aethergraph/server/ui_static/index.html +15 -0
- aethergraph/server/ui_static/logo.png +0 -0
- aethergraph/services/artifacts/__init__.py +0 -0
- aethergraph/services/artifacts/facade.py +1239 -132
- aethergraph/services/auth/{dev.py → authn.py} +0 -8
- aethergraph/services/auth/authz.py +100 -0
- aethergraph/services/channel/__init__.py +0 -0
- aethergraph/services/channel/channel_bus.py +19 -1
- aethergraph/services/channel/factory.py +13 -1
- aethergraph/services/channel/ingress.py +311 -0
- aethergraph/services/channel/queue_adapter.py +75 -0
- aethergraph/services/channel/session.py +502 -19
- aethergraph/services/container/default_container.py +122 -43
- aethergraph/services/continuations/continuation.py +6 -0
- aethergraph/services/continuations/stores/fs_store.py +19 -0
- aethergraph/services/eventhub/event_hub.py +76 -0
- aethergraph/services/kv/__init__.py +0 -0
- aethergraph/services/kv/ephemeral.py +244 -0
- aethergraph/services/llm/__init__.py +0 -0
- aethergraph/services/llm/generic_client copy.py +691 -0
- aethergraph/services/llm/generic_client.py +1288 -187
- aethergraph/services/llm/providers.py +3 -1
- aethergraph/services/llm/types.py +47 -0
- aethergraph/services/llm/utils.py +284 -0
- aethergraph/services/logger/std.py +3 -0
- aethergraph/services/mcp/__init__.py +9 -0
- aethergraph/services/mcp/http_client.py +38 -0
- aethergraph/services/mcp/service.py +225 -1
- aethergraph/services/mcp/stdio_client.py +41 -6
- aethergraph/services/mcp/ws_client.py +44 -2
- aethergraph/services/memory/__init__.py +0 -0
- aethergraph/services/memory/distillers/llm_long_term.py +234 -0
- aethergraph/services/memory/distillers/llm_meta_summary.py +398 -0
- aethergraph/services/memory/distillers/long_term.py +225 -0
- aethergraph/services/memory/facade/__init__.py +3 -0
- aethergraph/services/memory/facade/chat.py +440 -0
- aethergraph/services/memory/facade/core.py +447 -0
- aethergraph/services/memory/facade/distillation.py +424 -0
- aethergraph/services/memory/facade/rag.py +410 -0
- aethergraph/services/memory/facade/results.py +315 -0
- aethergraph/services/memory/facade/retrieval.py +139 -0
- aethergraph/services/memory/facade/types.py +77 -0
- aethergraph/services/memory/facade/utils.py +43 -0
- aethergraph/services/memory/facade_dep.py +1539 -0
- aethergraph/services/memory/factory.py +9 -3
- aethergraph/services/memory/utils.py +10 -0
- aethergraph/services/metering/eventlog_metering.py +470 -0
- aethergraph/services/metering/noop.py +25 -4
- aethergraph/services/rag/__init__.py +0 -0
- aethergraph/services/rag/facade.py +279 -23
- aethergraph/services/rag/index_factory.py +2 -2
- aethergraph/services/rag/node_rag.py +317 -0
- aethergraph/services/rate_limit/inmem_rate_limit.py +24 -0
- aethergraph/services/registry/__init__.py +0 -0
- aethergraph/services/registry/agent_app_meta.py +419 -0
- aethergraph/services/registry/registry_key.py +1 -1
- aethergraph/services/registry/unified_registry.py +74 -6
- aethergraph/services/scope/scope.py +159 -0
- aethergraph/services/scope/scope_factory.py +164 -0
- aethergraph/services/state_stores/serialize.py +5 -0
- aethergraph/services/state_stores/utils.py +2 -1
- aethergraph/services/viz/__init__.py +0 -0
- aethergraph/services/viz/facade.py +413 -0
- aethergraph/services/viz/viz_service.py +69 -0
- aethergraph/storage/artifacts/artifact_index_jsonl.py +180 -0
- aethergraph/storage/artifacts/artifact_index_sqlite.py +426 -0
- aethergraph/storage/artifacts/cas_store.py +422 -0
- aethergraph/storage/artifacts/fs_cas.py +18 -0
- aethergraph/storage/artifacts/s3_cas.py +14 -0
- aethergraph/storage/artifacts/utils.py +124 -0
- aethergraph/storage/blob/fs_blob.py +86 -0
- aethergraph/storage/blob/s3_blob.py +115 -0
- aethergraph/storage/continuation_store/fs_cont.py +283 -0
- aethergraph/storage/continuation_store/inmem_cont.py +146 -0
- aethergraph/storage/continuation_store/kvdoc_cont.py +261 -0
- aethergraph/storage/docstore/fs_doc.py +63 -0
- aethergraph/storage/docstore/sqlite_doc.py +31 -0
- aethergraph/storage/docstore/sqlite_doc_sync.py +90 -0
- aethergraph/storage/eventlog/fs_event.py +136 -0
- aethergraph/storage/eventlog/sqlite_event.py +47 -0
- aethergraph/storage/eventlog/sqlite_event_sync.py +178 -0
- aethergraph/storage/factory.py +432 -0
- aethergraph/storage/fs_utils.py +28 -0
- aethergraph/storage/graph_state_store/state_store.py +64 -0
- aethergraph/storage/kv/inmem_kv.py +103 -0
- aethergraph/storage/kv/layered_kv.py +52 -0
- aethergraph/storage/kv/sqlite_kv.py +39 -0
- aethergraph/storage/kv/sqlite_kv_sync.py +98 -0
- aethergraph/storage/memory/event_persist.py +68 -0
- aethergraph/storage/memory/fs_persist.py +118 -0
- aethergraph/{services/memory/hotlog_kv.py → storage/memory/hotlog.py} +8 -2
- aethergraph/{services → storage}/memory/indices.py +31 -7
- aethergraph/storage/metering/meter_event.py +55 -0
- aethergraph/storage/runs/doc_store.py +280 -0
- aethergraph/storage/runs/inmen_store.py +82 -0
- aethergraph/storage/runs/sqlite_run_store.py +403 -0
- aethergraph/storage/sessions/doc_store.py +183 -0
- aethergraph/storage/sessions/inmem_store.py +110 -0
- aethergraph/storage/sessions/sqlite_session_store.py +399 -0
- aethergraph/storage/vector_index/chroma_index.py +138 -0
- aethergraph/storage/vector_index/faiss_index.py +179 -0
- aethergraph/storage/vector_index/sqlite_index.py +187 -0
- {aethergraph-0.1.0a1.dist-info → aethergraph-0.1.0a3.dist-info}/METADATA +138 -31
- aethergraph-0.1.0a3.dist-info/RECORD +356 -0
- aethergraph-0.1.0a3.dist-info/entry_points.txt +3 -0
- aethergraph/services/artifacts/factory.py +0 -35
- aethergraph/services/artifacts/fs_store.py +0 -656
- aethergraph/services/artifacts/jsonl_index.py +0 -123
- aethergraph/services/artifacts/sqlite_index.py +0 -209
- aethergraph/services/memory/distillers/episode.py +0 -116
- aethergraph/services/memory/distillers/rolling.py +0 -74
- aethergraph/services/memory/facade.py +0 -633
- aethergraph/services/memory/persist_fs.py +0 -40
- aethergraph/services/rag/index/base.py +0 -27
- aethergraph/services/rag/index/faiss_index.py +0 -121
- aethergraph/services/rag/index/sqlite_index.py +0 -134
- aethergraph-0.1.0a1.dist-info/RECORD +0 -182
- aethergraph-0.1.0a1.dist-info/entry_points.txt +0 -2
- {aethergraph-0.1.0a1.dist-info → aethergraph-0.1.0a3.dist-info}/WHEEL +0 -0
- {aethergraph-0.1.0a1.dist-info → aethergraph-0.1.0a3.dist-info}/licenses/LICENSE +0 -0
- {aethergraph-0.1.0a1.dist-info → aethergraph-0.1.0a3.dist-info}/licenses/NOTICE +0 -0
- {aethergraph-0.1.0a1.dist-info → aethergraph-0.1.0a3.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
from datetime import datetime, timezone
|
|
2
|
+
|
|
3
|
+
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
|
4
|
+
|
|
5
|
+
from aethergraph.api.v1.deps import RequestIdentity, get_identity
|
|
6
|
+
from aethergraph.api.v1.pagination import decode_cursor, encode_cursor
|
|
7
|
+
from aethergraph.api.v1.runs import _extract_app_id_from_tags
|
|
8
|
+
from aethergraph.api.v1.schemas import (
|
|
9
|
+
RunSummary,
|
|
10
|
+
Session,
|
|
11
|
+
SessionChatEvent,
|
|
12
|
+
SessionCreateRequest,
|
|
13
|
+
SessionListResponse,
|
|
14
|
+
SessionRunsResponse,
|
|
15
|
+
SessionUpdateRequest,
|
|
16
|
+
)
|
|
17
|
+
from aethergraph.core.runtime.run_types import RunImportance, RunVisibility, SessionKind
|
|
18
|
+
from aethergraph.core.runtime.runtime_registry import current_registry
|
|
19
|
+
from aethergraph.core.runtime.runtime_services import current_services
|
|
20
|
+
|
|
21
|
+
router = APIRouter(tags=["sessions"])
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@router.post("/sessions", response_model=Session)
|
|
25
|
+
async def create_session(
|
|
26
|
+
body: SessionCreateRequest,
|
|
27
|
+
identity: RequestIdentity = Depends(get_identity), # noqa: B008
|
|
28
|
+
) -> Session:
|
|
29
|
+
"""
|
|
30
|
+
Create a new session.
|
|
31
|
+
"""
|
|
32
|
+
container = current_services()
|
|
33
|
+
ss = getattr(container, "session_store", None)
|
|
34
|
+
if ss is None:
|
|
35
|
+
raise HTTPException(status_code=500, detail="SessionStore not available")
|
|
36
|
+
|
|
37
|
+
sess = await ss.create(
|
|
38
|
+
kind=body.kind,
|
|
39
|
+
title=body.title,
|
|
40
|
+
external_ref=body.external_ref,
|
|
41
|
+
user_id=identity.user_id,
|
|
42
|
+
org_id=identity.org_id,
|
|
43
|
+
source="webui",
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
return sess
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@router.get("/sessions", response_model=SessionListResponse)
|
|
50
|
+
async def list_sessions(
|
|
51
|
+
kind: SessionKind | None = Query(None, description="Filter sessions by kind"), # noqa: B008
|
|
52
|
+
limit: int = Query(50, ge=1, le=1000), # noqa: B008
|
|
53
|
+
cursor: str | None = Query(None), # noqa: B008
|
|
54
|
+
identity: RequestIdentity = Depends(get_identity), # noqa: B008
|
|
55
|
+
) -> SessionListResponse:
|
|
56
|
+
"""
|
|
57
|
+
List sessions for the current user/org, optionally filtered by kind.
|
|
58
|
+
"""
|
|
59
|
+
container = current_services()
|
|
60
|
+
ss = getattr(container, "session_store", None)
|
|
61
|
+
if ss is None:
|
|
62
|
+
raise HTTPException(status_code=500, detail="SessionStore not available")
|
|
63
|
+
|
|
64
|
+
offset = decode_cursor(cursor)
|
|
65
|
+
|
|
66
|
+
# Enforce identity for cloud/demo
|
|
67
|
+
if identity.mode in ("cloud", "demo") and identity.user_id is None:
|
|
68
|
+
raise HTTPException(status_code=403, detail="User identity required")
|
|
69
|
+
|
|
70
|
+
sessions = await ss.list_for_user(
|
|
71
|
+
user_id=identity.user_id if identity.mode in ("cloud", "demo") else identity.user_id,
|
|
72
|
+
org_id=identity.org_id if identity.mode in ("cloud", "demo") else identity.org_id,
|
|
73
|
+
kind=kind,
|
|
74
|
+
limit=limit,
|
|
75
|
+
offset=offset,
|
|
76
|
+
)
|
|
77
|
+
# print(f"Listed {len(sessions)} sessions for user_id={identity.user_id} org_id={identity.org_id} offset={offset} limit={limit}")
|
|
78
|
+
# print(f"Sessions: {[s for s in sessions]}")
|
|
79
|
+
next_cursor = encode_cursor(offset + limit) if len(sessions) == limit else None
|
|
80
|
+
return SessionListResponse(items=sessions, next_cursor=next_cursor)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@router.get("/sessions/{session_id}", response_model=Session)
|
|
84
|
+
async def get_session(
|
|
85
|
+
session_id: str,
|
|
86
|
+
identity: RequestIdentity = Depends(get_identity), # noqa: B008
|
|
87
|
+
) -> Session:
|
|
88
|
+
container = current_services()
|
|
89
|
+
ss = getattr(container, "session_store", None)
|
|
90
|
+
if ss is None:
|
|
91
|
+
raise HTTPException(status_code=500, detail="SessionStore not available")
|
|
92
|
+
|
|
93
|
+
sess = await ss.get(session_id)
|
|
94
|
+
if sess is None:
|
|
95
|
+
raise HTTPException(status_code=404, detail="Session not found")
|
|
96
|
+
|
|
97
|
+
# Optional: enforce that the session belongs to the user/org
|
|
98
|
+
if identity.mode != "local":
|
|
99
|
+
if identity.user_id and sess.user_id is not None and sess.user_id != identity.user_id:
|
|
100
|
+
raise HTTPException(status_code=403, detail="Access denied")
|
|
101
|
+
|
|
102
|
+
if identity.org_id and sess.org_id is not None and sess.org_id != identity.org_id:
|
|
103
|
+
raise HTTPException(status_code=403, detail="Access denied")
|
|
104
|
+
return sess
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
@router.get("/sessions/{session_id}/runs", response_model=SessionRunsResponse)
|
|
108
|
+
async def get_session_runs(
|
|
109
|
+
session_id: str,
|
|
110
|
+
include_inline: bool = Query(False), # noqa: B008
|
|
111
|
+
identity: RequestIdentity = Depends(get_identity), # noqa: B008
|
|
112
|
+
) -> SessionRunsResponse:
|
|
113
|
+
container = current_services()
|
|
114
|
+
ss = getattr(container, "session_store", None)
|
|
115
|
+
rm = getattr(container, "run_manager", None)
|
|
116
|
+
if ss is None:
|
|
117
|
+
raise HTTPException(status_code=500, detail="SessionStore not available")
|
|
118
|
+
if rm is None:
|
|
119
|
+
raise HTTPException(status_code=500, detail="RunManager not available")
|
|
120
|
+
|
|
121
|
+
# Make sure the session exists and belongs to this user/org
|
|
122
|
+
sess = await ss.get(session_id)
|
|
123
|
+
if sess is None:
|
|
124
|
+
raise HTTPException(status_code=404, detail="Session not found")
|
|
125
|
+
|
|
126
|
+
if identity.mode != "local":
|
|
127
|
+
if identity.user_id and sess.user_id is not None and sess.user_id != identity.user_id:
|
|
128
|
+
raise HTTPException(status_code=403, detail="Access denied")
|
|
129
|
+
if identity.org_id and sess.org_id is not None and sess.org_id != identity.org_id:
|
|
130
|
+
raise HTTPException(status_code=403, detail="Access denied")
|
|
131
|
+
|
|
132
|
+
# For now, just scan recent runs and filter by session_id
|
|
133
|
+
# Later, we need a dedicated index/query in RunStore
|
|
134
|
+
records = await rm.list_records(
|
|
135
|
+
graph_id=None,
|
|
136
|
+
status=None,
|
|
137
|
+
session_id=session_id,
|
|
138
|
+
flow_id=None,
|
|
139
|
+
limit=1000,
|
|
140
|
+
offset=0,
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
# 🔹 Visibility & importance policy for session views:
|
|
144
|
+
# - Always require importance == normal (ephemeral hidden for now).
|
|
145
|
+
# - If include_inline is False:
|
|
146
|
+
# include only visibility == normal
|
|
147
|
+
# Else:
|
|
148
|
+
# include visibility in {normal, inline}
|
|
149
|
+
visible_states = {RunVisibility.normal}
|
|
150
|
+
if include_inline:
|
|
151
|
+
visible_states.add(RunVisibility.inline)
|
|
152
|
+
|
|
153
|
+
records = [
|
|
154
|
+
rec
|
|
155
|
+
for rec in records
|
|
156
|
+
if rec.visibility in visible_states and rec.importance == RunImportance.normal
|
|
157
|
+
]
|
|
158
|
+
|
|
159
|
+
reg = getattr(container, "registry", None) or current_registry()
|
|
160
|
+
summaries: list[RunSummary] = []
|
|
161
|
+
|
|
162
|
+
for rec in records:
|
|
163
|
+
# defaults to avoid UnboundLocalError if reg is None
|
|
164
|
+
flow_id: str | None = None
|
|
165
|
+
entrypoint = False
|
|
166
|
+
|
|
167
|
+
if reg is not None:
|
|
168
|
+
if rec.kind == "taskgraph":
|
|
169
|
+
meta = reg.get_meta(nspace="graph", name=rec.graph_id, version=None) or {}
|
|
170
|
+
elif rec.kind == "graphfn":
|
|
171
|
+
meta = reg.get_meta(nspace="graphfn", name=rec.graph_id, version=None) or {}
|
|
172
|
+
else:
|
|
173
|
+
meta = {}
|
|
174
|
+
|
|
175
|
+
flow_id = meta.get("flow_id")
|
|
176
|
+
entrypoint = bool(meta.get("entrypoint", False))
|
|
177
|
+
|
|
178
|
+
# derive app info
|
|
179
|
+
app_id = rec.meta.get("app_id") or _extract_app_id_from_tags(rec.tags)
|
|
180
|
+
app_name = rec.meta.get("app_name")
|
|
181
|
+
|
|
182
|
+
summaries.append(
|
|
183
|
+
RunSummary(
|
|
184
|
+
run_id=rec.run_id,
|
|
185
|
+
graph_id=rec.graph_id,
|
|
186
|
+
status=rec.status,
|
|
187
|
+
started_at=rec.started_at,
|
|
188
|
+
finished_at=rec.finished_at,
|
|
189
|
+
tags=rec.tags,
|
|
190
|
+
user_id=rec.user_id,
|
|
191
|
+
org_id=rec.org_id,
|
|
192
|
+
graph_kind=rec.kind,
|
|
193
|
+
flow_id=flow_id,
|
|
194
|
+
entrypoint=entrypoint,
|
|
195
|
+
meta=rec.meta or {},
|
|
196
|
+
app_id=app_id,
|
|
197
|
+
app_name=app_name,
|
|
198
|
+
session_id=rec.session_id,
|
|
199
|
+
origin=rec.origin,
|
|
200
|
+
visibility=rec.visibility,
|
|
201
|
+
importance=rec.importance,
|
|
202
|
+
agent_id=rec.agent_id,
|
|
203
|
+
)
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
return SessionRunsResponse(items=summaries)
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
@router.get("/sessions/{session_id}/chat/events", response_model=list[SessionChatEvent])
|
|
210
|
+
async def get_session_chat_events(
|
|
211
|
+
session_id: str,
|
|
212
|
+
request: Request,
|
|
213
|
+
since_ts: float | None = Query(None), # noqa: B008
|
|
214
|
+
identity: RequestIdentity = Depends(get_identity), # noqa: B008
|
|
215
|
+
) -> list[SessionChatEvent]:
|
|
216
|
+
container = current_services()
|
|
217
|
+
event_log = container.eventlog
|
|
218
|
+
|
|
219
|
+
if event_log is None:
|
|
220
|
+
raise HTTPException(status_code=503, detail="EventLog not available")
|
|
221
|
+
|
|
222
|
+
since_dt: datetime | None = None
|
|
223
|
+
if since_ts is not None:
|
|
224
|
+
since_dt = datetime.fromtimestamp(since_ts, tz=timezone.utc)
|
|
225
|
+
|
|
226
|
+
events = await event_log.query(
|
|
227
|
+
scope_id=session_id,
|
|
228
|
+
since=since_dt,
|
|
229
|
+
kinds=["session_chat"],
|
|
230
|
+
limit=1000,
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
if since_ts is not None:
|
|
234
|
+
# make cursor exclusive -- only return events after since_ts to avoid duplicates
|
|
235
|
+
events = [ev for ev in events if (ev.get("ts") or 0) > since_ts]
|
|
236
|
+
|
|
237
|
+
out: list[SessionChatEvent] = []
|
|
238
|
+
for ev in events:
|
|
239
|
+
payload = ev.get("payload", {}) or {}
|
|
240
|
+
out.append(
|
|
241
|
+
SessionChatEvent(
|
|
242
|
+
id=ev.get("id"),
|
|
243
|
+
session_id=session_id,
|
|
244
|
+
ts=ev.get("ts"),
|
|
245
|
+
type=payload.get("type") or "agent.message",
|
|
246
|
+
text=payload.get("text"),
|
|
247
|
+
buttons=payload.get("buttons", []),
|
|
248
|
+
file=payload.get("file"), # may be None
|
|
249
|
+
files=payload.get("files") or None, # forward list
|
|
250
|
+
meta=payload.get("meta", {}) or {},
|
|
251
|
+
agent_id=payload.get("agent_id"),
|
|
252
|
+
upsert_key=payload.get("upsert_key"), # forward idempotent key
|
|
253
|
+
)
|
|
254
|
+
)
|
|
255
|
+
out.sort(key=lambda e: e.ts)
|
|
256
|
+
|
|
257
|
+
return out
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
@router.patch("/sessions/{session_id}", response_model=Session)
|
|
261
|
+
async def update_session(
|
|
262
|
+
session_id: str,
|
|
263
|
+
body: SessionUpdateRequest,
|
|
264
|
+
identity: RequestIdentity = Depends(get_identity), # noqa: B008
|
|
265
|
+
) -> Session:
|
|
266
|
+
container = current_services()
|
|
267
|
+
ss = getattr(container, "session_store", None)
|
|
268
|
+
if ss is None:
|
|
269
|
+
raise HTTPException(status_code=500, detail="SessionStore not available")
|
|
270
|
+
|
|
271
|
+
existing = await ss.get(session_id)
|
|
272
|
+
if existing is None:
|
|
273
|
+
raise HTTPException(status_code=404, detail="Session not found")
|
|
274
|
+
|
|
275
|
+
# Enforce ownership for non-local modes
|
|
276
|
+
if identity.mode != "local":
|
|
277
|
+
if (
|
|
278
|
+
identity.user_id
|
|
279
|
+
and existing.user_id is not None
|
|
280
|
+
and existing.user_id != identity.user_id
|
|
281
|
+
):
|
|
282
|
+
raise HTTPException(status_code=403, detail="Access denied")
|
|
283
|
+
if identity.org_id and existing.org_id is not None and existing.org_id != identity.org_id:
|
|
284
|
+
raise HTTPException(status_code=403, detail="Access denied")
|
|
285
|
+
|
|
286
|
+
updated = await ss.update(
|
|
287
|
+
session_id,
|
|
288
|
+
title=body.title,
|
|
289
|
+
external_ref=body.external_ref,
|
|
290
|
+
)
|
|
291
|
+
if updated is None:
|
|
292
|
+
# Defensive; shouldn't happen given we already fetched it
|
|
293
|
+
raise HTTPException(status_code=404, detail="Session not found")
|
|
294
|
+
|
|
295
|
+
return updated
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
@router.delete("/sessions/{session_id}", status_code=204)
|
|
299
|
+
async def delete_session(
|
|
300
|
+
session_id: str,
|
|
301
|
+
identity: RequestIdentity = Depends(get_identity), # noqa: B008
|
|
302
|
+
) -> None:
|
|
303
|
+
container = current_services()
|
|
304
|
+
ss = getattr(container, "session_store", None)
|
|
305
|
+
if ss is None:
|
|
306
|
+
raise HTTPException(status_code=500, detail="SessionStore not available")
|
|
307
|
+
|
|
308
|
+
existing = await ss.get(session_id)
|
|
309
|
+
if existing is None:
|
|
310
|
+
# 204 for idempotent delete
|
|
311
|
+
return
|
|
312
|
+
|
|
313
|
+
if identity.mode != "local":
|
|
314
|
+
if (
|
|
315
|
+
identity.user_id
|
|
316
|
+
and existing.user_id is not None
|
|
317
|
+
and existing.user_id != identity.user_id
|
|
318
|
+
):
|
|
319
|
+
raise HTTPException(status_code=403, detail="Access denied")
|
|
320
|
+
if identity.org_id and existing.org_id is not None and existing.org_id != identity.org_id:
|
|
321
|
+
raise HTTPException(status_code=403, detail="Access denied")
|
|
322
|
+
|
|
323
|
+
await ss.delete(session_id)
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
from collections.abc import Iterable
|
|
2
|
+
from typing import Annotated, Any
|
|
3
|
+
|
|
4
|
+
from fastapi import APIRouter, Depends, HTTPException, Query
|
|
5
|
+
|
|
6
|
+
from aethergraph.core.runtime.runtime_services import current_services
|
|
7
|
+
|
|
8
|
+
from .deps import RequestIdentity, get_identity
|
|
9
|
+
from .schemas import (
|
|
10
|
+
ArtifactStats,
|
|
11
|
+
GraphStats,
|
|
12
|
+
GraphStatsEntry,
|
|
13
|
+
LLMStats,
|
|
14
|
+
MemoryStats,
|
|
15
|
+
StatsOverview,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
router = APIRouter(tags=["stats"])
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
# This is demo-only; real multi-tenant setups should rely on user_id/org_id instead.
|
|
22
|
+
def _has_client_tag(tags: Iterable[str] | None, client_id: str | None) -> bool:
|
|
23
|
+
if not client_id:
|
|
24
|
+
return True
|
|
25
|
+
if not tags:
|
|
26
|
+
return False
|
|
27
|
+
needle = f"client:{client_id}"
|
|
28
|
+
return any(t == needle for t in tags)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
async def _get_run_ids_for_client(
|
|
32
|
+
client_id: str | None,
|
|
33
|
+
limit: int = 500,
|
|
34
|
+
) -> set[str]:
|
|
35
|
+
"""
|
|
36
|
+
TEMP: demo-only helper.
|
|
37
|
+
Look up recent runs and filter by client:<id> tag.
|
|
38
|
+
"""
|
|
39
|
+
if not client_id:
|
|
40
|
+
return set()
|
|
41
|
+
|
|
42
|
+
container = current_services()
|
|
43
|
+
rm = getattr(container, "run_manager", None)
|
|
44
|
+
if rm is None:
|
|
45
|
+
return set()
|
|
46
|
+
|
|
47
|
+
records = await rm.list_records(
|
|
48
|
+
graph_id=None,
|
|
49
|
+
status=None,
|
|
50
|
+
flow_id=None,
|
|
51
|
+
limit=limit,
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
return {r.run_id for r in records if _has_client_tag(r.tags, client_id)}
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@router.get("/stats/overview", response_model=StatsOverview)
|
|
58
|
+
async def get_stats_overview(
|
|
59
|
+
window: Annotated[str, Query(description="Time window for stats, e.g., '24h', '7d'")] = "24h",
|
|
60
|
+
identity: RequestIdentity = Depends(get_identity), # noqa: B008
|
|
61
|
+
) -> StatsOverview:
|
|
62
|
+
"""
|
|
63
|
+
Get an overview of usage statistics.
|
|
64
|
+
|
|
65
|
+
- **window**: Time window for stats (e.g., "24h", "7d").
|
|
66
|
+
"""
|
|
67
|
+
container = current_services()
|
|
68
|
+
meter = getattr(container, "metering", None)
|
|
69
|
+
if meter is None:
|
|
70
|
+
raise HTTPException(status_code=501, detail="Metering service not available")
|
|
71
|
+
|
|
72
|
+
raw: dict[str, int] = await meter.get_overview(
|
|
73
|
+
user_id=identity.user_id if identity and identity.user_id else None,
|
|
74
|
+
org_id=identity.org_id if identity and identity.org_id else None,
|
|
75
|
+
window=window,
|
|
76
|
+
)
|
|
77
|
+
return StatsOverview(**raw)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@router.get("/stats/graphs", response_model=GraphStats)
|
|
81
|
+
async def get_graphs_stats(
|
|
82
|
+
window: Annotated[str, Query(description="Time window for stats, e.g., '24h', '7d'")] = "24h",
|
|
83
|
+
graph_id: Annotated[str | None, Query(description="Optional graph_id filter")] = None,
|
|
84
|
+
identity: RequestIdentity = Depends(get_identity), # noqa: B008
|
|
85
|
+
) -> GraphStats:
|
|
86
|
+
"""
|
|
87
|
+
Get usage statistics for graphs.
|
|
88
|
+
|
|
89
|
+
- **window**: Time window for stats (e.g., "24h", "7d").
|
|
90
|
+
- **graph_id**: Optional filter; if provided, only stats for that graph are returned.
|
|
91
|
+
"""
|
|
92
|
+
container = current_services()
|
|
93
|
+
meter = getattr(container, "metering", None)
|
|
94
|
+
if meter is None:
|
|
95
|
+
raise HTTPException(status_code=501, detail="Metering service not available")
|
|
96
|
+
|
|
97
|
+
raw_all: dict[str, dict[str, Any]] = await meter.get_graph_stats(
|
|
98
|
+
user_id=identity.user_id if identity and identity.user_id else None,
|
|
99
|
+
org_id=identity.org_id if identity and identity.org_id else None,
|
|
100
|
+
window=window,
|
|
101
|
+
)
|
|
102
|
+
# raw_all: { "<graph_id>": {"runs":..., "succeeded":..., "failed":..., "total_duration_s":...}, ... }
|
|
103
|
+
|
|
104
|
+
if graph_id is not None:
|
|
105
|
+
# Return only the requested graph, but still in map form
|
|
106
|
+
entry = raw_all.get(graph_id, {})
|
|
107
|
+
filtered: dict[str, dict[str, Any]] = {
|
|
108
|
+
graph_id: {
|
|
109
|
+
"runs": int(entry.get("runs", 0)),
|
|
110
|
+
"succeeded": int(entry.get("succeeded", 0)),
|
|
111
|
+
"failed": int(entry.get("failed", 0)),
|
|
112
|
+
"total_duration_s": float(entry.get("total_duration_s", 0.0)),
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
return GraphStats(root={gid: GraphStatsEntry(**vals) for gid, vals in filtered.items()})
|
|
116
|
+
|
|
117
|
+
# Normalize all entries to GraphStatsEntry
|
|
118
|
+
normalized: dict[str, GraphStatsEntry] = {}
|
|
119
|
+
for gid, vals in raw_all.items():
|
|
120
|
+
normalized[gid] = GraphStatsEntry(
|
|
121
|
+
runs=int(vals.get("runs", 0)),
|
|
122
|
+
succeeded=int(vals.get("succeeded", 0)),
|
|
123
|
+
failed=int(vals.get("failed", 0)),
|
|
124
|
+
total_duration_s=float(vals.get("total_duration_s", 0.0)),
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
return GraphStats(root=normalized)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
@router.get("/stats/memory", response_model=MemoryStats)
|
|
131
|
+
async def get_memory_stats(
|
|
132
|
+
scope_id: Annotated[str | None, Query(description="Logical memory scope (optional)")] = None,
|
|
133
|
+
window: Annotated[str, Query(description="Time window, e.g., '24h', '7d'")] = "24h",
|
|
134
|
+
identity: RequestIdentity = Depends(get_identity), # noqa: B008
|
|
135
|
+
) -> MemoryStats:
|
|
136
|
+
"""
|
|
137
|
+
Get memory usage statistics.
|
|
138
|
+
|
|
139
|
+
- **scope_id**: Logical memory scope (optional).
|
|
140
|
+
- **window**: Time window for stats (e.g., "24h", "7d").
|
|
141
|
+
"""
|
|
142
|
+
container = current_services()
|
|
143
|
+
meter = getattr(container, "metering", None)
|
|
144
|
+
|
|
145
|
+
if meter is None:
|
|
146
|
+
raise HTTPException(status_code=501, detail="Metering service not available")
|
|
147
|
+
|
|
148
|
+
raw: dict[str, dict[str, int]] = await meter.get_memory_stats(
|
|
149
|
+
scope_id=scope_id,
|
|
150
|
+
user_id=identity.user_id if identity and identity.user_id else None,
|
|
151
|
+
org_id=identity.org_id if identity and identity.org_id else None,
|
|
152
|
+
window=window,
|
|
153
|
+
)
|
|
154
|
+
# raw: { "memory.user_msg": {"count": N}, ... }
|
|
155
|
+
return MemoryStats(root=raw)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
@router.get("/stats/artifacts", response_model=ArtifactStats)
|
|
159
|
+
async def get_artifacts_stats(
|
|
160
|
+
window: Annotated[str, Query(description="Time window, e.g., '24h', '7d'")] = "24h",
|
|
161
|
+
identity: RequestIdentity = Depends(get_identity), # noqa: B008
|
|
162
|
+
) -> ArtifactStats:
|
|
163
|
+
"""
|
|
164
|
+
Aggregate artifact stats for this user/org: counts, bytes, pinned, etc.
|
|
165
|
+
Backed by MeteringService.get_artifact_stats().
|
|
166
|
+
"""
|
|
167
|
+
container = current_services()
|
|
168
|
+
meter = getattr(container, "metering", None)
|
|
169
|
+
if meter is None:
|
|
170
|
+
raise HTTPException(status_code=501, detail="Metering service not available")
|
|
171
|
+
|
|
172
|
+
raw: dict[str, dict[str, int]] = await meter.get_artifact_stats(
|
|
173
|
+
user_id=identity.user_id if identity and identity.user_id else None,
|
|
174
|
+
org_id=identity.org_id if identity and identity.org_id else None,
|
|
175
|
+
window=window,
|
|
176
|
+
)
|
|
177
|
+
# raw: { "json": {"count":..., "bytes":..., "pinned_count":..., "pinned_bytes":...}, ... }
|
|
178
|
+
return ArtifactStats(root=raw)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
@router.get("/stats/llm", response_model=LLMStats)
|
|
182
|
+
async def get_stats_llm(
|
|
183
|
+
window: Annotated[str, Query(description="Time window, e.g., '24h', '7d'")] = "24h",
|
|
184
|
+
identity: RequestIdentity = Depends(get_identity), # noqa: B008
|
|
185
|
+
) -> LLMStats:
|
|
186
|
+
"""
|
|
187
|
+
LLM usage stats: tokens, requests, breakdown by provider/model.
|
|
188
|
+
Backed by MeteringService.get_llm_stats().
|
|
189
|
+
"""
|
|
190
|
+
container = current_services()
|
|
191
|
+
meter = getattr(container, "metering", None)
|
|
192
|
+
if meter is None:
|
|
193
|
+
raise HTTPException(status_code=501, detail="Metering service not available")
|
|
194
|
+
|
|
195
|
+
raw: dict[str, dict[str, int]] = await meter.get_llm_stats(
|
|
196
|
+
user_id=identity.user_id if identity and identity.user_id else None,
|
|
197
|
+
org_id=identity.org_id if identity and identity.org_id else None,
|
|
198
|
+
window=window,
|
|
199
|
+
)
|
|
200
|
+
# raw: { "gpt-4o-mini": {"calls":..., "prompt_tokens":..., "completion_tokens":...}, ... }
|
|
201
|
+
return LLMStats(root=raw)
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
from typing import Annotated, Any
|
|
5
|
+
|
|
6
|
+
from fastapi import APIRouter, Depends, HTTPException, Query
|
|
7
|
+
|
|
8
|
+
from aethergraph.api.v1.deps import RequestIdentity, get_identity
|
|
9
|
+
from aethergraph.api.v1.schemas import (
|
|
10
|
+
RunVizResponse,
|
|
11
|
+
VizFigure,
|
|
12
|
+
VizKind,
|
|
13
|
+
VizPoint,
|
|
14
|
+
VizTrack,
|
|
15
|
+
)
|
|
16
|
+
from aethergraph.core.runtime.runtime_services import current_services
|
|
17
|
+
|
|
18
|
+
router = APIRouter(tags=["viz"])
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@router.get("/runs/{run_id}/viz", response_model=RunVizResponse)
|
|
22
|
+
async def get_run_viz(
|
|
23
|
+
run_id: str,
|
|
24
|
+
viz_kinds: Annotated[
|
|
25
|
+
str | None,
|
|
26
|
+
Query(
|
|
27
|
+
description=(
|
|
28
|
+
"Comma-separated list of viz kinds to include. "
|
|
29
|
+
"Options: scalar,vector,matrix,image. "
|
|
30
|
+
"If omitted, all viz kinds are returned."
|
|
31
|
+
)
|
|
32
|
+
),
|
|
33
|
+
] = None,
|
|
34
|
+
identity: RequestIdentity = Depends(get_identity), # noqa: B008
|
|
35
|
+
) -> RunVizResponse:
|
|
36
|
+
"""
|
|
37
|
+
Aggregate visualization data for a run into figures/tracks for the Vis tab.
|
|
38
|
+
|
|
39
|
+
- Uses the EventLog-backed VizService.
|
|
40
|
+
- Enforces demo scoping via RunManager (client_id).
|
|
41
|
+
- Returns structured data (scalars, vectors, matrices, image references),
|
|
42
|
+
not pre-rendered plots.
|
|
43
|
+
"""
|
|
44
|
+
container = current_services()
|
|
45
|
+
|
|
46
|
+
viz_service = getattr(container, "viz_service", None)
|
|
47
|
+
rm = getattr(container, "run_manager", None)
|
|
48
|
+
if viz_service is None:
|
|
49
|
+
raise HTTPException(status_code=500, detail="VizService not available")
|
|
50
|
+
|
|
51
|
+
# Demo mode: require RunManager to verify access
|
|
52
|
+
if identity.mode == "demo" and rm is None:
|
|
53
|
+
raise HTTPException(status_code=500, detail="RunManager not available")
|
|
54
|
+
|
|
55
|
+
# Parse viz kinds filter [optional]
|
|
56
|
+
kinds_filter: list[VizKind] | None = None
|
|
57
|
+
if viz_kinds:
|
|
58
|
+
raw = [k.strip().lower() for k in viz_kinds.split(",") if k.strip()]
|
|
59
|
+
allowed: set[str] = {"scalar", "vector", "matrix", "image"}
|
|
60
|
+
bad = [k for k in raw if k not in allowed]
|
|
61
|
+
if bad:
|
|
62
|
+
raise HTTPException(
|
|
63
|
+
status_code=400,
|
|
64
|
+
detail=f"Invalid viz kinds: {', '.join(bad)}; allowed: {', '.join(sorted(allowed))}",
|
|
65
|
+
)
|
|
66
|
+
kinds_filter = raw # type: ignore[assignment]
|
|
67
|
+
|
|
68
|
+
# Query raw viz events for this run from VizService
|
|
69
|
+
rows = await viz_service.query_run(run_id, kinds=kinds_filter)
|
|
70
|
+
|
|
71
|
+
# Group into figures/tracks/points
|
|
72
|
+
# Key: (figure_id, track_id, viz_kind, node_id)
|
|
73
|
+
track_map: dict[tuple[str | None, str, str, str | None], dict[str, Any]] = {}
|
|
74
|
+
for row in rows:
|
|
75
|
+
data: dict[str, Any] = row.get("data") or {}
|
|
76
|
+
viz_kind: str = data.get("viz_kind")
|
|
77
|
+
track_id: str = data.get("track_id")
|
|
78
|
+
figure_id: str | None = data.get("figure_id")
|
|
79
|
+
node_id: str | None = data.get("node_id")
|
|
80
|
+
step = data.get("step")
|
|
81
|
+
|
|
82
|
+
if track_id is None or viz_kind is None or step is None:
|
|
83
|
+
# skip malformed events
|
|
84
|
+
continue
|
|
85
|
+
|
|
86
|
+
key = (figure_id, track_id, viz_kind, node_id)
|
|
87
|
+
agg = track_map.get(key)
|
|
88
|
+
if agg is None:
|
|
89
|
+
agg = {
|
|
90
|
+
"figure_id": figure_id,
|
|
91
|
+
"track_id": track_id,
|
|
92
|
+
"viz_kind": viz_kind,
|
|
93
|
+
"node_id": node_id,
|
|
94
|
+
"mode": data.get("mode", "append"),
|
|
95
|
+
"meta": data.get("meta") or {},
|
|
96
|
+
"points": [],
|
|
97
|
+
}
|
|
98
|
+
track_map[key] = agg
|
|
99
|
+
|
|
100
|
+
# Mode/meta: keep the first one, but allow later events to update if you want
|
|
101
|
+
# For now we just keep existing 'mode' and 'meta' if already set.
|
|
102
|
+
|
|
103
|
+
# Build point
|
|
104
|
+
ts_str: str | None = row.get("ts") or data.get("created_at")
|
|
105
|
+
created_at: datetime | None = None
|
|
106
|
+
if ts_str:
|
|
107
|
+
try:
|
|
108
|
+
created_at = datetime.fromisoformat(ts_str)
|
|
109
|
+
except Exception:
|
|
110
|
+
created_at = None
|
|
111
|
+
|
|
112
|
+
point = VizPoint(
|
|
113
|
+
step=int(step),
|
|
114
|
+
value=data.get("value"),
|
|
115
|
+
vector=data.get("vector"),
|
|
116
|
+
matrix=data.get("matrix"),
|
|
117
|
+
artifact_id=data.get("artifact_id"),
|
|
118
|
+
created_at=created_at,
|
|
119
|
+
)
|
|
120
|
+
agg["points"].append(point)
|
|
121
|
+
|
|
122
|
+
# Build figures from grouping
|
|
123
|
+
figures_map: dict[str | None, list[VizTrack]] = {}
|
|
124
|
+
|
|
125
|
+
for (fig_id, track_id, _, node_id), agg in track_map.items():
|
|
126
|
+
points: list[VizPoint] = agg["points"]
|
|
127
|
+
# Sort points by step (and then by created_at as tiebreaker)
|
|
128
|
+
points.sort(key=lambda p: (p.step, p.created_at or datetime.min))
|
|
129
|
+
|
|
130
|
+
track = VizTrack(
|
|
131
|
+
track_id=track_id,
|
|
132
|
+
figure_id=fig_id,
|
|
133
|
+
node_id=node_id,
|
|
134
|
+
viz_kind=agg["viz_kind"],
|
|
135
|
+
mode=agg["mode"],
|
|
136
|
+
meta=agg["meta"],
|
|
137
|
+
points=points,
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
lst = figures_map.setdefault(fig_id, [])
|
|
141
|
+
lst.append(track)
|
|
142
|
+
|
|
143
|
+
# Sort tracks within each figure by track_id for stability
|
|
144
|
+
figures: list[VizFigure] = []
|
|
145
|
+
for fig_id, tracks in figures_map.items():
|
|
146
|
+
tracks.sort(key=lambda t: t.track_id)
|
|
147
|
+
figures.append(VizFigure(figure_id=fig_id, tracks=tracks))
|
|
148
|
+
|
|
149
|
+
# Sort figures: put named figures first, then the Node/default one
|
|
150
|
+
figures.sort(key=lambda f: (f.figure_id is None, f.figure_id or ""))
|
|
151
|
+
|
|
152
|
+
return RunVizResponse(run_id=run_id, figures=figures)
|