PyTaskWireCheck 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.
Files changed (45) hide show
  1. pytaskwirecheck/__init__.py +32 -0
  2. pytaskwirecheck/api.py +314 -0
  3. pytaskwirecheck/cache/__init__.py +0 -0
  4. pytaskwirecheck/cache/fingerprint.py +176 -0
  5. pytaskwirecheck/cache/policy.py +49 -0
  6. pytaskwirecheck/cache/store.py +367 -0
  7. pytaskwirecheck/cli.py +690 -0
  8. pytaskwirecheck/config/__init__.py +0 -0
  9. pytaskwirecheck/config/loader.py +72 -0
  10. pytaskwirecheck/config/schema.py +170 -0
  11. pytaskwirecheck/config/validation.py +590 -0
  12. pytaskwirecheck/diagnostics/__init__.py +31 -0
  13. pytaskwirecheck/diagnostics/errors.py +86 -0
  14. pytaskwirecheck/engine/__init__.py +0 -0
  15. pytaskwirecheck/engine/cancellation.py +38 -0
  16. pytaskwirecheck/engine/events.py +93 -0
  17. pytaskwirecheck/engine/executor.py +301 -0
  18. pytaskwirecheck/engine/scheduler.py +312 -0
  19. pytaskwirecheck/engine/state.py +123 -0
  20. pytaskwirecheck/graph/__init__.py +0 -0
  21. pytaskwirecheck/graph/builder.py +33 -0
  22. pytaskwirecheck/graph/model.py +104 -0
  23. pytaskwirecheck/graph/planner.py +155 -0
  24. pytaskwirecheck/graph/validator.py +92 -0
  25. pytaskwirecheck/history/__init__.py +0 -0
  26. pytaskwirecheck/history/store.py +164 -0
  27. pytaskwirecheck/logging/__init__.py +0 -0
  28. pytaskwirecheck/logging/artifacts.py +95 -0
  29. pytaskwirecheck/logging/structured.py +83 -0
  30. pytaskwirecheck/notifications/__init__.py +0 -0
  31. pytaskwirecheck/notifications/base.py +42 -0
  32. pytaskwirecheck/notifications/webhook.py +71 -0
  33. pytaskwirecheck/processes/__init__.py +0 -0
  34. pytaskwirecheck/processes/runner.py +265 -0
  35. pytaskwirecheck/processes/signals.py +65 -0
  36. pytaskwirecheck/py.typed +0 -0
  37. pytaskwirecheck/tui/__init__.py +0 -0
  38. pytaskwirecheck/tui/app.py +204 -0
  39. pytaskwirecheck/tui/renderer.py +143 -0
  40. pytaskwirecheck-0.1.0.dist-info/METADATA +316 -0
  41. pytaskwirecheck-0.1.0.dist-info/RECORD +45 -0
  42. pytaskwirecheck-0.1.0.dist-info/WHEEL +5 -0
  43. pytaskwirecheck-0.1.0.dist-info/entry_points.txt +2 -0
  44. pytaskwirecheck-0.1.0.dist-info/licenses/LICENSE +21 -0
  45. pytaskwirecheck-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,155 @@
1
+ """Topological planning for the task graph.
2
+
3
+ The planner produces deterministic orderings and parallelism opportunities. It
4
+ is pure: it never launches processes, touches the cache, or renders output.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from dataclasses import dataclass
10
+ from typing import Dict, List, Optional, Sequence, Set, Tuple
11
+
12
+ from pytaskwirecheck.diagnostics.errors import GraphError
13
+ from pytaskwirecheck.graph.model import TaskGraph
14
+
15
+
16
+ @dataclass(frozen=True)
17
+ class PlannedTask:
18
+ """A task as it appears in a plan."""
19
+
20
+ id: str
21
+ dependencies: Tuple[str, ...]
22
+ level: int
23
+ cache_eligible: bool
24
+
25
+
26
+ @dataclass(frozen=True)
27
+ class ExecutionPlan:
28
+ """A deterministic execution plan for a (sub)set of tasks."""
29
+
30
+ order: Tuple[str, ...]
31
+ levels: Tuple[Tuple[str, ...], ...]
32
+ tasks: Tuple[PlannedTask, ...]
33
+
34
+ @property
35
+ def max_parallelism(self) -> int:
36
+ return max((len(level) for level in self.levels), default=0)
37
+
38
+
39
+ def _ordered_ids(graph: TaskGraph, subset: Optional[Set[str]]) -> List[str]:
40
+ ids = [node.id for node in graph]
41
+ if subset is None:
42
+ return ids
43
+ return [task_id for task_id in ids if task_id in subset]
44
+
45
+
46
+ def topological_order(graph: TaskGraph, subset: Optional[Set[str]] = None) -> List[str]:
47
+ """Return a deterministic topological order of the selected tasks.
48
+
49
+ Ties are broken by declaration order, so the same configuration always
50
+ yields the same plan.
51
+
52
+ Raises:
53
+ GraphError: if the selected subgraph contains a cycle.
54
+ """
55
+ ordered = _ordered_ids(graph, subset)
56
+ selected = set(ordered)
57
+ rank = {task_id: index for index, task_id in enumerate(ordered)}
58
+
59
+ indegree: Dict[str, int] = {}
60
+ for task_id in ordered:
61
+ deps = [d for d in graph.dependencies(task_id) if d in selected]
62
+ indegree[task_id] = len(deps)
63
+
64
+ ready: List[str] = sorted(
65
+ (t for t in ordered if indegree[t] == 0), key=rank.__getitem__
66
+ )
67
+ result: List[str] = []
68
+ while ready:
69
+ current = ready.pop(0)
70
+ result.append(current)
71
+ newly_ready: List[str] = []
72
+ for dependent in graph.dependents(current):
73
+ if dependent not in selected:
74
+ continue
75
+ indegree[dependent] -= 1
76
+ if indegree[dependent] == 0:
77
+ newly_ready.append(dependent)
78
+ if newly_ready:
79
+ ready.extend(newly_ready)
80
+ ready.sort(key=rank.__getitem__)
81
+
82
+ if len(result) != len(ordered):
83
+ raise GraphError("cycle detected while planning")
84
+ return result
85
+
86
+
87
+ def execution_levels(
88
+ graph: TaskGraph, subset: Optional[Set[str]] = None
89
+ ) -> List[List[str]]:
90
+ """Group selected tasks into levels that may run in parallel.
91
+
92
+ Level 0 contains tasks with no selected dependencies; each subsequent level
93
+ contains tasks whose dependencies all live in earlier levels.
94
+ """
95
+ ordered = _ordered_ids(graph, subset)
96
+ selected = set(ordered)
97
+ level_of: Dict[str, int] = {}
98
+ levels: List[List[str]] = []
99
+ for task_id in topological_order(graph, subset):
100
+ deps = [d for d in graph.dependencies(task_id) if d in selected]
101
+ level = 0
102
+ if deps:
103
+ level = max(level_of[d] for d in deps) + 1
104
+ level_of[task_id] = level
105
+ while len(levels) <= level:
106
+ levels.append([])
107
+ levels[level].append(task_id)
108
+ return levels
109
+
110
+
111
+ def build_plan(graph: TaskGraph, subset: Optional[Set[str]] = None) -> ExecutionPlan:
112
+ """Build a full :class:`ExecutionPlan` for the selected tasks."""
113
+ order = topological_order(graph, subset)
114
+ levels = execution_levels(graph, subset)
115
+ level_of: Dict[str, int] = {}
116
+ for index, level in enumerate(levels):
117
+ for task_id in level:
118
+ level_of[task_id] = index
119
+
120
+ def sort_key(task_id: str) -> int:
121
+ return order.index(task_id)
122
+
123
+ tasks = tuple(
124
+ PlannedTask(
125
+ id=task_id,
126
+ dependencies=tuple(
127
+ d for d in graph.dependencies(task_id) if d in set(order)
128
+ ),
129
+ level=level_of[task_id],
130
+ cache_eligible=graph.get(task_id).config.cache_eligible,
131
+ )
132
+ for task_id in order
133
+ )
134
+ normalized_levels = tuple(tuple(sorted(level, key=sort_key)) for level in levels)
135
+ return ExecutionPlan(order=tuple(order), levels=normalized_levels, tasks=tasks)
136
+
137
+
138
+ def resolve_targets(
139
+ graph: TaskGraph, targets: Sequence[str], *, include_dependents: bool = False
140
+ ) -> Set[str]:
141
+ """Resolve the set of tasks to run for the given target names.
142
+
143
+ With no targets, the entire graph is selected. Otherwise the dependency
144
+ closure of the targets is selected, and optionally every task that depends
145
+ on them.
146
+ """
147
+ if not targets:
148
+ return set(graph.nodes.keys())
149
+ for target in targets:
150
+ if target not in graph.nodes:
151
+ raise GraphError("unknown task: %s" % target, task=target)
152
+ selected = graph.closure(list(targets))
153
+ if include_dependents:
154
+ selected |= graph.reverse_closure(list(targets))
155
+ return selected
@@ -0,0 +1,92 @@
1
+ """Validation of task graphs: missing dependencies and cycles.
2
+
3
+ Validation is read-only and never executes tasks. It raises
4
+ :class:`~pytaskwirecheck.diagnostics.errors.GraphError` subclasses with precise,
5
+ actionable messages.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import Dict, Iterator, List, Tuple
11
+
12
+ from pytaskwirecheck.diagnostics.errors import CycleError, GraphError
13
+ from pytaskwirecheck.graph.model import TaskGraph
14
+
15
+ _WHITE = 0
16
+ _GRAY = 1
17
+ _BLACK = 2
18
+
19
+
20
+ def find_missing_dependencies(graph: TaskGraph) -> Dict[str, Tuple[str, ...]]:
21
+ """Return a mapping of task id -> missing dependency ids."""
22
+ known = set(graph.nodes.keys())
23
+ missing: Dict[str, List[str]] = {}
24
+ for node in graph:
25
+ bad = [dep for dep in node.depends_on if dep not in known]
26
+ if bad:
27
+ missing[node.id] = bad
28
+ return {k: tuple(v) for k, v in missing.items()}
29
+
30
+
31
+ def validate_references(graph: TaskGraph) -> None:
32
+ """Raise if any task references a dependency that does not exist."""
33
+ missing = find_missing_dependencies(graph)
34
+ if missing:
35
+ details = "; ".join(
36
+ "%s -> %s" % (task, ", ".join(deps))
37
+ for task, deps in sorted(missing.items())
38
+ )
39
+ first_task = sorted(missing.keys())[0]
40
+ raise GraphError("unknown task dependencies: %s" % details, task=first_task)
41
+
42
+
43
+ def find_cycle(graph: TaskGraph) -> List[str]:
44
+ """Return one dependency cycle as an ordered path, or ``[]`` if acyclic.
45
+
46
+ The returned path repeats the first node at the end, e.g.
47
+ ``["a", "b", "c", "a"]``.
48
+ """
49
+ known = set(graph.nodes.keys())
50
+ color: Dict[str, int] = {task_id: _WHITE for task_id in known}
51
+
52
+ def deps_iter(task_id: str) -> Iterator[str]:
53
+ return iter(d for d in graph.dependencies(task_id) if d in known)
54
+
55
+ for start in known:
56
+ if color[start] != _WHITE:
57
+ continue
58
+ color[start] = _GRAY
59
+ path: List[str] = [start]
60
+ stack: List[Tuple[str, Iterator[str]]] = [(start, deps_iter(start))]
61
+ while stack:
62
+ node, iterator = stack[-1]
63
+ advanced = False
64
+ for dep in iterator:
65
+ if color[dep] == _GRAY:
66
+ index = path.index(dep)
67
+ return path[index:] + [dep]
68
+ if color[dep] == _WHITE:
69
+ color[dep] = _GRAY
70
+ path.append(dep)
71
+ stack.append((dep, deps_iter(dep)))
72
+ advanced = True
73
+ break
74
+ if not advanced:
75
+ color[node] = _BLACK
76
+ stack.pop()
77
+ if path and path[-1] == node:
78
+ path.pop()
79
+ return []
80
+
81
+
82
+ def validate_acyclic(graph: TaskGraph) -> None:
83
+ """Raise :class:`CycleError` if the graph contains a cycle."""
84
+ cycle = find_cycle(graph)
85
+ if cycle:
86
+ raise CycleError(cycle)
87
+
88
+
89
+ def validate_graph(graph: TaskGraph) -> None:
90
+ """Run all structural validations against a graph."""
91
+ validate_references(graph)
92
+ validate_acyclic(graph)
File without changes
@@ -0,0 +1,164 @@
1
+ """Local execution history backed by SQLite.
2
+
3
+ The history is a convenience, never a dependency: a write failure is surfaced
4
+ to the caller but must never change a run's outcome. The database lives under
5
+ ``<root>/.pytaskwirecheck/history.db`` and uses WAL mode for safe concurrent readers.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import sqlite3
12
+ import threading
13
+ import time
14
+ from pathlib import Path
15
+ from typing import List, Optional
16
+
17
+ from pytaskwirecheck.config.loader import state_dir
18
+ from pytaskwirecheck.diagnostics.errors import HistoryError
19
+ from pytaskwirecheck.engine.scheduler import RunResult
20
+
21
+ SCHEMA_VERSION = 1
22
+
23
+
24
+ class HistoryStore:
25
+ """A minimal, local execution-history store."""
26
+
27
+ def __init__(self, root: Path, *, max_runs: int = 500) -> None:
28
+ self._root = root.resolve()
29
+ self._path = state_dir(self._root) / "history.db"
30
+ self._max_runs = max_runs
31
+ self._lock = threading.Lock()
32
+ self._conn: Optional[sqlite3.Connection] = None
33
+
34
+ def _connect(self) -> sqlite3.Connection:
35
+ if self._conn is not None:
36
+ return self._conn
37
+ try:
38
+ self._path.parent.mkdir(parents=True, exist_ok=True)
39
+ conn = sqlite3.connect(str(self._path), timeout=10.0)
40
+ conn.execute("PRAGMA journal_mode=WAL")
41
+ conn.execute("PRAGMA synchronous=NORMAL")
42
+ self._create_schema(conn)
43
+ self._conn = conn
44
+ except sqlite3.Error as exc:
45
+ raise HistoryError("cannot open history database: %s" % exc) from exc
46
+ return conn
47
+
48
+ def _create_schema(self, conn: sqlite3.Connection) -> None:
49
+ conn.execute(
50
+ """
51
+ CREATE TABLE IF NOT EXISTS meta (
52
+ key TEXT PRIMARY KEY,
53
+ value TEXT NOT NULL
54
+ )
55
+ """
56
+ )
57
+ conn.execute(
58
+ """
59
+ CREATE TABLE IF NOT EXISTS runs (
60
+ run_id TEXT PRIMARY KEY,
61
+ project TEXT,
62
+ config_version INTEGER,
63
+ started_at REAL,
64
+ ended_at REAL,
65
+ duration_seconds REAL,
66
+ succeeded INTEGER,
67
+ cancelled INTEGER,
68
+ summary_json TEXT
69
+ )
70
+ """
71
+ )
72
+ conn.execute(
73
+ "INSERT OR IGNORE INTO meta(key, value) VALUES('schema_version', ?)",
74
+ (str(SCHEMA_VERSION),),
75
+ )
76
+ conn.commit()
77
+
78
+ def record_run(
79
+ self,
80
+ result: RunResult,
81
+ *,
82
+ project: str,
83
+ config_version: int,
84
+ ) -> None:
85
+ """Persist a run summary. Raises :class:`HistoryError` on failure."""
86
+ summary = result.summary()
87
+ ended_at = time.time()
88
+ started_at = ended_at - result.duration_seconds
89
+ with self._lock:
90
+ try:
91
+ conn = self._connect()
92
+ conn.execute(
93
+ """
94
+ INSERT OR REPLACE INTO runs
95
+ (run_id, project, config_version, started_at, ended_at,
96
+ duration_seconds, succeeded, cancelled, summary_json)
97
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
98
+ """,
99
+ (
100
+ result.run_id,
101
+ project,
102
+ config_version,
103
+ started_at,
104
+ ended_at,
105
+ result.duration_seconds,
106
+ 1 if result.succeeded else 0,
107
+ 1 if result.cancelled else 0,
108
+ json.dumps(summary, separators=(",", ":")),
109
+ ),
110
+ )
111
+ conn.commit()
112
+ self._prune(conn)
113
+ except sqlite3.Error as exc:
114
+ raise HistoryError("cannot record run: %s" % exc) from exc
115
+
116
+ def _prune(self, conn: sqlite3.Connection) -> None:
117
+ conn.execute(
118
+ """
119
+ DELETE FROM runs WHERE run_id NOT IN (
120
+ SELECT run_id FROM runs ORDER BY ended_at DESC LIMIT ?
121
+ )
122
+ """,
123
+ (self._max_runs,),
124
+ )
125
+ conn.commit()
126
+
127
+ def recent(self, limit: int = 20) -> List[dict]:
128
+ with self._lock:
129
+ try:
130
+ conn = self._connect()
131
+ rows = conn.execute(
132
+ """
133
+ SELECT run_id, project, started_at, ended_at,
134
+ duration_seconds, succeeded, cancelled, summary_json
135
+ FROM runs ORDER BY ended_at DESC LIMIT ?
136
+ """,
137
+ (limit,),
138
+ ).fetchall()
139
+ except sqlite3.Error as exc:
140
+ raise HistoryError("cannot read history: %s" % exc) from exc
141
+ results = []
142
+ for row in rows:
143
+ results.append(
144
+ {
145
+ "run_id": row[0],
146
+ "project": row[1],
147
+ "started_at": row[2],
148
+ "ended_at": row[3],
149
+ "duration_seconds": row[4],
150
+ "succeeded": bool(row[5]),
151
+ "cancelled": bool(row[6]),
152
+ "summary": json.loads(row[7]) if row[7] else {},
153
+ }
154
+ )
155
+ return results
156
+
157
+ def close(self) -> None:
158
+ with self._lock:
159
+ if self._conn is not None:
160
+ try:
161
+ self._conn.close()
162
+ except sqlite3.Error:
163
+ pass
164
+ self._conn = None
File without changes
@@ -0,0 +1,95 @@
1
+ """Streaming task output artifacts.
2
+
3
+ Output is appended to per-task files under the state directory as it is
4
+ produced. Writes are bounded so a runaway process cannot exhaust disk during a
5
+ run; truncation is recorded in the log.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import threading
11
+ from dataclasses import dataclass
12
+ from pathlib import Path
13
+ from typing import BinaryIO, Dict, Optional
14
+
15
+ from pytaskwirecheck.config.loader import state_dir
16
+ from pytaskwirecheck.engine.events import Event, TaskOutput
17
+
18
+
19
+ @dataclass
20
+ class _TaskLog:
21
+ handle: Optional[BinaryIO] = None
22
+ path: Optional[Path] = None
23
+ written: int = 0
24
+ truncated: bool = False
25
+
26
+
27
+ class ArtifactWriter:
28
+ """Persists streamed task output to files, bounded per task."""
29
+
30
+ def __init__(
31
+ self,
32
+ root: Path,
33
+ run_id: str,
34
+ *,
35
+ max_bytes_per_task: Optional[int] = 64 * 1024 * 1024,
36
+ ) -> None:
37
+ self._base = state_dir(root) / "logs" / run_id
38
+ self._max_bytes = max_bytes_per_task
39
+ self._lock = threading.Lock()
40
+ self._logs: Dict[str, _TaskLog] = {}
41
+
42
+ def _open(self, task_id: str) -> _TaskLog:
43
+ log = self._logs.get(task_id)
44
+ if log is not None:
45
+ return log
46
+ log = _TaskLog()
47
+ try:
48
+ self._base.mkdir(parents=True, exist_ok=True)
49
+ path = self._base / ("%s.log" % task_id)
50
+ log.handle = open(path, "ab")
51
+ log.path = path
52
+ except OSError:
53
+ log.handle = None
54
+ self._logs[task_id] = log
55
+ return log
56
+
57
+ def path_for(self, task_id: str) -> Optional[Path]:
58
+ log = self._logs.get(task_id)
59
+ return log.path if log else None
60
+
61
+ def handle(self, event: Event) -> None:
62
+ if not isinstance(event, TaskOutput):
63
+ return
64
+ with self._lock:
65
+ log = self._open(event.task_id)
66
+ if log.handle is None or log.truncated:
67
+ return
68
+ data = event.data
69
+ if self._max_bytes is not None:
70
+ remaining = self._max_bytes - log.written
71
+ if remaining <= 0:
72
+ log.truncated = True
73
+ try:
74
+ log.handle.write(b"\n[pytaskwirecheck] output truncated\n")
75
+ except OSError:
76
+ pass
77
+ return
78
+ if len(data) > remaining:
79
+ data = data[:remaining]
80
+ log.truncated = True
81
+ try:
82
+ log.handle.write(data)
83
+ log.written += len(data)
84
+ except OSError:
85
+ pass
86
+
87
+ def close(self) -> None:
88
+ with self._lock:
89
+ for log in self._logs.values():
90
+ if log.handle is not None:
91
+ try:
92
+ log.handle.close()
93
+ except OSError:
94
+ pass
95
+ log.handle = None
@@ -0,0 +1,83 @@
1
+ """Newline-delimited JSON event logging for a run.
2
+
3
+ This produces a machine-readable trace alongside human output. Task output
4
+ events are summarised (never inlined) to avoid unbounded log growth.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import dataclasses
10
+ import json
11
+ import threading
12
+ from pathlib import Path
13
+ from typing import Any, Optional
14
+
15
+ from pytaskwirecheck.config.loader import state_dir
16
+ from pytaskwirecheck.engine.events import Event, TaskOutput
17
+
18
+
19
+ def _encode(value: Any) -> Any:
20
+ if isinstance(value, bytes):
21
+ return value.decode("utf-8", errors="replace")
22
+ if hasattr(value, "value"): # Enum
23
+ return value.value
24
+ if dataclasses.is_dataclass(value) and not isinstance(value, type):
25
+ return {
26
+ f.name: _encode(getattr(value, f.name)) for f in dataclasses.fields(value)
27
+ }
28
+ return value
29
+
30
+
31
+ def event_to_dict(event: Event) -> dict:
32
+ payload = _encode(event)
33
+ payload["event"] = type(event).__name__
34
+ return payload
35
+
36
+
37
+ class StructuredLogger:
38
+ """Writes JSONL events to ``.pytaskwirecheck/runs/<run_id>.jsonl``."""
39
+
40
+ def __init__(self, root: Path, run_id: str) -> None:
41
+ self._path: Optional[Path] = None
42
+ self._handle = None
43
+ self._lock = threading.Lock()
44
+ try:
45
+ base = state_dir(root) / "runs"
46
+ base.mkdir(parents=True, exist_ok=True)
47
+ self._path = base / ("%s.jsonl" % run_id)
48
+ self._handle = open(self._path, "a", encoding="utf-8")
49
+ except OSError:
50
+ self._handle = None
51
+
52
+ @property
53
+ def path(self) -> Optional[Path]:
54
+ return self._path
55
+
56
+ def handle(self, event: Event) -> None:
57
+ if self._handle is None:
58
+ return
59
+ if isinstance(event, TaskOutput):
60
+ payload = {
61
+ "event": "TaskOutput",
62
+ "task_id": event.task_id,
63
+ "stream": event.stream,
64
+ "bytes": len(event.data),
65
+ }
66
+ else:
67
+ payload = event_to_dict(event)
68
+ line = json.dumps(payload, separators=(",", ":"), default=str)
69
+ with self._lock:
70
+ try:
71
+ self._handle.write(line + "\n")
72
+ self._handle.flush()
73
+ except OSError:
74
+ pass
75
+
76
+ def close(self) -> None:
77
+ with self._lock:
78
+ if self._handle is not None:
79
+ try:
80
+ self._handle.close()
81
+ except OSError:
82
+ pass
83
+ self._handle = None
File without changes
@@ -0,0 +1,42 @@
1
+ """Notification interfaces.
2
+
3
+ Notifications are best-effort observability. A notification failure must never
4
+ change a run's outcome, and payloads must never include full environments,
5
+ command output, or file contents.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from dataclasses import dataclass, field
11
+ from typing import Dict, List, Optional, Protocol
12
+
13
+
14
+ @dataclass(frozen=True)
15
+ class RunNotification:
16
+ """A sanitised, minimal description of a completed run."""
17
+
18
+ event: str
19
+ run_id: str
20
+ project: str
21
+ succeeded: bool
22
+ cancelled: bool
23
+ duration_seconds: float
24
+ counts: Dict[str, int] = field(default_factory=dict)
25
+ failed_tasks: List[Dict[str, Optional[object]]] = field(default_factory=list)
26
+
27
+ def to_payload(self) -> dict:
28
+ return {
29
+ "event": self.event,
30
+ "run_id": self.run_id,
31
+ "project": self.project,
32
+ "succeeded": self.succeeded,
33
+ "cancelled": self.cancelled,
34
+ "duration_seconds": round(self.duration_seconds, 4),
35
+ "counts": self.counts,
36
+ "failed_tasks": self.failed_tasks,
37
+ }
38
+
39
+
40
+ class Notifier(Protocol):
41
+ def send(self, notification: RunNotification) -> bool:
42
+ """Deliver a notification; return True on success."""