cascade-orchestrator 0.1.0__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.
- cascade/__init__.py +8 -0
- cascade/config.py +48 -0
- cascade/database.py +69 -0
- cascade/engine/__init__.py +1 -0
- cascade/engine/loop.py +105 -0
- cascade/engine/pinger.py +39 -0
- cascade/engine/poller.py +38 -0
- cascade/engine/progress_tracker.py +63 -0
- cascade/integrations/__init__.py +33 -0
- cascade/integrations/hermes_bridge.py +616 -0
- cascade/integrations/monitor_daemon.py +357 -0
- cascade/main.py +122 -0
- cascade/mcp/__init__.py +5 -0
- cascade/mcp/instructions.py +34 -0
- cascade/mcp/server.py +74 -0
- cascade/mcp/tools.py +230 -0
- cascade/models/__init__.py +26 -0
- cascade/models/dependency.py +44 -0
- cascade/models/event.py +58 -0
- cascade/models/goal.py +46 -0
- cascade/models/message.py +38 -0
- cascade/models/milestone.py +43 -0
- cascade/models/project.py +57 -0
- cascade/models/task.py +144 -0
- cascade/models/telemetry.py +34 -0
- cascade/routers/__init__.py +21 -0
- cascade/routers/dashboard.py +149 -0
- cascade/routers/events.py +61 -0
- cascade/routers/goals.py +70 -0
- cascade/routers/milestones.py +60 -0
- cascade/routers/pages.py +154 -0
- cascade/routers/projects.py +69 -0
- cascade/routers/tasks.py +204 -0
- cascade/schemas/__init__.py +51 -0
- cascade/schemas/event.py +50 -0
- cascade/schemas/goal.py +57 -0
- cascade/schemas/message.py +31 -0
- cascade/schemas/milestone.py +42 -0
- cascade/schemas/project.py +34 -0
- cascade/schemas/task.py +92 -0
- cascade/services/__init__.py +25 -0
- cascade/services/auto_decision.py +116 -0
- cascade/services/event_service.py +104 -0
- cascade/services/goal_service.py +130 -0
- cascade/services/milestone_service.py +125 -0
- cascade/services/monitor_service.py +182 -0
- cascade/services/project_service.py +111 -0
- cascade/services/scheduler_service.py +124 -0
- cascade/services/task_service.py +478 -0
- cascade/utils.py +28 -0
- cascade/web/static/app.js +50 -0
- cascade/web/templates/base.html +50 -0
- cascade/web/templates/board.html +55 -0
- cascade/web/templates/dashboard.html +98 -0
- cascade/web/templates/partials/goal_card.html +14 -0
- cascade/web/templates/partials/progress_bar.html +12 -0
- cascade/web/templates/partials/project_aggregate.html +41 -0
- cascade/web/templates/partials/task_card.html +24 -0
- cascade/web/templates/partials/task_list.html +15 -0
- cascade/web/templates/project.html +113 -0
- cascade/web/templates/task.html +157 -0
- cascade_orchestrator-0.1.0.dist-info/METADATA +290 -0
- cascade_orchestrator-0.1.0.dist-info/RECORD +67 -0
- cascade_orchestrator-0.1.0.dist-info/WHEEL +5 -0
- cascade_orchestrator-0.1.0.dist-info/entry_points.txt +3 -0
- cascade_orchestrator-0.1.0.dist-info/licenses/LICENSE +21 -0
- cascade_orchestrator-0.1.0.dist-info/top_level.txt +1 -0
cascade/__init__.py
ADDED
cascade/config.py
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""Application configuration via Pydantic Settings.
|
|
2
|
+
|
|
3
|
+
All settings can be overridden with environment variables prefixed ``CASCADE_``
|
|
4
|
+
(e.g. ``CASCADE_PORT=8100``) or via a ``.env`` file in the working directory.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from pydantic_settings import BaseSettings, SettingsConfigDict
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class Settings(BaseSettings):
|
|
13
|
+
"""Central configuration for the Cascade platform."""
|
|
14
|
+
|
|
15
|
+
model_config = SettingsConfigDict(
|
|
16
|
+
env_prefix="CASCADE_",
|
|
17
|
+
env_file=".env",
|
|
18
|
+
env_file_encoding="utf-8",
|
|
19
|
+
extra="ignore",
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
# --- Database ---
|
|
23
|
+
database_url: str = "sqlite+aiosqlite:///./cascade.db"
|
|
24
|
+
|
|
25
|
+
# --- Server ---
|
|
26
|
+
host: str = "0.0.0.0"
|
|
27
|
+
port: int = 8100
|
|
28
|
+
reload: bool = True
|
|
29
|
+
|
|
30
|
+
# --- Engine ---
|
|
31
|
+
# Seconds between monitoring-loop ticks. Continuous monitoring, not hourly.
|
|
32
|
+
loop_tick_seconds: int = 10
|
|
33
|
+
# Seconds between stall-detection sweeps.
|
|
34
|
+
stall_check_interval_seconds: int = 300
|
|
35
|
+
# Minutes of silence before an ongoing task is considered stalled.
|
|
36
|
+
stall_threshold_minutes: int = 30
|
|
37
|
+
# Seconds before an agent session with no heartbeat is considered dead.
|
|
38
|
+
session_timeout_seconds: int = 60
|
|
39
|
+
|
|
40
|
+
# --- Scheduling ---
|
|
41
|
+
scheduler_interval_seconds: int = 60
|
|
42
|
+
|
|
43
|
+
# --- Feature flags ---
|
|
44
|
+
enable_monitoring_loop: bool = True
|
|
45
|
+
enable_scheduler: bool = True
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
settings = Settings()
|
cascade/database.py
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""Async database engine, session factory and declarative Base.
|
|
2
|
+
|
|
3
|
+
Provides the single :data:`engine`, the :data:`async_session_factory` and the
|
|
4
|
+
dependency :func:`get_db` used by FastAPI route handlers.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from collections.abc import AsyncGenerator
|
|
10
|
+
|
|
11
|
+
from sqlalchemy import event
|
|
12
|
+
from sqlalchemy.ext.asyncio import (
|
|
13
|
+
AsyncSession,
|
|
14
|
+
async_sessionmaker,
|
|
15
|
+
create_async_engine,
|
|
16
|
+
)
|
|
17
|
+
from sqlalchemy.orm import DeclarativeBase
|
|
18
|
+
|
|
19
|
+
from cascade.config import settings
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class Base(DeclarativeBase):
|
|
23
|
+
"""Declarative base for all SQLAlchemy 2.0 typed models."""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
engine = create_async_engine(
|
|
27
|
+
settings.database_url,
|
|
28
|
+
echo=False,
|
|
29
|
+
future=True,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
if engine.url.get_backend_name() == "sqlite":
|
|
33
|
+
# The monitoring loop and API requests hit SQLite from several concurrent
|
|
34
|
+
# connections. Without WAL + a busy timeout, SQLite's default rollback
|
|
35
|
+
# journal serialises writers and raises "database is locked" almost
|
|
36
|
+
# immediately under any real concurrency.
|
|
37
|
+
@event.listens_for(engine.sync_engine, "connect")
|
|
38
|
+
def _set_sqlite_pragmas(dbapi_connection, connection_record) -> None: # noqa: ANN001
|
|
39
|
+
cursor = dbapi_connection.cursor()
|
|
40
|
+
cursor.execute("PRAGMA journal_mode=WAL")
|
|
41
|
+
cursor.execute("PRAGMA busy_timeout=30000")
|
|
42
|
+
cursor.execute("PRAGMA foreign_keys=ON")
|
|
43
|
+
cursor.close()
|
|
44
|
+
|
|
45
|
+
async_session_factory: async_sessionmaker[AsyncSession] = async_sessionmaker(
|
|
46
|
+
bind=engine,
|
|
47
|
+
class_=AsyncSession,
|
|
48
|
+
expire_on_commit=False,
|
|
49
|
+
autoflush=False,
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
async def get_db() -> AsyncGenerator[AsyncSession, None]:
|
|
54
|
+
"""FastAPI dependency that yields an :class:`AsyncSession`."""
|
|
55
|
+
async with async_session_factory() as session:
|
|
56
|
+
try:
|
|
57
|
+
yield session
|
|
58
|
+
except Exception:
|
|
59
|
+
await session.rollback()
|
|
60
|
+
raise
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
async def init_db() -> None:
|
|
64
|
+
"""Create all tables. Intended for dev/bootstrap; Alembic is used in prod."""
|
|
65
|
+
# Importing here ensures every model is registered on ``Base.metadata``.
|
|
66
|
+
from cascade import models # noqa: F401 (register models)
|
|
67
|
+
|
|
68
|
+
async with engine.begin() as conn:
|
|
69
|
+
await conn.run_sync(Base.metadata.create_all)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Engine: continuous monitoring loop, polling, pinger and progress tracking."""
|
cascade/engine/loop.py
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"""Main monitoring loop — continuous execution (10s tick, NOT hourly).
|
|
2
|
+
|
|
3
|
+
Runs forever (as a background task on app startup) and, on every tick, runs
|
|
4
|
+
the poller, pinger and scheduler concurrently. Stall detection runs on its own
|
|
5
|
+
slower cadence. This is the AgentRQ loop, reimagined as continuous monitoring.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import asyncio
|
|
11
|
+
import logging
|
|
12
|
+
import time
|
|
13
|
+
|
|
14
|
+
from cascade.config import settings
|
|
15
|
+
from cascade.database import async_session_factory
|
|
16
|
+
from cascade.engine.pinger import pinger_tick
|
|
17
|
+
from cascade.engine.poller import poller_tick
|
|
18
|
+
from cascade.services.scheduler_service import SchedulerService
|
|
19
|
+
|
|
20
|
+
logger = logging.getLogger(__name__)
|
|
21
|
+
|
|
22
|
+
# Module-level state so the loop can be stopped gracefully.
|
|
23
|
+
_running = False
|
|
24
|
+
_last_stall_check = 0.0
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
async def scheduler_tick() -> int:
|
|
28
|
+
"""Process due cron templates, returning spawned task count."""
|
|
29
|
+
async with async_session_factory() as session:
|
|
30
|
+
svc = SchedulerService(session)
|
|
31
|
+
spawned = await svc.process_cron_templates()
|
|
32
|
+
if spawned:
|
|
33
|
+
logger.info("Scheduler spawned %d cron task(s)", len(spawned))
|
|
34
|
+
return len(spawned)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
async def stall_detector_tick() -> int:
|
|
38
|
+
"""Run stall detection on its own slower cadence (every 5 min)."""
|
|
39
|
+
global _last_stall_check
|
|
40
|
+
now = time.time()
|
|
41
|
+
if now - _last_stall_check < settings.stall_check_interval_seconds:
|
|
42
|
+
return 0
|
|
43
|
+
_last_stall_check = now
|
|
44
|
+
async with async_session_factory() as session:
|
|
45
|
+
return await poller_tick(session)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
async def _poller_tick_isolated() -> int:
|
|
49
|
+
"""Run the poller in its own session (never share one across concurrent ticks)."""
|
|
50
|
+
async with async_session_factory() as session:
|
|
51
|
+
result = await poller_tick(session)
|
|
52
|
+
await session.commit()
|
|
53
|
+
return result
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
async def _pinger_tick_isolated() -> int:
|
|
57
|
+
"""Run the pinger in its own session (never share one across concurrent ticks)."""
|
|
58
|
+
async with async_session_factory() as session:
|
|
59
|
+
result = await pinger_tick(session)
|
|
60
|
+
await session.commit()
|
|
61
|
+
return result
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
async def _tick() -> None:
|
|
65
|
+
"""Run one monitoring tick: poller + pinger + scheduler concurrently.
|
|
66
|
+
|
|
67
|
+
Each concurrent piece of work gets its own :class:`AsyncSession` —
|
|
68
|
+
``AsyncSession`` is not safe for concurrent use by multiple coroutines,
|
|
69
|
+
so sharing one across ``asyncio.gather`` would corrupt session state.
|
|
70
|
+
"""
|
|
71
|
+
results = await asyncio.gather(
|
|
72
|
+
_poller_tick_isolated(),
|
|
73
|
+
_pinger_tick_isolated(),
|
|
74
|
+
scheduler_tick(),
|
|
75
|
+
stall_detector_tick(),
|
|
76
|
+
return_exceptions=True,
|
|
77
|
+
)
|
|
78
|
+
for name, result in zip(
|
|
79
|
+
("poller", "pinger", "scheduler", "stall_detector"), results
|
|
80
|
+
):
|
|
81
|
+
if isinstance(result, BaseException):
|
|
82
|
+
logger.error("Monitoring tick component %r failed: %s", name, result)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
async def monitoring_loop() -> None:
|
|
86
|
+
"""Main loop that runs forever, monitoring all projects every 10s."""
|
|
87
|
+
global _running
|
|
88
|
+
_running = True
|
|
89
|
+
logger.info("Cascade monitoring loop started (tick=%ss)", settings.loop_tick_seconds)
|
|
90
|
+
while _running:
|
|
91
|
+
try:
|
|
92
|
+
await _tick()
|
|
93
|
+
except Exception: # pragma: no cover - never let the loop die
|
|
94
|
+
logger.exception("Monitoring tick failed")
|
|
95
|
+
await asyncio.sleep(settings.loop_tick_seconds)
|
|
96
|
+
logger.info("Cascade monitoring loop stopped")
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def stop_monitoring_loop() -> None:
|
|
100
|
+
"""Signal the monitoring loop to stop after the current tick."""
|
|
101
|
+
global _running
|
|
102
|
+
_running = False
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
__all__ = ["monitoring_loop", "stop_monitoring_loop", "scheduler_tick"]
|
cascade/engine/pinger.py
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""Pinger — session keep-alive + dead-session eviction.
|
|
2
|
+
|
|
3
|
+
Runs on the monitoring loop to evict agent sessions whose last heartbeat
|
|
4
|
+
exceeds the configured timeout, so the dashboard's "agent alive" indicator
|
|
5
|
+
stays accurate.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import logging
|
|
11
|
+
|
|
12
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
13
|
+
|
|
14
|
+
from cascade.engine.progress_tracker import tracker
|
|
15
|
+
from cascade.services.monitor_service import MonitorService
|
|
16
|
+
|
|
17
|
+
logger = logging.getLogger(__name__)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
async def pinger_tick(session: AsyncSession) -> int:
|
|
21
|
+
"""Evict dead agent sessions and broadcast status changes via SSE.
|
|
22
|
+
|
|
23
|
+
Returns the number of sessions evicted.
|
|
24
|
+
"""
|
|
25
|
+
monitor_svc = MonitorService(session)
|
|
26
|
+
# Snapshot BEFORE evicting: a project whose last session just died is
|
|
27
|
+
# removed from ``_sessions`` entirely, so iterating the dict *after*
|
|
28
|
+
# eviction would silently skip broadcasting its now-offline status.
|
|
29
|
+
watched_projects = set(monitor_svc._sessions) # noqa: SLF001
|
|
30
|
+
evicted = await monitor_svc.evict_dead_sessions()
|
|
31
|
+
if evicted:
|
|
32
|
+
logger.info("Evicted %d dead agent session(s)", evicted)
|
|
33
|
+
# Notify any dashboard subscribers that liveness may have changed.
|
|
34
|
+
for project_id in watched_projects:
|
|
35
|
+
alive = await monitor_svc.is_agent_alive(project_id)
|
|
36
|
+
await tracker.publish(
|
|
37
|
+
project_id, "agent_status", {"alive": alive, "evicted": evicted}
|
|
38
|
+
)
|
|
39
|
+
return evicted
|
cascade/engine/poller.py
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""Poller — work distribution + stall nudging.
|
|
2
|
+
|
|
3
|
+
In Cascade, agents pull work via ``get_task`` (dequeue). The poller therefore
|
|
4
|
+
focuses on *push* opportunities that a pull-only model misses: detecting
|
|
5
|
+
stalled tasks and nudging them so work never silently stalls.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import logging
|
|
11
|
+
|
|
12
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
13
|
+
|
|
14
|
+
from cascade.services.monitor_service import MonitorService
|
|
15
|
+
from cascade.services.project_service import ProjectService
|
|
16
|
+
|
|
17
|
+
logger = logging.getLogger(__name__)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
async def poller_tick(session: AsyncSession) -> int:
|
|
21
|
+
"""Detect stalled tasks across all projects and nudge them.
|
|
22
|
+
|
|
23
|
+
Returns the number of tasks nudged.
|
|
24
|
+
"""
|
|
25
|
+
project_svc = ProjectService(session)
|
|
26
|
+
monitor_svc = MonitorService(session)
|
|
27
|
+
projects = await project_svc.list_projects(status="active")
|
|
28
|
+
|
|
29
|
+
nudged = 0
|
|
30
|
+
for project in projects:
|
|
31
|
+
stalled = await monitor_svc.get_stalled_tasks(project.id)
|
|
32
|
+
for task in stalled:
|
|
33
|
+
await monitor_svc.nudge_stalled_task(task.id)
|
|
34
|
+
nudged += 1
|
|
35
|
+
logger.info("Nudged stalled task %s in project %s", task.id, project.id)
|
|
36
|
+
if nudged:
|
|
37
|
+
await session.commit()
|
|
38
|
+
return nudged
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""Real-time progress aggregation + SSE broadcasting broker.
|
|
2
|
+
|
|
3
|
+
An in-memory pub/sub keyed by project. SSE clients subscribe to a project's
|
|
4
|
+
queue via :meth:`ProgressTracker.subscribe`; services publish events via
|
|
5
|
+
:meth:`ProgressTracker.publish` which fans out to every subscriber.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import asyncio
|
|
11
|
+
import logging
|
|
12
|
+
from collections import defaultdict
|
|
13
|
+
from collections.abc import AsyncGenerator
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
logger = logging.getLogger(__name__)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class ProgressTracker:
|
|
20
|
+
"""In-memory broadcast broker for Server-Sent Events."""
|
|
21
|
+
|
|
22
|
+
def __init__(self) -> None:
|
|
23
|
+
# project_id -> list of asyncio.Queue
|
|
24
|
+
self._subscribers: dict[str, list[asyncio.Queue[dict[str, Any]]]] = defaultdict(list)
|
|
25
|
+
|
|
26
|
+
def subscribe(self, project_id: str) -> asyncio.Queue[dict[str, Any]]:
|
|
27
|
+
"""Register a new subscriber queue for ``project_id``."""
|
|
28
|
+
queue: asyncio.Queue[dict[str, Any]] = asyncio.Queue(maxsize=256)
|
|
29
|
+
self._subscribers[project_id].append(queue)
|
|
30
|
+
logger.debug("SSE subscriber added for project %s", project_id)
|
|
31
|
+
return queue
|
|
32
|
+
|
|
33
|
+
def unsubscribe(self, project_id: str, queue: asyncio.Queue[dict[str, Any]]) -> None:
|
|
34
|
+
"""Remove a previously-registered subscriber queue."""
|
|
35
|
+
if project_id in self._subscribers:
|
|
36
|
+
try:
|
|
37
|
+
self._subscribers[project_id].remove(queue)
|
|
38
|
+
except ValueError:
|
|
39
|
+
pass
|
|
40
|
+
if not self._subscribers[project_id]:
|
|
41
|
+
del self._subscribers[project_id]
|
|
42
|
+
|
|
43
|
+
async def publish(self, project_id: str, event_type: str, data: dict[str, Any]) -> None:
|
|
44
|
+
"""Broadcast an event to all subscribers of ``project_id``."""
|
|
45
|
+
message = {"event": event_type, "data": {**data, "project_id": project_id}}
|
|
46
|
+
for queue in list(self._subscribers.get(project_id, [])):
|
|
47
|
+
try:
|
|
48
|
+
queue.put_nowait(message)
|
|
49
|
+
except asyncio.QueueFull:
|
|
50
|
+
logger.warning("SSE queue full for project %s; dropping event", project_id)
|
|
51
|
+
|
|
52
|
+
async def stream(self, project_id: str) -> AsyncGenerator[dict[str, Any], None]:
|
|
53
|
+
"""Yield events for ``project_id`` until the subscriber disconnects."""
|
|
54
|
+
queue = self.subscribe(project_id)
|
|
55
|
+
try:
|
|
56
|
+
while True:
|
|
57
|
+
yield await queue.get()
|
|
58
|
+
finally:
|
|
59
|
+
self.unsubscribe(project_id, queue)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
# Module-level singleton used across services and routers.
|
|
63
|
+
tracker = ProgressTracker()
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""Cascade integration layer.
|
|
2
|
+
|
|
3
|
+
Bridges external systems (notably the Hermes agent) to the Cascade task
|
|
4
|
+
orchestration platform. The async REST client is always importable; the
|
|
5
|
+
monitor daemon is imported lazily so that consumers who only need the client
|
|
6
|
+
do not pay for its heavier runtime setup.
|
|
7
|
+
|
|
8
|
+
.. code-block:: python
|
|
9
|
+
|
|
10
|
+
from cascade.integrations import CascadeClient
|
|
11
|
+
|
|
12
|
+
async with CascadeClient("http://localhost:8100") as client:
|
|
13
|
+
project = await client.create_project(name="Ship feature X")
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
from cascade.integrations.hermes_bridge import (
|
|
19
|
+
CascadeAPIError,
|
|
20
|
+
CascadeClient,
|
|
21
|
+
DotDict,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
__all__ = ["CascadeClient", "CascadeAPIError", "DotDict", "MonitorDaemon"]
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def __getattr__(name: str):
|
|
28
|
+
"""Lazy-load the monitor daemon to keep the client import lightweight."""
|
|
29
|
+
if name == "MonitorDaemon":
|
|
30
|
+
from cascade.integrations.monitor_daemon import MonitorDaemon
|
|
31
|
+
|
|
32
|
+
return MonitorDaemon
|
|
33
|
+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|