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.
- pytaskwirecheck/__init__.py +32 -0
- pytaskwirecheck/api.py +314 -0
- pytaskwirecheck/cache/__init__.py +0 -0
- pytaskwirecheck/cache/fingerprint.py +176 -0
- pytaskwirecheck/cache/policy.py +49 -0
- pytaskwirecheck/cache/store.py +367 -0
- pytaskwirecheck/cli.py +690 -0
- pytaskwirecheck/config/__init__.py +0 -0
- pytaskwirecheck/config/loader.py +72 -0
- pytaskwirecheck/config/schema.py +170 -0
- pytaskwirecheck/config/validation.py +590 -0
- pytaskwirecheck/diagnostics/__init__.py +31 -0
- pytaskwirecheck/diagnostics/errors.py +86 -0
- pytaskwirecheck/engine/__init__.py +0 -0
- pytaskwirecheck/engine/cancellation.py +38 -0
- pytaskwirecheck/engine/events.py +93 -0
- pytaskwirecheck/engine/executor.py +301 -0
- pytaskwirecheck/engine/scheduler.py +312 -0
- pytaskwirecheck/engine/state.py +123 -0
- pytaskwirecheck/graph/__init__.py +0 -0
- pytaskwirecheck/graph/builder.py +33 -0
- pytaskwirecheck/graph/model.py +104 -0
- pytaskwirecheck/graph/planner.py +155 -0
- pytaskwirecheck/graph/validator.py +92 -0
- pytaskwirecheck/history/__init__.py +0 -0
- pytaskwirecheck/history/store.py +164 -0
- pytaskwirecheck/logging/__init__.py +0 -0
- pytaskwirecheck/logging/artifacts.py +95 -0
- pytaskwirecheck/logging/structured.py +83 -0
- pytaskwirecheck/notifications/__init__.py +0 -0
- pytaskwirecheck/notifications/base.py +42 -0
- pytaskwirecheck/notifications/webhook.py +71 -0
- pytaskwirecheck/processes/__init__.py +0 -0
- pytaskwirecheck/processes/runner.py +265 -0
- pytaskwirecheck/processes/signals.py +65 -0
- pytaskwirecheck/py.typed +0 -0
- pytaskwirecheck/tui/__init__.py +0 -0
- pytaskwirecheck/tui/app.py +204 -0
- pytaskwirecheck/tui/renderer.py +143 -0
- pytaskwirecheck-0.1.0.dist-info/METADATA +316 -0
- pytaskwirecheck-0.1.0.dist-info/RECORD +45 -0
- pytaskwirecheck-0.1.0.dist-info/WHEEL +5 -0
- pytaskwirecheck-0.1.0.dist-info/entry_points.txt +2 -0
- pytaskwirecheck-0.1.0.dist-info/licenses/LICENSE +21 -0
- 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."""
|