Skip to content

Commit b9b900c

Browse files
committed
Phase 5: 자동화 태스크 시스템 구현
- automation/taskModel.py: TaskDefinition, TaskRun, TaskStatus 모델 - automation/taskRunner.py: 문서를 headless로 실행 (기존 SessionManager 재사용) - automation/scheduler.py: asyncio 기반 인프로세스 스케줄러 (@every_Xm/h/s 프리셋) - automation/taskRegistry.py: 태스크 CRUD + 실행 이력, ~/.codaro/tasks/ 디스크 영속 - api/automationRouter.py: GET/POST/PUT/DELETE /api/tasks, POST /api/tasks/{id}/run - cli.py: `codaro task run <path>`, `codaro task list` 커맨드 추가 - server.py: automation 라우터 등록 - 테스트 22개 추가 (총 231개 통과)
1 parent 3bc3f66 commit b9b900c

10 files changed

Lines changed: 750 additions & 1 deletion

File tree

src/codaro/api/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
from .aiRouter import createAiRouter
2+
from .automationRouter import createAutomationRouter
23
from .appState import ServerState, createServerState
34
from .bootstrapRouter import createBootstrapRouter
45
from .curriculumRouter import createCurriculumRouter
@@ -38,6 +39,7 @@
3839
"ApiError",
3940
"apiErrorHandler",
4041
"createAiRouter",
42+
"createAutomationRouter",
4143
"createBootstrapRouter",
4244
"createCurriculumRouter",
4345
"createDocumentRouter",

src/codaro/api/automationRouter.py

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
from __future__ import annotations
2+
3+
import logging
4+
from typing import Any
5+
6+
from fastapi import APIRouter, HTTPException, Query
7+
from pydantic import BaseModel
8+
9+
from ..automation.taskModel import TaskDefinition
10+
from ..automation.taskRegistry import getTaskRegistry
11+
from ..automation.taskRunner import TaskRunner
12+
13+
logger = logging.getLogger(__name__)
14+
15+
16+
class CreateTaskRequest(BaseModel):
17+
name: str
18+
documentPath: str
19+
description: str = ""
20+
schedule: str | None = None
21+
inputs: dict[str, Any] | None = None
22+
23+
24+
class UpdateTaskRequest(BaseModel):
25+
name: str | None = None
26+
description: str | None = None
27+
schedule: str | None = None
28+
enabled: bool | None = None
29+
30+
31+
class ScheduleRequest(BaseModel):
32+
schedule: str
33+
34+
35+
def createAutomationRouter(state: Any) -> APIRouter:
36+
router = APIRouter()
37+
38+
@router.get("/api/tasks")
39+
def apiListTasks():
40+
registry = getTaskRegistry()
41+
tasks = registry.listTasks()
42+
return {
43+
"tasks": [t.serialize() for t in tasks],
44+
"total": len(tasks),
45+
}
46+
47+
@router.post("/api/tasks")
48+
def apiCreateTask(req: CreateTaskRequest):
49+
registry = getTaskRegistry()
50+
task = registry.create(
51+
name=req.name,
52+
documentPath=req.documentPath,
53+
description=req.description,
54+
schedule=req.schedule,
55+
inputs=req.inputs,
56+
)
57+
return task.serialize()
58+
59+
@router.get("/api/tasks/{taskId}")
60+
def apiGetTask(taskId: str):
61+
registry = getTaskRegistry()
62+
task = registry.get(taskId)
63+
if task is None:
64+
raise HTTPException(status_code=404, detail="Task not found")
65+
lastRun = registry.getLastRun(taskId)
66+
result = task.serialize()
67+
if lastRun:
68+
result["lastRun"] = lastRun.serialize()
69+
return result
70+
71+
@router.put("/api/tasks/{taskId}")
72+
def apiUpdateTask(taskId: str, req: UpdateTaskRequest):
73+
registry = getTaskRegistry()
74+
task = registry.update(
75+
taskId,
76+
name=req.name,
77+
description=req.description,
78+
schedule=req.schedule,
79+
enabled=req.enabled,
80+
)
81+
if task is None:
82+
raise HTTPException(status_code=404, detail="Task not found")
83+
return task.serialize()
84+
85+
@router.delete("/api/tasks/{taskId}")
86+
def apiDeleteTask(taskId: str):
87+
registry = getTaskRegistry()
88+
deleted = registry.delete(taskId)
89+
if not deleted:
90+
raise HTTPException(status_code=404, detail="Task not found")
91+
return {"ok": True}
92+
93+
@router.post("/api/tasks/{taskId}/run")
94+
async def apiRunTask(taskId: str):
95+
registry = getTaskRegistry()
96+
task = registry.get(taskId)
97+
if task is None:
98+
raise HTTPException(status_code=404, detail="Task not found")
99+
100+
workspaceRoot = str(getattr(state, "workspaceRoot", "."))
101+
runner = TaskRunner(workspaceRoot=workspaceRoot)
102+
run = await runner.run(task)
103+
registry.addRun(run)
104+
return run.serialize()
105+
106+
@router.get("/api/tasks/{taskId}/runs")
107+
def apiGetRuns(taskId: str, limit: int = Query(20)):
108+
registry = getTaskRegistry()
109+
task = registry.get(taskId)
110+
if task is None:
111+
raise HTTPException(status_code=404, detail="Task not found")
112+
runs = registry.getRuns(taskId, limit=limit)
113+
return {"runs": [r.serialize() for r in runs]}
114+
115+
@router.put("/api/tasks/{taskId}/schedule")
116+
def apiSetSchedule(taskId: str, req: ScheduleRequest):
117+
registry = getTaskRegistry()
118+
task = registry.update(taskId, schedule=req.schedule)
119+
if task is None:
120+
raise HTTPException(status_code=404, detail="Task not found")
121+
122+
from ..automation.scheduler import TaskScheduler, parseScheduleSeconds
123+
124+
if parseScheduleSeconds(req.schedule) is None:
125+
raise HTTPException(status_code=400, detail=f"Invalid schedule: {req.schedule}")
126+
127+
return {"ok": True, "schedule": req.schedule}
128+
129+
return router

src/codaro/automation/__init__.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
from .taskModel import TaskDefinition, TaskRun, TaskStatus
2+
from .taskRunner import TaskRunner
3+
from .scheduler import TaskScheduler
4+
from .taskRegistry import TaskRegistry, getTaskRegistry
5+
6+
__all__ = [
7+
"TaskDefinition",
8+
"TaskRun",
9+
"TaskStatus",
10+
"TaskRunner",
11+
"TaskScheduler",
12+
"TaskRegistry",
13+
"getTaskRegistry",
14+
]

src/codaro/automation/scheduler.py

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
from __future__ import annotations
2+
3+
import asyncio
4+
import logging
5+
import re
6+
from datetime import datetime, timezone
7+
from typing import Any, Callable, Coroutine
8+
9+
logger = logging.getLogger(__name__)
10+
11+
_CRON_PRESETS: dict[str, int] = {
12+
"@every_1m": 60,
13+
"@every_5m": 300,
14+
"@every_15m": 900,
15+
"@every_30m": 1800,
16+
"@every_1h": 3600,
17+
"@every_6h": 21600,
18+
"@every_12h": 43200,
19+
"@daily": 86400,
20+
}
21+
22+
23+
def parseScheduleSeconds(schedule: str) -> int | None:
24+
if schedule in _CRON_PRESETS:
25+
return _CRON_PRESETS[schedule]
26+
27+
match = re.match(r"^@every_(\d+)(s|m|h)$", schedule)
28+
if match:
29+
value = int(match.group(1))
30+
unit = match.group(2)
31+
if unit == "s":
32+
return value
33+
if unit == "m":
34+
return value * 60
35+
if unit == "h":
36+
return value * 3600
37+
38+
return None
39+
40+
41+
class TaskScheduler:
42+
43+
def __init__(self) -> None:
44+
self._jobs: dict[str, asyncio.Task[None]] = {}
45+
self._running = False
46+
47+
@property
48+
def jobCount(self) -> int:
49+
return len(self._jobs)
50+
51+
def schedule(
52+
self,
53+
taskId: str,
54+
schedule: str,
55+
callback: Callable[[], Coroutine[Any, Any, None]],
56+
) -> bool:
57+
intervalSeconds = parseScheduleSeconds(schedule)
58+
if intervalSeconds is None:
59+
return False
60+
61+
self.cancel(taskId)
62+
63+
async def _loop() -> None:
64+
while True:
65+
await asyncio.sleep(intervalSeconds)
66+
try:
67+
await callback()
68+
except asyncio.CancelledError:
69+
break
70+
except Exception:
71+
logger.exception("Scheduled task %s failed", taskId)
72+
73+
self._jobs[taskId] = asyncio.create_task(_loop())
74+
return True
75+
76+
def cancel(self, taskId: str) -> bool:
77+
job = self._jobs.pop(taskId, None)
78+
if job is not None:
79+
job.cancel()
80+
return True
81+
return False
82+
83+
def cancelAll(self) -> int:
84+
count = len(self._jobs)
85+
for job in self._jobs.values():
86+
job.cancel()
87+
self._jobs.clear()
88+
return count
89+
90+
def isScheduled(self, taskId: str) -> bool:
91+
return taskId in self._jobs
92+
93+
def listScheduled(self) -> list[str]:
94+
return list(self._jobs.keys())

src/codaro/automation/taskModel.py

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
from __future__ import annotations
2+
3+
import uuid
4+
from dataclasses import dataclass, field
5+
from datetime import datetime, timezone
6+
from enum import Enum
7+
from typing import Any
8+
9+
10+
class TaskStatus(str, Enum):
11+
PENDING = "pending"
12+
RUNNING = "running"
13+
SUCCESS = "success"
14+
FAILED = "failed"
15+
CANCELLED = "cancelled"
16+
17+
18+
@dataclass
19+
class TaskDefinition:
20+
id: str = field(default_factory=lambda: f"task-{uuid.uuid4().hex[:10]}")
21+
name: str = ""
22+
description: str = ""
23+
documentPath: str = ""
24+
schedule: str | None = None
25+
inputs: dict[str, Any] = field(default_factory=dict)
26+
outputs: list[str] = field(default_factory=list)
27+
createdAt: str = field(default_factory=lambda: datetime.now(timezone.utc).isoformat())
28+
updatedAt: str = field(default_factory=lambda: datetime.now(timezone.utc).isoformat())
29+
enabled: bool = True
30+
31+
def serialize(self) -> dict[str, Any]:
32+
return {
33+
"id": self.id,
34+
"name": self.name,
35+
"description": self.description,
36+
"documentPath": self.documentPath,
37+
"schedule": self.schedule,
38+
"inputs": self.inputs,
39+
"outputs": self.outputs,
40+
"createdAt": self.createdAt,
41+
"updatedAt": self.updatedAt,
42+
"enabled": self.enabled,
43+
}
44+
45+
46+
@dataclass
47+
class TaskRun:
48+
id: str = field(default_factory=lambda: f"run-{uuid.uuid4().hex[:10]}")
49+
taskId: str = ""
50+
status: TaskStatus = TaskStatus.PENDING
51+
startedAt: str | None = None
52+
finishedAt: str | None = None
53+
durationMs: int | None = None
54+
output: str = ""
55+
error: str | None = None
56+
variables: dict[str, Any] = field(default_factory=dict)
57+
58+
def serialize(self) -> dict[str, Any]:
59+
return {
60+
"id": self.id,
61+
"taskId": self.taskId,
62+
"status": self.status.value,
63+
"startedAt": self.startedAt,
64+
"finishedAt": self.finishedAt,
65+
"durationMs": self.durationMs,
66+
"output": self.output,
67+
"error": self.error,
68+
"variables": self.variables,
69+
}

0 commit comments

Comments
 (0)