-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathcheckpoint.py
More file actions
196 lines (140 loc) · 6.5 KB
/
Copy pathcheckpoint.py
File metadata and controls
196 lines (140 loc) · 6.5 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
"""
Gerenciador de checkpoint — leitura/escrita atômica de estado no filesystem.
Toda escrita usa o padrão write-to-temp-then-rename para garantir que
um crash no meio da operação não corrompe o JSON.
"""
from __future__ import annotations
import json
import os
import tempfile
from datetime import datetime, timedelta, timezone
from pathlib import Path
from typing import Optional, TypeVar
from pydantic import BaseModel
from models import (
Alert, AlertList, AlertSeverity, Checkpoint, GlobalStats,
History, HistoryEvent, Project, SessionLock, TaskList,
)
import config
T = TypeVar("T", bound=BaseModel)
# ── Escrita Atômica ────────────────────────────────────────────────
def atomic_write_json(path: Path, data: dict) -> None:
"""Escreve JSON de forma atômica via temp file + rename."""
path.parent.mkdir(parents=True, exist_ok=True)
fd, tmp_path = tempfile.mkstemp(
dir=str(path.parent),
suffix=".tmp",
)
try:
with os.fdopen(fd, "w") as f:
json.dump(data, f, indent=2, ensure_ascii=False, default=str)
os.replace(tmp_path, str(path))
except Exception:
if os.path.exists(tmp_path):
os.unlink(tmp_path)
raise
def save_model(path: Path, model: BaseModel) -> None:
"""Serializa um modelo Pydantic e salva atomicamente."""
atomic_write_json(path, model.model_dump(mode="json"))
def load_model(path: Path, model_class: type[T]) -> Optional[T]:
"""Carrega um modelo Pydantic do filesystem. Retorna None se não existir."""
if not path.exists():
return None
with open(path) as f:
return model_class.model_validate_json(f.read())
# ── Checkpoint ─────────────────────────────────────────────────────
def checkpoint_path(project_id: str) -> Path:
return config.project_dir(project_id) / "checkpoint.json"
def load_checkpoint(project_id: str) -> Optional[Checkpoint]:
return load_model(checkpoint_path(project_id), Checkpoint)
def save_checkpoint(project_id: str, cp: Checkpoint) -> None:
cp.last_heartbeat = datetime.now(timezone.utc)
save_model(checkpoint_path(project_id), cp)
def heartbeat(project_id: str, cp: Checkpoint) -> None:
"""Atualiza apenas o heartbeat — operação leve para o timer."""
cp.last_heartbeat = datetime.now(timezone.utc)
save_checkpoint(project_id, cp)
# ── Project State ──────────────────────────────────────────────────
def load_project(project_id: str) -> Optional[Project]:
return load_model(config.project_dir(project_id) / "project.json", Project)
def save_project(project_id: str, project: Project) -> None:
project.updated_at = datetime.now(timezone.utc)
save_model(config.project_dir(project_id) / "project.json", project)
def load_tasks(project_id: str) -> TaskList:
result = load_model(config.project_dir(project_id) / "tasks.json", TaskList)
return result or TaskList()
def save_tasks(project_id: str, tasks: TaskList) -> None:
save_model(config.project_dir(project_id) / "tasks.json", tasks)
def load_history(project_id: str) -> History:
result = load_model(config.project_dir(project_id) / "history.json", History)
return result or History()
def save_history(project_id: str, history: History) -> None:
save_model(config.project_dir(project_id) / "history.json", history)
def append_event(project_id: str, event: HistoryEvent) -> None:
"""Adiciona um evento ao histórico e salva."""
history = load_history(project_id)
history.append(event)
save_history(project_id, history)
# ── Session Lock ───────────────────────────────────────────────────
def acquire_lock(session_id: str) -> bool:
"""Tenta adquirir o lock. Retorna True se conseguiu."""
existing = load_model(config.SESSION_LOCK_FILE, SessionLock)
if existing is not None:
# Lock existe — checar se expirou
expires_at = existing.acquired_at + timedelta(
minutes=existing.ttl_minutes
)
if datetime.now(timezone.utc) < expires_at:
# Lock ainda válido e de outra sessão
if existing.holder != session_id:
return False
# Mesma sessão — renovar
# Lock expirado — pode tomar
lock = SessionLock(
holder=session_id,
acquired_at=datetime.now(timezone.utc),
ttl_minutes=config.SESSION_LOCK_TTL_MINUTES,
pid=os.getpid(),
)
save_model(config.SESSION_LOCK_FILE, lock)
return True
def release_lock() -> None:
"""Libera o lock."""
if config.SESSION_LOCK_FILE.exists():
config.SESSION_LOCK_FILE.unlink()
def renew_lock(session_id: str) -> None:
"""Renova o TTL do lock."""
existing = load_model(config.SESSION_LOCK_FILE, SessionLock)
if existing and existing.holder == session_id:
existing.acquired_at = datetime.now(timezone.utc)
save_model(config.SESSION_LOCK_FILE, existing)
# ── Alerts ─────────────────────────────────────────────────────────
def load_alerts() -> AlertList:
result = load_model(config.ALERTS_FILE, AlertList)
return result or AlertList()
def add_alert(
project_id: str,
alert_type: str,
message: str,
task_id: Optional[str] = None,
severity: AlertSeverity = AlertSeverity.critical,
) -> None:
"""Adiciona alerta e persiste."""
alerts = load_alerts()
alerts.alerts.append(Alert(
project_id=project_id,
severity=severity,
type=alert_type,
task_id=task_id,
message=message,
))
save_model(config.ALERTS_FILE, alerts)
# ── Global Stats ───────────────────────────────────────────────────
def load_stats() -> GlobalStats:
result = load_model(config.STATS_FILE, GlobalStats)
today = datetime.now(timezone.utc).strftime("%Y-%m-%d")
if result is None or result.date != today:
return GlobalStats(date=today)
return result
def save_stats(stats: GlobalStats) -> None:
save_model(config.STATS_FILE, stats)