taskmanager-engine 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.
@@ -0,0 +1,150 @@
1
+ """
2
+ TaskManager: Modern background task execution engine with dynamic cron scheduling
3
+ and real-time management dashboard.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ from collections.abc import Callable
9
+ from typing import Any
10
+
11
+ import fakeredis.aioredis
12
+ import redis.asyncio as redis
13
+
14
+ import taskmanager.core.builtin_tasks as builtin_tasks # Auto-registers system tasks
15
+ from taskmanager.api.app import create_app
16
+ from taskmanager.config import settings
17
+ from taskmanager.core.broker import RedisBroker
18
+ from taskmanager.core.job import Job
19
+ from taskmanager.core.task import Task, TaskRegistry, registry, task
20
+ from taskmanager.scheduler.scheduler import Scheduler
21
+ from taskmanager.worker.worker import Worker
22
+
23
+ __version__ = "0.1.0"
24
+
25
+
26
+ class TaskManager:
27
+ """
28
+ High-level manager for embedding TaskManager into existing Python applications
29
+ (FastAPI, Starlette, Django, Flask, or standalone scripts).
30
+
31
+ Example:
32
+ ```python
33
+ from taskmanager import TaskManager, task
34
+
35
+ tm = TaskManager(redis_url="redis://localhost:6379/0", prefix="myapp")
36
+
37
+ @task(name="send_welcome_email")
38
+ async def send_welcome_email(email: str):
39
+ ...
40
+
41
+ # Mount into FastAPI
42
+ app.mount("/tasks", tm.get_app())
43
+ ```
44
+ """
45
+
46
+ def __init__(
47
+ self,
48
+ redis_url: str | None = None,
49
+ prefix: str | None = None,
50
+ task_registry: TaskRegistry | None = None,
51
+ broker: RedisBroker | None = None,
52
+ ) -> None:
53
+ self.redis_url = redis_url or settings.redis_url
54
+ self.prefix = prefix or settings.redis_prefix
55
+ self.registry = task_registry or registry
56
+ self._broker = broker
57
+ self._scheduler: Scheduler | None = None
58
+ self._app: Any = None
59
+
60
+ @property
61
+ def broker(self) -> RedisBroker:
62
+ if self._broker is None:
63
+ if self.redis_url and self.redis_url.startswith("memory://"):
64
+ client = fakeredis.aioredis.FakeRedis(decode_responses=True)
65
+ else:
66
+ client = redis.from_url(self.redis_url, decode_responses=True)
67
+ self._broker = RedisBroker(client, prefix=self.prefix)
68
+ self.registry.set_broker(self._broker)
69
+ return self._broker
70
+
71
+ @property
72
+ def scheduler(self) -> Scheduler:
73
+ if self._scheduler is None:
74
+ self._scheduler = Scheduler(self.broker)
75
+ return self._scheduler
76
+
77
+ def task(
78
+ self,
79
+ name: str | None = None,
80
+ queue: str = "default",
81
+ max_retries: int = 3,
82
+ retry_backoff: float = 2.0,
83
+ timeout: float | None = None,
84
+ ) -> Callable[..., Task]:
85
+ """Decorator to register a function as a Task within this manager's registry."""
86
+
87
+ def decorator(func: Callable[..., Any]) -> Task:
88
+ t = Task(
89
+ func=func,
90
+ name=name,
91
+ queue=queue,
92
+ max_retries=max_retries,
93
+ retry_backoff=retry_backoff,
94
+ timeout=timeout,
95
+ broker=self.broker,
96
+ )
97
+ self.registry.register(t)
98
+ return t
99
+
100
+ return decorator
101
+
102
+ def create_worker(
103
+ self,
104
+ queues: list[str] | None = None,
105
+ concurrency: int = 5,
106
+ name: str | None = None,
107
+ max_memory_mb: float | None = None,
108
+ max_cpu_percent: float | None = None,
109
+ ) -> Worker:
110
+ """Creates a Worker instance bound to this broker and registry."""
111
+ return Worker(
112
+ broker=self.broker,
113
+ task_registry=self.registry,
114
+ queues=queues or ["default"],
115
+ concurrency=concurrency,
116
+ name=name,
117
+ max_memory_mb=max_memory_mb,
118
+ max_cpu_percent=max_cpu_percent,
119
+ )
120
+
121
+ def get_app(self, root_path: str = "") -> Any:
122
+ """Returns the configured FastAPI application for the dashboard and REST API."""
123
+ if self._app is None or root_path:
124
+ self._app = create_app(
125
+ broker=self.broker,
126
+ task_reg=self.registry,
127
+ root_path=root_path,
128
+ )
129
+ return self._app
130
+
131
+ def mount_to(self, parent_app: Any, path: str = "/tasks") -> None:
132
+ """Mounts the TaskManager dashboard and API sub-application onto a parent FastAPI/Starlette app."""
133
+ sub_app = self.get_app(root_path=path)
134
+ parent_app.mount(path, sub_app)
135
+
136
+
137
+ __all__ = [
138
+ "TaskManager",
139
+ "task",
140
+ "create_app",
141
+ "Job",
142
+ "Task",
143
+ "TaskRegistry",
144
+ "registry",
145
+ "Worker",
146
+ "Scheduler",
147
+ "RedisBroker",
148
+ "builtin_tasks",
149
+ "__version__",
150
+ ]
taskmanager/api/app.py ADDED
@@ -0,0 +1,488 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ from contextlib import asynccontextmanager
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ import psutil
9
+ import redis.asyncio as redis
10
+ from fastapi import FastAPI, HTTPException, WebSocket, WebSocketDisconnect
11
+ from fastapi.middleware.cors import CORSMiddleware
12
+ from fastapi.responses import FileResponse, JSONResponse
13
+ from fastapi.staticfiles import StaticFiles
14
+ from pydantic import BaseModel, Field
15
+
16
+ from taskmanager.api.events import WebSocketEventManager
17
+ from taskmanager.config import settings
18
+ from taskmanager.core.broker import RedisBroker
19
+ from taskmanager.core.job import Job
20
+ from taskmanager.core.task import TaskRegistry, registry
21
+ from taskmanager.scheduler.cron import Schedule
22
+ from taskmanager.scheduler.scheduler import Scheduler
23
+ from taskmanager.worker.heartbeat import HeartbeatManager
24
+ from taskmanager.worker.worker import Worker
25
+
26
+
27
+ class SpawnWorkerRequest(BaseModel):
28
+ name: str | None = None
29
+ queues: list[str] = Field(default_factory=lambda: ["default"])
30
+ concurrency: int = 5
31
+ max_memory_mb: float | None = None
32
+ max_cpu_percent: float | None = None
33
+
34
+
35
+ class CreateScheduleRequest(BaseModel):
36
+ name: str
37
+ task_name: str
38
+ queue: str = "default"
39
+ schedule_type: str = "cron" # "cron" or "interval"
40
+ cron_expression: str | None = None
41
+ interval_seconds: float | None = None
42
+ args: list[Any] = Field(default_factory=list)
43
+ kwargs: dict[str, Any] = Field(default_factory=dict)
44
+ enabled: bool = True
45
+
46
+
47
+ class EnqueueTaskRequest(BaseModel):
48
+ args: list[Any] = Field(default_factory=list)
49
+ kwargs: dict[str, Any] = Field(default_factory=dict)
50
+ queue: str | None = None
51
+ delay: float | None = None
52
+ priority: int = 0
53
+ max_retries: int | None = None
54
+ timeout: float | None = None
55
+
56
+
57
+ def create_app(
58
+ broker: RedisBroker | None = None,
59
+ task_reg: TaskRegistry | None = None,
60
+ root_path: str = "",
61
+ ) -> FastAPI:
62
+ """Factory creating configured FastAPI application."""
63
+ if broker is None:
64
+ client = redis.from_url(settings.redis_url, decode_responses=True)
65
+ broker = RedisBroker(client, prefix=settings.redis_prefix)
66
+ if task_reg is None:
67
+ task_reg = registry
68
+
69
+ scheduler = Scheduler(broker)
70
+ event_manager = WebSocketEventManager(broker)
71
+
72
+ @asynccontextmanager
73
+ async def lifespan(app: FastAPI):
74
+ await event_manager.start_listener()
75
+ sched_task = asyncio.create_task(scheduler.start())
76
+ try:
77
+ yield
78
+ finally:
79
+ await scheduler.stop()
80
+ sched_task.cancel()
81
+ await event_manager.stop_listener()
82
+
83
+ app = FastAPI(
84
+ title="TaskManager API",
85
+ description="Background task execution engine & real-time dashboard API",
86
+ version="0.1.0",
87
+ lifespan=lifespan,
88
+ root_path=root_path,
89
+ )
90
+
91
+ app.add_middleware(
92
+ CORSMiddleware,
93
+ allow_origins=["*"],
94
+ allow_credentials=True,
95
+ allow_methods=["*"],
96
+ allow_headers=["*"],
97
+ )
98
+
99
+ app.state.broker = broker
100
+ app.state.registry = task_reg
101
+ app.state.scheduler = scheduler
102
+ app.state.event_manager = event_manager
103
+
104
+ @app.exception_handler(ValueError)
105
+ async def value_error_handler(request, exc: ValueError):
106
+ return JSONResponse(status_code=400, content={"detail": str(exc)})
107
+
108
+ spawned_workers: dict[str, tuple[Worker, asyncio.Task[Any]]] = {}
109
+
110
+ # --- REST Endpoints ---
111
+
112
+ @app.get("/api/overview")
113
+ async def get_overview():
114
+ """Returns consolidated metrics for dashboard counters."""
115
+ for t_name in task_reg.list_tasks():
116
+ t = task_reg.get(t_name)
117
+ if t and t.queue:
118
+ await broker.redis.sadd(broker._key_queues(), t.queue)
119
+
120
+ queues = await broker.get_all_queues()
121
+ queue_summaries = []
122
+ total_pending = 0
123
+ total_delayed = 0
124
+ total_dlq = 0
125
+
126
+ for q in queues:
127
+ m = await broker.get_queue_metrics(q)
128
+ queue_summaries.append(m)
129
+ total_pending += m["pending"]
130
+ total_delayed += m["delayed"]
131
+ total_dlq += m["dlq"]
132
+
133
+ workers = await HeartbeatManager.get_all_workers(broker)
134
+ active_workers = [w for w in workers if w.status in ["idle", "busy", "paused", "throttled"]]
135
+ total_active_jobs = sum(w.active_jobs_count for w in active_workers)
136
+
137
+ schedules = await scheduler.list_schedules()
138
+
139
+ # Worker Resource Telemetry (from heartbeats / worker process RSS)
140
+ if active_workers:
141
+ worker_cpu_percent = round(sum(w.cpu_percent for w in active_workers) / len(active_workers), 1)
142
+ worker_memory_mb = round(sum(w.memory_mb for w in active_workers), 2)
143
+ worker_memory_detail = f"{len(active_workers)} worker(s) ativo(s)"
144
+ else:
145
+ try:
146
+ proc = psutil.Process()
147
+ worker_cpu_percent = psutil.cpu_percent(interval=None)
148
+ worker_memory_mb = round(proc.memory_info().rss / (1024 * 1024), 2)
149
+ worker_memory_detail = "Processo local"
150
+ except Exception:
151
+ worker_cpu_percent = 0.0
152
+ worker_memory_mb = 0.0
153
+ worker_memory_detail = "--"
154
+
155
+ persistence = await broker.check_persistence_health()
156
+
157
+ return {
158
+ "workers_count": len(active_workers),
159
+ "total_workers": len(workers),
160
+ "active_jobs": total_active_jobs,
161
+ "total_pending": total_pending,
162
+ "total_delayed": total_delayed,
163
+ "total_dlq": total_dlq,
164
+ "schedules_count": len(schedules),
165
+ "worker_cpu_percent": worker_cpu_percent,
166
+ "worker_memory_mb": worker_memory_mb,
167
+ "worker_memory_detail": worker_memory_detail,
168
+ "queues": queue_summaries,
169
+ "persistence": persistence,
170
+ }
171
+
172
+ @app.get("/api/queues")
173
+ async def get_queues():
174
+ queues = await broker.get_all_queues()
175
+ results = []
176
+ for q in queues:
177
+ m = await broker.get_queue_metrics(q)
178
+ results.append(m)
179
+ return results
180
+
181
+ @app.post("/api/queues")
182
+ async def create_queue_endpoint(payload: dict[str, str]):
183
+ name = payload.get("name", "").strip()
184
+ if not name:
185
+ raise HTTPException(status_code=400, detail="Nome da fila é obrigatório.")
186
+ created = await broker.create_queue(name)
187
+ if not created:
188
+ raise HTTPException(status_code=400, detail="Nome de fila inválido.")
189
+ metrics = await broker.get_queue_metrics(name)
190
+ return {"status": "created", "queue": name, "metrics": metrics}
191
+
192
+ @app.delete("/api/queues/{queue}")
193
+ async def delete_queue_endpoint(queue: str):
194
+ if queue == "default":
195
+ raise HTTPException(status_code=400, detail="A fila 'default' não pode ser excluída.")
196
+ deleted = await broker.delete_queue(queue)
197
+ if not deleted:
198
+ raise HTTPException(status_code=404, detail="Fila não encontrada.")
199
+ return {"status": "deleted", "queue": queue}
200
+
201
+ @app.get("/api/workers")
202
+ async def get_workers():
203
+ return await HeartbeatManager.get_all_workers(broker)
204
+
205
+ @app.post("/api/workers/spawn")
206
+ async def spawn_worker_endpoint(req: SpawnWorkerRequest):
207
+ """Dynamically creates and starts a new worker directly from the Dashboard/API."""
208
+ worker = Worker(
209
+ name=req.name,
210
+ queues=req.queues,
211
+ concurrency=req.concurrency,
212
+ max_memory_mb=req.max_memory_mb,
213
+ max_cpu_percent=req.max_cpu_percent,
214
+ broker=broker,
215
+ task_registry=task_reg,
216
+ )
217
+ task = asyncio.create_task(worker.start())
218
+ spawned_workers[worker.id] = (worker, task)
219
+ # Publish event so frontend updates instantly
220
+ await broker.publish_event(
221
+ "worker:spawned",
222
+ {
223
+ "id": worker.id,
224
+ "name": worker.name,
225
+ "queues": worker.queues,
226
+ "concurrency": worker.concurrency,
227
+ },
228
+ )
229
+ return {
230
+ "status": "started",
231
+ "id": worker.id,
232
+ "name": worker.name,
233
+ "queues": worker.queues,
234
+ "concurrency": worker.concurrency,
235
+ }
236
+
237
+ @app.post("/api/workers/{worker_id}/pause")
238
+ async def pause_worker_endpoint(worker_id: str):
239
+ """Pauses job consumption on a worker."""
240
+ if worker_id in spawned_workers:
241
+ spawned_workers[worker_id][0].pause()
242
+ await broker.publish_control("pause", worker_id=worker_id)
243
+ return {"status": "paused", "worker_id": worker_id}
244
+
245
+ @app.post("/api/workers/{worker_id}/resume")
246
+ async def resume_worker_endpoint(worker_id: str):
247
+ """Resumes job consumption on a worker."""
248
+ if worker_id in spawned_workers:
249
+ spawned_workers[worker_id][0].resume()
250
+ await broker.publish_control("resume", worker_id=worker_id)
251
+ return {"status": "resumed", "worker_id": worker_id}
252
+
253
+ @app.post("/api/workers/{worker_id}/stop")
254
+ async def stop_worker_endpoint(worker_id: str):
255
+ """Gracefully terminates a worker."""
256
+ if worker_id in spawned_workers:
257
+ w, _ = spawned_workers[worker_id]
258
+ await w.stop()
259
+ del spawned_workers[worker_id]
260
+ await broker.publish_control("stop", worker_id=worker_id)
261
+ return {"status": "stopped", "worker_id": worker_id}
262
+
263
+ @app.get("/api/tasks")
264
+ async def list_registered_tasks():
265
+ tasks = []
266
+ for name in task_reg.list_tasks():
267
+ t = task_reg.get(name)
268
+ if t:
269
+ sig_info = t.get_signature_info()
270
+ tasks.append(
271
+ {
272
+ "name": t.name,
273
+ "queue": t.queue,
274
+ "max_retries": t.max_retries,
275
+ "retry_backoff": t.retry_backoff,
276
+ "timeout": t.timeout,
277
+ "is_async": t.is_async,
278
+ "parameters": sig_info["parameters"],
279
+ "sample_kwargs": sig_info["sample_kwargs"],
280
+ "docstring": sig_info["docstring"],
281
+ }
282
+ )
283
+ return tasks
284
+
285
+ @app.post("/api/tasks/{task_name}/enqueue")
286
+ async def enqueue_task(task_name: str, req: EnqueueTaskRequest):
287
+ t = task_reg.get(task_name)
288
+ if not t:
289
+ # Allow dynamic enqueue even if not registered locally
290
+ job = Job(
291
+ task_name=task_name,
292
+ queue=req.queue or "default",
293
+ args=req.args,
294
+ kwargs=req.kwargs,
295
+ priority=req.priority,
296
+ max_retries=req.max_retries or 3,
297
+ timeout=req.timeout,
298
+ )
299
+ if req.delay and req.delay > 0:
300
+ enqueued = await broker.schedule_delayed(job, req.delay)
301
+ else:
302
+ enqueued = await broker.enqueue(job)
303
+ return enqueued
304
+
305
+ enqueued = await t.apply_async(
306
+ args=req.args,
307
+ kwargs=req.kwargs,
308
+ queue=req.queue,
309
+ delay=req.delay,
310
+ priority=req.priority,
311
+ max_retries=req.max_retries,
312
+ timeout=req.timeout,
313
+ )
314
+ return enqueued
315
+
316
+ @app.get("/api/jobs/history")
317
+ async def get_job_history(
318
+ limit: int = 50,
319
+ status: str | None = None,
320
+ task_name: str | None = None,
321
+ ):
322
+ """Returns recent job execution history with optional filtering."""
323
+ return await broker.get_history(limit=limit, status=status, task_name=task_name)
324
+
325
+ @app.get("/api/metrics/observability")
326
+ async def get_observability_metrics():
327
+ """Returns LGTM-style aggregated performance metrics (p95 latency, success rate, throughput)."""
328
+ return await broker.get_observability_metrics()
329
+
330
+ @app.get("/api/jobs/{job_id}")
331
+ async def get_job_by_id(job_id: str):
332
+ job = await broker.get_job(job_id)
333
+ if not job:
334
+ raise HTTPException(status_code=404, detail="Job not found")
335
+ return job
336
+
337
+ @app.post("/api/jobs/{job_id}/cancel")
338
+ async def cancel_job_endpoint(job_id: str):
339
+ success = await broker.cancel_job(job_id)
340
+ if not success:
341
+ raise HTTPException(
342
+ status_code=400, detail="Job cannot be cancelled (not pending/delayed or not found)"
343
+ )
344
+ return {"status": "cancelled", "job_id": job_id}
345
+
346
+ # --- Schedule Endpoints ---
347
+ @app.get("/api/schedules")
348
+ async def list_schedules():
349
+ return await scheduler.list_schedules()
350
+
351
+ @app.post("/api/schedules")
352
+ async def create_schedule(req: CreateScheduleRequest):
353
+ cron_expr = req.cron_expression.strip() if req.cron_expression else None
354
+ if req.schedule_type == "cron" and not cron_expr:
355
+ cron_expr = "0 * * * *"
356
+ interval_sec = req.interval_seconds if req.interval_seconds and req.interval_seconds > 0 else (60.0 if req.schedule_type == "interval" else None)
357
+
358
+ sched = Schedule(
359
+ name=req.name,
360
+ task_name=req.task_name,
361
+ queue=req.queue,
362
+ schedule_type=req.schedule_type,
363
+ cron_expression=cron_expr,
364
+ interval_seconds=interval_sec,
365
+ args=req.args,
366
+ kwargs=req.kwargs,
367
+ enabled=req.enabled,
368
+ )
369
+ created = await scheduler.add_schedule(sched)
370
+ return created
371
+
372
+ @app.get("/api/schedules/{schedule_id}")
373
+ async def get_schedule(schedule_id: str):
374
+ sched = await scheduler.get_schedule(schedule_id)
375
+ if not sched:
376
+ raise HTTPException(status_code=404, detail="Schedule not found")
377
+ return sched
378
+
379
+ @app.put("/api/schedules/{schedule_id}")
380
+ async def update_schedule(schedule_id: str, req: CreateScheduleRequest):
381
+ existing = await scheduler.get_schedule(schedule_id)
382
+ if not existing:
383
+ raise HTTPException(status_code=404, detail="Schedule not found")
384
+
385
+ cron_expr = req.cron_expression.strip() if req.cron_expression else None
386
+ if req.schedule_type == "cron" and not cron_expr:
387
+ cron_expr = "0 * * * *"
388
+ interval_sec = req.interval_seconds if req.interval_seconds and req.interval_seconds > 0 else (60.0 if req.schedule_type == "interval" else None)
389
+
390
+ existing.name = req.name
391
+ existing.task_name = req.task_name
392
+ existing.queue = req.queue
393
+ existing.schedule_type = req.schedule_type
394
+ existing.cron_expression = cron_expr
395
+ existing.interval_seconds = interval_sec
396
+ existing.args = req.args
397
+ existing.kwargs = req.kwargs
398
+ existing.enabled = req.enabled
399
+
400
+ updated = await scheduler.update_schedule(existing)
401
+ return updated
402
+
403
+ @app.delete("/api/schedules/{schedule_id}")
404
+ async def delete_schedule(schedule_id: str):
405
+ deleted = await scheduler.delete_schedule(schedule_id)
406
+ if not deleted:
407
+ raise HTTPException(status_code=404, detail="Schedule not found")
408
+ return {"status": "deleted", "schedule_id": schedule_id}
409
+
410
+ @app.post("/api/schedules/{schedule_id}/toggle")
411
+ async def toggle_schedule(schedule_id: str, payload: dict[str, bool]):
412
+ enabled = payload.get("enabled", True)
413
+ sched = await scheduler.toggle_schedule(schedule_id, enabled)
414
+ if not sched:
415
+ raise HTTPException(status_code=404, detail="Schedule not found")
416
+ return sched
417
+
418
+ @app.post("/api/schedules/{schedule_id}/trigger")
419
+ async def trigger_schedule(schedule_id: str):
420
+ job = await scheduler.trigger_now(schedule_id)
421
+ if not job:
422
+ raise HTTPException(status_code=404, detail="Schedule not found")
423
+ return {"status": "triggered", "job": job}
424
+
425
+ # --- DLQ Endpoints ---
426
+ @app.get("/api/dlq")
427
+ @app.get("/api/dlq/{queue}")
428
+ async def get_dlq(queue: str = "all"):
429
+ return await broker.get_dlq_jobs(queue)
430
+
431
+ @app.post("/api/dlq/{job_id}/replay")
432
+ async def replay_dlq(job_id: str):
433
+ replayed = await broker.replay_dlq_job(job_id)
434
+ if not replayed:
435
+ raise HTTPException(status_code=404, detail="Job not found in DLQ")
436
+ return {"status": "replayed", "job": replayed}
437
+
438
+ @app.post("/api/dlq/purge")
439
+ @app.post("/api/dlq/{queue}/purge")
440
+ async def purge_dlq(queue: str = "all"):
441
+ count = await broker.purge_dlq(queue)
442
+ return {"status": "purged", "count": count, "queue": queue}
443
+
444
+ # --- Maintenance & Flush Endpoints ---
445
+ @app.post("/api/maintenance/flush")
446
+ async def flush_maintenance(payload: dict[str, str]):
447
+ target = payload.get("target", "queues")
448
+ if target == "queues":
449
+ res = await broker.flush_queues()
450
+ return {"status": "ok", "target": "queues", "result": res}
451
+ elif target == "history":
452
+ res = await broker.flush_history()
453
+ return {"status": "ok", "target": "history", "deleted_keys": res}
454
+ elif target == "all":
455
+ res = await broker.flush_all()
456
+ return {"status": "ok", "target": "all", "deleted_keys": res}
457
+ else:
458
+ raise HTTPException(
459
+ status_code=400,
460
+ detail="Invalid target. Must be 'queues', 'history', or 'all'.",
461
+ )
462
+
463
+ # --- WebSocket Real-Time Stream ---
464
+ @app.websocket("/ws/events")
465
+ async def websocket_endpoint(websocket: WebSocket):
466
+ await event_manager.connect(websocket)
467
+ try:
468
+ while True:
469
+ # Keepalive loop
470
+ await websocket.receive_text()
471
+ except WebSocketDisconnect:
472
+ event_manager.disconnect(websocket)
473
+ except Exception:
474
+ event_manager.disconnect(websocket)
475
+
476
+ # --- Static UI Mount ---
477
+ ui_dir = Path(__file__).resolve().parent.parent / "ui"
478
+ if ui_dir.exists():
479
+ app.mount("/static", StaticFiles(directory=str(ui_dir)), name="static")
480
+
481
+ @app.get("/")
482
+ async def index():
483
+ index_file = ui_dir / "index.html"
484
+ if index_file.exists():
485
+ return FileResponse(str(index_file))
486
+ return JSONResponse({"status": "ok", "message": "TaskManager API is running"})
487
+
488
+ return app