-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathexecution_trace.py
More file actions
165 lines (146 loc) · 5.55 KB
/
Copy pathexecution_trace.py
File metadata and controls
165 lines (146 loc) · 5.55 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
"""Durable, secret-safe progress reporting for maintainer controller runs."""
from __future__ import annotations
import fcntl
import json
import uuid
from contextlib import contextmanager
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Iterator
UTC = timezone.utc
TRACE_FILE_NAME = "execution.json"
MAX_EVENTS = 160
MAX_HISTORY = 30
TERMINAL_STATUSES = {"completed", "failed", "blocked"}
def now_iso() -> str:
return datetime.now(UTC).isoformat()
def default_trace() -> dict[str, Any]:
return {"schema_version": 1, "current": None, "history": []}
class ExecutionTrace:
"""A small cross-process event log shared by the live worker and Console."""
def __init__(self, state_path: Path, run_id: str | None = None, trace_path: Path | None = None):
self.state_path = Path(state_path).expanduser()
self.path = (trace_path or self.state_path / TRACE_FILE_NAME).expanduser()
self.lock_path = self.path.with_suffix(self.path.suffix + ".lock")
self.run_id = run_id
def _read_unlocked(self) -> dict[str, Any]:
if not self.path.exists():
return default_trace()
try:
value = json.loads(self.path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
return default_trace()
if not isinstance(value, dict):
return default_trace()
merged = default_trace()
merged.update(value)
if not isinstance(merged.get("history"), list):
merged["history"] = []
return merged
@contextmanager
def _locked(self) -> Iterator[None]:
self.path.parent.mkdir(parents=True, exist_ok=True)
with self.lock_path.open("a+", encoding="utf-8") as lock_file:
fcntl.flock(lock_file.fileno(), fcntl.LOCK_EX)
try:
yield
finally:
fcntl.flock(lock_file.fileno(), fcntl.LOCK_UN)
def _write_unlocked(self, value: dict[str, Any]) -> None:
temporary = self.path.with_suffix(self.path.suffix + ".tmp")
temporary.write_text(json.dumps(value, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
temporary.replace(self.path)
def snapshot(self) -> dict[str, Any]:
with self._locked():
return self._read_unlocked()
@staticmethod
def _event(
current: dict[str, Any],
phase: str,
status: str,
message: str,
command: str | None,
details: dict[str, Any] | None,
) -> dict[str, Any]:
events = list(current.get("events") or [])
event = {
"sequence": len(events) + 1,
"at": now_iso(),
"phase": phase,
"status": status,
"message": message,
}
if command:
event["command"] = command
if details:
event["details"] = dict(details)
events.append(event)
current["events"] = events[-MAX_EVENTS:]
current["phase"] = phase
current["status"] = status if phase in {"completed", "failed", "blocked"} else "running"
current["message"] = message
current["updated_at"] = event["at"]
if details:
context = dict(current.get("context") or {})
context.update(details)
current["context"] = context
return event
def start(
self,
kind: str = "live_cycle",
trigger: str = "systemd timer",
message: str = "Maintainer cycle started",
) -> str:
self.run_id = self.run_id or f"{kind}-{uuid.uuid4().hex[:12]}"
started_at = now_iso()
current: dict[str, Any] = {
"id": self.run_id,
"kind": kind,
"trigger": trigger,
"status": "running",
"phase": "starting",
"message": message,
"started_at": started_at,
"updated_at": started_at,
"finished_at": None,
"context": {},
"events": [],
}
self._event(current, "starting", "running", message, "live_cycle.py", None)
with self._locked():
state = self._read_unlocked()
state["current"] = current
self._write_unlocked(state)
return self.run_id
def event(
self,
phase: str,
status: str = "running",
message: str = "",
command: str | None = None,
details: dict[str, Any] | None = None,
) -> dict[str, Any] | None:
with self._locked():
state = self._read_unlocked()
current = state.get("current")
if not isinstance(current, dict):
return None
if self.run_id and current.get("id") != self.run_id:
return None
self._event(current, phase, status, message, command, details)
if phase in {"completed", "failed", "blocked"} and status in TERMINAL_STATUSES:
current["finished_at"] = current.get("updated_at") or now_iso()
history = list(state.get("history") or [])
history.append(dict(current))
state["history"] = history[-MAX_HISTORY:]
state["current"] = current
self._write_unlocked(state)
return dict(current)
def finish(
self,
status: str,
message: str,
details: dict[str, Any] | None = None,
) -> dict[str, Any] | None:
phase = "completed" if status == "completed" else "failed" if status == "failed" else "blocked"
return self.event(phase, status, message, details=details)