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,403 @@
|
|
|
1
|
+
# aethergraph/storage/sqlite_run_store.py
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
from dataclasses import asdict, is_dataclass
|
|
7
|
+
from datetime import datetime
|
|
8
|
+
import json
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
import sqlite3
|
|
11
|
+
import threading
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from aethergraph.contracts.services.runs import RunStore
|
|
15
|
+
from aethergraph.core.runtime.run_types import RunRecord, RunStatus
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _dt_to_ts(dt: datetime | None) -> float | None:
|
|
19
|
+
if dt is None:
|
|
20
|
+
return None
|
|
21
|
+
if dt.tzinfo is None:
|
|
22
|
+
# assume UTC if naive
|
|
23
|
+
return dt.replace(tzinfo=datetime.timezone.utc).timestamp()
|
|
24
|
+
return dt.timestamp()
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _encode_run(record: RunRecord) -> dict[str, Any]:
|
|
28
|
+
"""Convert RunRecord -> plain dict with JSON-safe types."""
|
|
29
|
+
if is_dataclass(record): # noqa: SIM108
|
|
30
|
+
data = asdict(record)
|
|
31
|
+
else:
|
|
32
|
+
# fallback; should not really happen
|
|
33
|
+
data = dict(record.__dict__)
|
|
34
|
+
|
|
35
|
+
for k, v in list(data.items()):
|
|
36
|
+
if isinstance(v, datetime):
|
|
37
|
+
data[k] = v.isoformat()
|
|
38
|
+
return data
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _decode_run(data: dict[str, Any]) -> RunRecord:
|
|
42
|
+
"""Convert dict from JSON back into RunRecord."""
|
|
43
|
+
|
|
44
|
+
# Best-effort datetime parsing for common fields
|
|
45
|
+
def _parse_dt(val: Any) -> datetime | None:
|
|
46
|
+
if val is None:
|
|
47
|
+
return None
|
|
48
|
+
if isinstance(val, datetime):
|
|
49
|
+
return val
|
|
50
|
+
if isinstance(val, str):
|
|
51
|
+
try:
|
|
52
|
+
return datetime.fromisoformat(val)
|
|
53
|
+
except Exception:
|
|
54
|
+
return None
|
|
55
|
+
return None
|
|
56
|
+
|
|
57
|
+
for key in (
|
|
58
|
+
"created_at",
|
|
59
|
+
"updated_at",
|
|
60
|
+
"started_at",
|
|
61
|
+
"finished_at",
|
|
62
|
+
"first_artifact_at",
|
|
63
|
+
"last_artifact_at",
|
|
64
|
+
):
|
|
65
|
+
if key in data:
|
|
66
|
+
parsed = _parse_dt(data[key])
|
|
67
|
+
if parsed is not None:
|
|
68
|
+
data[key] = parsed
|
|
69
|
+
|
|
70
|
+
return RunRecord(**data)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class SQLiteRunStoreSync:
|
|
74
|
+
"""
|
|
75
|
+
SQLite-backed RunStore.
|
|
76
|
+
|
|
77
|
+
- Stores full RunRecord as JSON in `data_json`
|
|
78
|
+
- Promotes a few fields to columns for fast filtering:
|
|
79
|
+
run_id, graph_id, status, user_id, org_id, session_id,
|
|
80
|
+
started_at, finished_at
|
|
81
|
+
"""
|
|
82
|
+
|
|
83
|
+
def __init__(self, path: str):
|
|
84
|
+
path_obj = Path(path)
|
|
85
|
+
path_obj.parent.mkdir(parents=True, exist_ok=True)
|
|
86
|
+
|
|
87
|
+
self._db = sqlite3.connect(
|
|
88
|
+
str(path_obj),
|
|
89
|
+
check_same_thread=False,
|
|
90
|
+
isolation_level=None, # autocommit
|
|
91
|
+
)
|
|
92
|
+
self._db.execute("PRAGMA journal_mode=WAL;")
|
|
93
|
+
self._db.execute("PRAGMA synchronous=NORMAL;")
|
|
94
|
+
|
|
95
|
+
# Base table
|
|
96
|
+
self._db.execute(
|
|
97
|
+
"""
|
|
98
|
+
CREATE TABLE IF NOT EXISTS runs (
|
|
99
|
+
run_id TEXT PRIMARY KEY,
|
|
100
|
+
data_json TEXT NOT NULL,
|
|
101
|
+
graph_id TEXT,
|
|
102
|
+
status TEXT,
|
|
103
|
+
user_id TEXT,
|
|
104
|
+
org_id TEXT,
|
|
105
|
+
session_id TEXT,
|
|
106
|
+
started_at REAL,
|
|
107
|
+
finished_at REAL
|
|
108
|
+
)
|
|
109
|
+
"""
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
# Indices for common queries
|
|
113
|
+
self._db.execute(
|
|
114
|
+
"CREATE INDEX IF NOT EXISTS idx_runs_graph_started ON runs(graph_id, started_at DESC)"
|
|
115
|
+
)
|
|
116
|
+
self._db.execute(
|
|
117
|
+
"CREATE INDEX IF NOT EXISTS idx_runs_status_started ON runs(status, started_at DESC)"
|
|
118
|
+
)
|
|
119
|
+
self._db.execute(
|
|
120
|
+
"CREATE INDEX IF NOT EXISTS idx_runs_user_started ON runs(user_id, started_at DESC)"
|
|
121
|
+
)
|
|
122
|
+
self._db.execute(
|
|
123
|
+
"CREATE INDEX IF NOT EXISTS idx_runs_org_started ON runs(org_id, started_at DESC)"
|
|
124
|
+
)
|
|
125
|
+
self._db.execute(
|
|
126
|
+
"CREATE INDEX IF NOT EXISTS idx_runs_session_started ON runs(session_id, started_at DESC)"
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
self._lock = threading.RLock()
|
|
130
|
+
|
|
131
|
+
# --- core ops ---
|
|
132
|
+
|
|
133
|
+
def create(self, record: RunRecord) -> None:
|
|
134
|
+
data = _encode_run(record)
|
|
135
|
+
payload = json.dumps(data, ensure_ascii=False)
|
|
136
|
+
started_ts = _dt_to_ts(getattr(record, "started_at", None))
|
|
137
|
+
finished_ts = _dt_to_ts(getattr(record, "finished_at", None))
|
|
138
|
+
|
|
139
|
+
with self._lock:
|
|
140
|
+
self._db.execute(
|
|
141
|
+
"""
|
|
142
|
+
INSERT INTO runs (
|
|
143
|
+
run_id, data_json,
|
|
144
|
+
graph_id, status,
|
|
145
|
+
user_id, org_id, session_id,
|
|
146
|
+
started_at, finished_at
|
|
147
|
+
)
|
|
148
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
149
|
+
""",
|
|
150
|
+
(
|
|
151
|
+
record.run_id,
|
|
152
|
+
payload,
|
|
153
|
+
getattr(record, "graph_id", None),
|
|
154
|
+
record.status.value
|
|
155
|
+
if isinstance(record.status, RunStatus)
|
|
156
|
+
else str(record.status),
|
|
157
|
+
getattr(record, "user_id", None),
|
|
158
|
+
getattr(record, "org_id", None),
|
|
159
|
+
getattr(record, "session_id", None),
|
|
160
|
+
started_ts,
|
|
161
|
+
finished_ts,
|
|
162
|
+
),
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
def update_status(
|
|
166
|
+
self,
|
|
167
|
+
run_id: str,
|
|
168
|
+
status: RunStatus,
|
|
169
|
+
*,
|
|
170
|
+
finished_at: datetime | None = None,
|
|
171
|
+
error: str | None = None,
|
|
172
|
+
) -> None:
|
|
173
|
+
with self._lock:
|
|
174
|
+
row = self._db.execute(
|
|
175
|
+
"SELECT data_json FROM runs WHERE run_id = ?",
|
|
176
|
+
(run_id,),
|
|
177
|
+
).fetchone()
|
|
178
|
+
if not row:
|
|
179
|
+
return
|
|
180
|
+
|
|
181
|
+
data = json.loads(row[0])
|
|
182
|
+
data["status"] = status.value if isinstance(status, RunStatus) else str(status)
|
|
183
|
+
if finished_at is not None:
|
|
184
|
+
data["finished_at"] = finished_at.isoformat()
|
|
185
|
+
if error is not None:
|
|
186
|
+
data["error"] = error
|
|
187
|
+
|
|
188
|
+
payload = json.dumps(data, ensure_ascii=False)
|
|
189
|
+
finished_ts = _dt_to_ts(finished_at)
|
|
190
|
+
|
|
191
|
+
self._db.execute(
|
|
192
|
+
"""
|
|
193
|
+
UPDATE runs
|
|
194
|
+
SET data_json = ?, status = ?, finished_at = ?
|
|
195
|
+
WHERE run_id = ?
|
|
196
|
+
""",
|
|
197
|
+
(payload, status.value, finished_ts, run_id),
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
def get(self, run_id: str) -> RunRecord | None:
|
|
201
|
+
with self._lock:
|
|
202
|
+
row = self._db.execute(
|
|
203
|
+
"SELECT data_json FROM runs WHERE run_id = ?",
|
|
204
|
+
(run_id,),
|
|
205
|
+
).fetchone()
|
|
206
|
+
if not row:
|
|
207
|
+
return None
|
|
208
|
+
data = json.loads(row[0])
|
|
209
|
+
return _decode_run(data)
|
|
210
|
+
|
|
211
|
+
def list(
|
|
212
|
+
self,
|
|
213
|
+
*,
|
|
214
|
+
graph_id: str | None = None,
|
|
215
|
+
status: RunStatus | None = None,
|
|
216
|
+
user_id: str | None = None,
|
|
217
|
+
org_id: str | None = None,
|
|
218
|
+
session_id: str | None = None,
|
|
219
|
+
limit: int = 100,
|
|
220
|
+
offset: int = 0,
|
|
221
|
+
) -> list[RunRecord]:
|
|
222
|
+
"""
|
|
223
|
+
List runs ordered by started_at DESC.
|
|
224
|
+
|
|
225
|
+
NOTE: session_id is optional; you can ignore it if you want to keep
|
|
226
|
+
the signature 100% identical to your current RunStore, or add it
|
|
227
|
+
and update RunManager accordingly.
|
|
228
|
+
"""
|
|
229
|
+
where: list[str] = []
|
|
230
|
+
params: list[Any] = []
|
|
231
|
+
|
|
232
|
+
if graph_id is not None:
|
|
233
|
+
where.append("graph_id = ?")
|
|
234
|
+
params.append(graph_id)
|
|
235
|
+
|
|
236
|
+
if status is not None:
|
|
237
|
+
where.append("status = ?")
|
|
238
|
+
status_val = status.value if isinstance(status, RunStatus) else str(status)
|
|
239
|
+
params.append(status_val)
|
|
240
|
+
|
|
241
|
+
if org_id is not None:
|
|
242
|
+
where.append("org_id = ?")
|
|
243
|
+
params.append(org_id)
|
|
244
|
+
|
|
245
|
+
if user_id is not None:
|
|
246
|
+
where.append("user_id = ?")
|
|
247
|
+
params.append(user_id)
|
|
248
|
+
|
|
249
|
+
if session_id is not None:
|
|
250
|
+
where.append("session_id = ?")
|
|
251
|
+
params.append(session_id)
|
|
252
|
+
|
|
253
|
+
sql = "SELECT data_json FROM runs"
|
|
254
|
+
if where:
|
|
255
|
+
sql += " WHERE " + " AND ".join(where)
|
|
256
|
+
sql += " ORDER BY started_at DESC"
|
|
257
|
+
|
|
258
|
+
if limit is not None:
|
|
259
|
+
sql += " LIMIT ? OFFSET ?"
|
|
260
|
+
params.extend([limit, offset])
|
|
261
|
+
|
|
262
|
+
with self._lock:
|
|
263
|
+
rows = self._db.execute(sql, params).fetchall()
|
|
264
|
+
|
|
265
|
+
return [_decode_run(json.loads(r[0])) for r in rows]
|
|
266
|
+
|
|
267
|
+
def record_artifact(
|
|
268
|
+
self,
|
|
269
|
+
run_id: str,
|
|
270
|
+
*,
|
|
271
|
+
artifact_id: str,
|
|
272
|
+
created_at: datetime | None = None,
|
|
273
|
+
max_recent: int = 10,
|
|
274
|
+
) -> None:
|
|
275
|
+
"""
|
|
276
|
+
Optional API used by ArtifactFacade._record via getattr(..., 'record_artifact', None).
|
|
277
|
+
|
|
278
|
+
Updates artifact-related metadata:
|
|
279
|
+
|
|
280
|
+
- artifact_count
|
|
281
|
+
- first_artifact_at
|
|
282
|
+
- last_artifact_at
|
|
283
|
+
- recent_artifact_ids (bounded to `max_recent`)
|
|
284
|
+
|
|
285
|
+
No-op if the run does not exist.
|
|
286
|
+
"""
|
|
287
|
+
with self._lock:
|
|
288
|
+
row = self._db.execute(
|
|
289
|
+
"SELECT data_json FROM runs WHERE run_id = ?",
|
|
290
|
+
(run_id,),
|
|
291
|
+
).fetchone()
|
|
292
|
+
|
|
293
|
+
if not row:
|
|
294
|
+
return
|
|
295
|
+
|
|
296
|
+
# Decode current RunRecord from JSON
|
|
297
|
+
data = json.loads(row[0])
|
|
298
|
+
record = _decode_run(data)
|
|
299
|
+
|
|
300
|
+
# Choose timestamp
|
|
301
|
+
ts = created_at or datetime.utcnow()
|
|
302
|
+
|
|
303
|
+
# Update stats
|
|
304
|
+
record.artifact_count = (record.artifact_count or 0) + 1
|
|
305
|
+
|
|
306
|
+
if record.first_artifact_at is None or ts < record.first_artifact_at:
|
|
307
|
+
record.first_artifact_at = ts
|
|
308
|
+
|
|
309
|
+
if record.last_artifact_at is None or ts > record.last_artifact_at:
|
|
310
|
+
record.last_artifact_at = ts
|
|
311
|
+
|
|
312
|
+
# Maintain a small rolling window of recent IDs
|
|
313
|
+
if artifact_id:
|
|
314
|
+
recent = list(record.recent_artifact_ids or [])
|
|
315
|
+
recent.append(artifact_id)
|
|
316
|
+
record.recent_artifact_ids = recent[-max_recent:]
|
|
317
|
+
|
|
318
|
+
# Re-encode and persist JSON
|
|
319
|
+
new_data = _encode_run(record)
|
|
320
|
+
payload = json.dumps(new_data, ensure_ascii=False)
|
|
321
|
+
|
|
322
|
+
self._db.execute(
|
|
323
|
+
"""
|
|
324
|
+
UPDATE runs
|
|
325
|
+
SET data_json = ?
|
|
326
|
+
WHERE run_id = ?
|
|
327
|
+
""",
|
|
328
|
+
(payload, run_id),
|
|
329
|
+
)
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
class SQLiteRunStore(RunStore):
|
|
333
|
+
"""
|
|
334
|
+
Async RunStore implementation that delegates to SQLiteRunStoreSync
|
|
335
|
+
using asyncio.to_thread for I/O.
|
|
336
|
+
"""
|
|
337
|
+
|
|
338
|
+
def __init__(self, path: str):
|
|
339
|
+
self._sync = SQLiteRunStoreSync(path)
|
|
340
|
+
|
|
341
|
+
async def create(self, record: RunRecord) -> None:
|
|
342
|
+
await asyncio.to_thread(self._sync.create, record)
|
|
343
|
+
|
|
344
|
+
async def update_status(
|
|
345
|
+
self,
|
|
346
|
+
run_id: str,
|
|
347
|
+
status: RunStatus,
|
|
348
|
+
*,
|
|
349
|
+
finished_at: datetime | None = None,
|
|
350
|
+
error: str | None = None,
|
|
351
|
+
) -> None:
|
|
352
|
+
await asyncio.to_thread(
|
|
353
|
+
self._sync.update_status,
|
|
354
|
+
run_id,
|
|
355
|
+
status,
|
|
356
|
+
finished_at=finished_at,
|
|
357
|
+
error=error,
|
|
358
|
+
)
|
|
359
|
+
|
|
360
|
+
async def get(self, run_id: str) -> RunRecord | None:
|
|
361
|
+
return await asyncio.to_thread(self._sync.get, run_id)
|
|
362
|
+
|
|
363
|
+
async def list(
|
|
364
|
+
self,
|
|
365
|
+
*,
|
|
366
|
+
graph_id: str | None = None,
|
|
367
|
+
status: RunStatus | None = None,
|
|
368
|
+
user_id: str | None = None,
|
|
369
|
+
org_id: str | None = None,
|
|
370
|
+
session_id: str | None = None,
|
|
371
|
+
limit: int = 100,
|
|
372
|
+
offset: int = 0,
|
|
373
|
+
# If you decide to expose session_id here, add it and thread it down.
|
|
374
|
+
) -> list[RunRecord]:
|
|
375
|
+
# For now we only use graph_id/status; session_id can be added later
|
|
376
|
+
return await asyncio.to_thread(
|
|
377
|
+
self._sync.list,
|
|
378
|
+
graph_id=graph_id,
|
|
379
|
+
status=status,
|
|
380
|
+
user_id=user_id,
|
|
381
|
+
org_id=org_id,
|
|
382
|
+
session_id=session_id,
|
|
383
|
+
limit=limit,
|
|
384
|
+
offset=offset,
|
|
385
|
+
)
|
|
386
|
+
|
|
387
|
+
async def record_artifact(
|
|
388
|
+
self,
|
|
389
|
+
run_id: str,
|
|
390
|
+
*,
|
|
391
|
+
artifact_id: str,
|
|
392
|
+
created_at: datetime | None = None,
|
|
393
|
+
) -> None:
|
|
394
|
+
"""
|
|
395
|
+
Async façade for artifact stats update.
|
|
396
|
+
Called from ArtifactFacade._record via getattr(..., 'record_artifact', None).
|
|
397
|
+
"""
|
|
398
|
+
await asyncio.to_thread(
|
|
399
|
+
self._sync.record_artifact,
|
|
400
|
+
run_id,
|
|
401
|
+
artifact_id=artifact_id,
|
|
402
|
+
created_at=created_at,
|
|
403
|
+
)
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
from datetime import datetime, timezone
|
|
5
|
+
from typing import Any
|
|
6
|
+
import uuid
|
|
7
|
+
|
|
8
|
+
from aethergraph.api.v1.schemas import Session
|
|
9
|
+
from aethergraph.contracts.services.sessions import SessionStore
|
|
10
|
+
from aethergraph.contracts.storage.doc_store import (
|
|
11
|
+
DocStore, # wherever your DocStore Protocol lives
|
|
12
|
+
)
|
|
13
|
+
from aethergraph.core.runtime.run_types import SessionKind
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _encode_dt(dt: datetime | None) -> str | None:
|
|
17
|
+
return dt.isoformat() if dt else None
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _decode_dt(s: str | None) -> datetime | None:
|
|
21
|
+
if not s:
|
|
22
|
+
return None
|
|
23
|
+
if s.endswith("Z"):
|
|
24
|
+
s = s[:-1] + "+00:00"
|
|
25
|
+
return datetime.fromisoformat(s)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _session_to_doc(s: Session) -> dict[str, Any]:
|
|
29
|
+
d = s.model_dump() if hasattr(s, "model_dump") else dict(s) # pydantic v2/v1 tolerant
|
|
30
|
+
d["created_at"] = _encode_dt(getattr(s, "created_at", None))
|
|
31
|
+
d["updated_at"] = _encode_dt(getattr(s, "updated_at", None))
|
|
32
|
+
return d
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _doc_to_session(doc: dict[str, Any]) -> Session:
|
|
36
|
+
doc = dict(doc)
|
|
37
|
+
doc["created_at"] = _decode_dt(doc.get("created_at"))
|
|
38
|
+
doc["updated_at"] = _decode_dt(doc.get("updated_at"))
|
|
39
|
+
return Session(**doc)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class DocSessionStore(SessionStore):
|
|
43
|
+
"""
|
|
44
|
+
SessionStore backed by an arbitrary DocStore.
|
|
45
|
+
|
|
46
|
+
- Uses doc IDs like "<prefix><session_id>" (prefix defaults to "session:").
|
|
47
|
+
- Persists Session as JSON-friendly dicts (ISO datetimes).
|
|
48
|
+
- Supports FS-backed or SQLite-backed DocStore transparently.
|
|
49
|
+
|
|
50
|
+
The only requirement is that the underlying DocStore implements `list()`
|
|
51
|
+
if you want list_for_user() to work.
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
def __init__(self, doc_store: DocStore, *, prefix: str = "session:") -> None:
|
|
55
|
+
self._ds = doc_store
|
|
56
|
+
self._prefix = prefix
|
|
57
|
+
self._lock = asyncio.Lock()
|
|
58
|
+
|
|
59
|
+
def _doc_id(self, session_id: str) -> str:
|
|
60
|
+
return f"{self._prefix}{session_id}"
|
|
61
|
+
|
|
62
|
+
async def create(
|
|
63
|
+
self,
|
|
64
|
+
*,
|
|
65
|
+
kind: SessionKind,
|
|
66
|
+
user_id: str | None,
|
|
67
|
+
org_id: str | None,
|
|
68
|
+
title: str | None = None,
|
|
69
|
+
source: str = "webui",
|
|
70
|
+
external_ref: str | None = None,
|
|
71
|
+
) -> Session:
|
|
72
|
+
now = datetime.now(timezone.utc)
|
|
73
|
+
session_id = f"sess_{uuid.uuid4().hex[:8]}"
|
|
74
|
+
sess = Session(
|
|
75
|
+
session_id=session_id,
|
|
76
|
+
kind=kind,
|
|
77
|
+
title=title,
|
|
78
|
+
user_id=user_id,
|
|
79
|
+
org_id=org_id,
|
|
80
|
+
source=source,
|
|
81
|
+
external_ref=external_ref,
|
|
82
|
+
created_at=now,
|
|
83
|
+
updated_at=now,
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
async with self._lock:
|
|
87
|
+
await self._ds.put(self._doc_id(session_id), _session_to_doc(sess))
|
|
88
|
+
return sess
|
|
89
|
+
|
|
90
|
+
async def get(self, session_id: str) -> Session | None:
|
|
91
|
+
doc_id = self._doc_id(session_id)
|
|
92
|
+
async with self._lock:
|
|
93
|
+
doc = await self._ds.get(doc_id)
|
|
94
|
+
if not doc:
|
|
95
|
+
return None
|
|
96
|
+
return _doc_to_session(doc)
|
|
97
|
+
|
|
98
|
+
async def list_for_user(
|
|
99
|
+
self,
|
|
100
|
+
*,
|
|
101
|
+
user_id: str | None,
|
|
102
|
+
org_id: str | None = None,
|
|
103
|
+
kind: SessionKind | None = None,
|
|
104
|
+
limit: int = 50,
|
|
105
|
+
offset: int = 0,
|
|
106
|
+
) -> list[Session]:
|
|
107
|
+
# Same tradeoff as DocRunStore.list(): scan all, filter in Python
|
|
108
|
+
if not hasattr(self._ds, "list"):
|
|
109
|
+
raise RuntimeError(
|
|
110
|
+
"Underlying DocStore does not implement list(); "
|
|
111
|
+
"cannot support SessionStore.list_for_user()."
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
async with self._lock:
|
|
115
|
+
doc_ids: list[str] = await self._ds.list() # type: ignore[attr-defined]
|
|
116
|
+
doc_ids = [d for d in doc_ids if d.startswith(self._prefix)]
|
|
117
|
+
|
|
118
|
+
records: list[Session] = []
|
|
119
|
+
for doc_id in doc_ids:
|
|
120
|
+
doc = await self._ds.get(doc_id)
|
|
121
|
+
if not doc:
|
|
122
|
+
continue
|
|
123
|
+
sess = _doc_to_session(doc)
|
|
124
|
+
|
|
125
|
+
if user_id is not None and sess.user_id != user_id:
|
|
126
|
+
continue
|
|
127
|
+
if org_id is not None and sess.org_id != org_id:
|
|
128
|
+
continue
|
|
129
|
+
if kind is not None and sess.kind != kind:
|
|
130
|
+
continue
|
|
131
|
+
|
|
132
|
+
records.append(sess)
|
|
133
|
+
|
|
134
|
+
records.sort(key=lambda s: s.created_at, reverse=True)
|
|
135
|
+
|
|
136
|
+
if offset > 0:
|
|
137
|
+
records = records[offset:]
|
|
138
|
+
if limit is not None:
|
|
139
|
+
records = records[:limit]
|
|
140
|
+
return records
|
|
141
|
+
|
|
142
|
+
async def touch(
|
|
143
|
+
self,
|
|
144
|
+
session_id: str,
|
|
145
|
+
*,
|
|
146
|
+
updated_at: datetime | None = None,
|
|
147
|
+
) -> None:
|
|
148
|
+
doc_id = self._doc_id(session_id)
|
|
149
|
+
async with self._lock:
|
|
150
|
+
doc = await self._ds.get(doc_id)
|
|
151
|
+
if doc is None:
|
|
152
|
+
return
|
|
153
|
+
doc["updated_at"] = _encode_dt(updated_at or datetime.now(timezone.utc))
|
|
154
|
+
await self._ds.put(doc_id, doc)
|
|
155
|
+
|
|
156
|
+
async def update(
|
|
157
|
+
self,
|
|
158
|
+
session_id: str,
|
|
159
|
+
*,
|
|
160
|
+
title: str | None = None,
|
|
161
|
+
external_ref: str | None = None,
|
|
162
|
+
) -> Session | None:
|
|
163
|
+
doc_id = self._doc_id(session_id)
|
|
164
|
+
async with self._lock:
|
|
165
|
+
doc = await self._ds.get(doc_id)
|
|
166
|
+
if doc is None:
|
|
167
|
+
return None
|
|
168
|
+
|
|
169
|
+
if title is not None:
|
|
170
|
+
doc["title"] = title
|
|
171
|
+
if external_ref is not None:
|
|
172
|
+
doc["external_ref"] = external_ref
|
|
173
|
+
|
|
174
|
+
# Always bump updated_at
|
|
175
|
+
doc["updated_at"] = _encode_dt(datetime.now(timezone.utc))
|
|
176
|
+
|
|
177
|
+
await self._ds.put(doc_id, doc)
|
|
178
|
+
|
|
179
|
+
return _doc_to_session(doc)
|
|
180
|
+
|
|
181
|
+
async def delete(self, session_id: str) -> None:
|
|
182
|
+
async with self._lock:
|
|
183
|
+
await self._ds.delete(self._doc_id(session_id))
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
from datetime import datetime, timezone
|
|
3
|
+
import uuid
|
|
4
|
+
|
|
5
|
+
from aethergraph.api.v1.schemas import Session
|
|
6
|
+
from aethergraph.contracts.services.sessions import SessionStore
|
|
7
|
+
from aethergraph.core.runtime.run_types import SessionKind
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class InMemorySessionStore(SessionStore):
|
|
11
|
+
def __init__(self) -> None:
|
|
12
|
+
self._sessions: dict[str, Session] = {}
|
|
13
|
+
self._lock = asyncio.Lock() # TODO: confirm async lock is fine bc this will only be used inside uvicorn process with UI.
|
|
14
|
+
|
|
15
|
+
async def create(
|
|
16
|
+
self,
|
|
17
|
+
*,
|
|
18
|
+
kind: SessionKind,
|
|
19
|
+
user_id: str | None,
|
|
20
|
+
org_id: str | None,
|
|
21
|
+
title: str | None = None,
|
|
22
|
+
source: str = "webui",
|
|
23
|
+
external_ref: str | None = None,
|
|
24
|
+
) -> Session:
|
|
25
|
+
async with self._lock:
|
|
26
|
+
now = datetime.now(timezone.utc)
|
|
27
|
+
session_id = f"sess_{uuid.uuid4().hex[:8]}"
|
|
28
|
+
sess = Session(
|
|
29
|
+
session_id=session_id,
|
|
30
|
+
kind=kind,
|
|
31
|
+
title=title,
|
|
32
|
+
user_id=user_id,
|
|
33
|
+
org_id=org_id,
|
|
34
|
+
source=source,
|
|
35
|
+
external_ref=external_ref,
|
|
36
|
+
created_at=now,
|
|
37
|
+
updated_at=now,
|
|
38
|
+
)
|
|
39
|
+
self._sessions[session_id] = sess
|
|
40
|
+
return sess
|
|
41
|
+
|
|
42
|
+
async def get(self, session_id: str) -> Session | None:
|
|
43
|
+
async with self._lock:
|
|
44
|
+
return self._sessions.get(session_id)
|
|
45
|
+
|
|
46
|
+
async def list_for_user(
|
|
47
|
+
self,
|
|
48
|
+
*,
|
|
49
|
+
user_id: str | None,
|
|
50
|
+
org_id: str | None = None,
|
|
51
|
+
kind: SessionKind | None = None,
|
|
52
|
+
limit: int = 50,
|
|
53
|
+
offset: int = 0,
|
|
54
|
+
) -> list[Session]:
|
|
55
|
+
async with self._lock:
|
|
56
|
+
records = list(self._sessions.values())
|
|
57
|
+
|
|
58
|
+
if user_id is not None:
|
|
59
|
+
records = [s for s in records if s.user_id == user_id]
|
|
60
|
+
if org_id is not None:
|
|
61
|
+
records = [s for s in records if s.org_id == org_id]
|
|
62
|
+
if kind is not None:
|
|
63
|
+
records = [s for s in records if s.kind == kind]
|
|
64
|
+
|
|
65
|
+
records.sort(key=lambda s: s.created_at, reverse=True)
|
|
66
|
+
|
|
67
|
+
if offset:
|
|
68
|
+
records = records[offset:]
|
|
69
|
+
if limit:
|
|
70
|
+
records = records[:limit]
|
|
71
|
+
|
|
72
|
+
return records
|
|
73
|
+
|
|
74
|
+
async def touch(
|
|
75
|
+
self,
|
|
76
|
+
session_id: str,
|
|
77
|
+
*,
|
|
78
|
+
updated_at: datetime | None = None,
|
|
79
|
+
) -> None:
|
|
80
|
+
async with self._lock:
|
|
81
|
+
sess = self._sessions.get(session_id)
|
|
82
|
+
if not sess:
|
|
83
|
+
return
|
|
84
|
+
sess.updated_at = updated_at or datetime.now(timezone.utc)
|
|
85
|
+
|
|
86
|
+
async def update(
|
|
87
|
+
self,
|
|
88
|
+
session_id: str,
|
|
89
|
+
*,
|
|
90
|
+
title: str | None = None,
|
|
91
|
+
external_ref: str | None = None,
|
|
92
|
+
) -> Session | None:
|
|
93
|
+
async with self._lock:
|
|
94
|
+
sess = self._sessions.get(session_id)
|
|
95
|
+
if not sess:
|
|
96
|
+
return None
|
|
97
|
+
|
|
98
|
+
# Mutate in-place (Session is a Pydantic model or similar)
|
|
99
|
+
if title is not None:
|
|
100
|
+
sess.title = title
|
|
101
|
+
if external_ref is not None:
|
|
102
|
+
sess.external_ref = external_ref
|
|
103
|
+
|
|
104
|
+
sess.updated_at = datetime.now(timezone.utc)
|
|
105
|
+
self._sessions[session_id] = sess
|
|
106
|
+
return sess
|
|
107
|
+
|
|
108
|
+
async def delete(self, session_id: str) -> None:
|
|
109
|
+
async with self._lock:
|
|
110
|
+
self._sessions.pop(session_id, None)
|