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,38 @@
|
|
|
1
|
+
"""Cancellation signalling shared between the scheduler and process runner."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import threading
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class CancellationToken:
|
|
9
|
+
"""A one-shot cancellation signal with an associated reason.
|
|
10
|
+
|
|
11
|
+
The token is safe to share across threads. Once cancelled it stays
|
|
12
|
+
cancelled; retries observe the token and suppress further attempts.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
def __init__(self) -> None:
|
|
16
|
+
self._event = threading.Event()
|
|
17
|
+
self._lock = threading.Lock()
|
|
18
|
+
self._reason = ""
|
|
19
|
+
|
|
20
|
+
def cancel(self, reason: str = "") -> None:
|
|
21
|
+
with self._lock:
|
|
22
|
+
if not self._reason and reason:
|
|
23
|
+
self._reason = reason
|
|
24
|
+
self._event.set()
|
|
25
|
+
|
|
26
|
+
@property
|
|
27
|
+
def is_cancelled(self) -> bool:
|
|
28
|
+
return self._event.is_set()
|
|
29
|
+
|
|
30
|
+
@property
|
|
31
|
+
def reason(self) -> str:
|
|
32
|
+
with self._lock:
|
|
33
|
+
return self._reason
|
|
34
|
+
|
|
35
|
+
@property
|
|
36
|
+
def event(self) -> threading.Event:
|
|
37
|
+
"""The underlying event, suitable for passing to the process runner."""
|
|
38
|
+
return self._event
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"""Execution events and a minimal synchronous event bus.
|
|
2
|
+
|
|
3
|
+
Events decouple the engine from presentation. Listeners must be fast and must
|
|
4
|
+
not raise; the bus isolates listener exceptions so one bad subscriber cannot
|
|
5
|
+
corrupt the scheduler.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import threading
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
from typing import Callable, List, Optional
|
|
13
|
+
|
|
14
|
+
from pytaskwirecheck.engine.state import TaskStatus
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass
|
|
18
|
+
class Event:
|
|
19
|
+
"""Base class for all engine events."""
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass
|
|
23
|
+
class RunStarted(Event):
|
|
24
|
+
run_id: str
|
|
25
|
+
tasks: tuple
|
|
26
|
+
total_tasks: int
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass
|
|
30
|
+
class TaskStateChanged(Event):
|
|
31
|
+
task_id: str
|
|
32
|
+
status: TaskStatus
|
|
33
|
+
message: str = ""
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass
|
|
37
|
+
class TaskAttemptStarted(Event):
|
|
38
|
+
task_id: str
|
|
39
|
+
attempt: int
|
|
40
|
+
max_attempts: int
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass
|
|
44
|
+
class TaskAttemptFinished(Event):
|
|
45
|
+
task_id: str
|
|
46
|
+
attempt: int
|
|
47
|
+
status: TaskStatus
|
|
48
|
+
exit_code: Optional[int]
|
|
49
|
+
duration_seconds: float
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclass
|
|
53
|
+
class TaskOutput(Event):
|
|
54
|
+
task_id: str
|
|
55
|
+
stream: str
|
|
56
|
+
data: bytes
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@dataclass
|
|
60
|
+
class RunFinished(Event):
|
|
61
|
+
run_id: str
|
|
62
|
+
succeeded: bool
|
|
63
|
+
summary: dict = field(default_factory=dict)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
Listener = Callable[[Event], None]
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class EventBus:
|
|
70
|
+
"""A thread-safe, synchronous publish/subscribe bus."""
|
|
71
|
+
|
|
72
|
+
def __init__(self) -> None:
|
|
73
|
+
self._listeners: List[Listener] = []
|
|
74
|
+
self._lock = threading.Lock()
|
|
75
|
+
|
|
76
|
+
def subscribe(self, listener: Listener) -> None:
|
|
77
|
+
with self._lock:
|
|
78
|
+
self._listeners.append(listener)
|
|
79
|
+
|
|
80
|
+
def unsubscribe(self, listener: Listener) -> None:
|
|
81
|
+
with self._lock:
|
|
82
|
+
if listener in self._listeners:
|
|
83
|
+
self._listeners.remove(listener)
|
|
84
|
+
|
|
85
|
+
def emit(self, event: Event) -> None:
|
|
86
|
+
with self._lock:
|
|
87
|
+
listeners = list(self._listeners)
|
|
88
|
+
for listener in listeners:
|
|
89
|
+
try:
|
|
90
|
+
listener(event)
|
|
91
|
+
except Exception:
|
|
92
|
+
# Presentation must never break execution.
|
|
93
|
+
continue
|
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
"""Per-task execution: environment, timeout, retries, and cache integration.
|
|
2
|
+
|
|
3
|
+
The executor runs exactly one task to a terminal state. It is driven by the
|
|
4
|
+
scheduler one call per task; it does not know about other tasks except through
|
|
5
|
+
the dependency cache keys passed in by the scheduler.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
import time
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Mapping, Optional, Protocol
|
|
15
|
+
|
|
16
|
+
from pytaskwirecheck.config.schema import TaskConfig
|
|
17
|
+
from pytaskwirecheck.diagnostics.errors import ProcessStartError
|
|
18
|
+
from pytaskwirecheck.engine.cancellation import CancellationToken
|
|
19
|
+
from pytaskwirecheck.engine.events import (
|
|
20
|
+
EventBus,
|
|
21
|
+
TaskAttemptFinished,
|
|
22
|
+
TaskAttemptStarted,
|
|
23
|
+
TaskOutput,
|
|
24
|
+
)
|
|
25
|
+
from pytaskwirecheck.engine.state import TaskStatus
|
|
26
|
+
from pytaskwirecheck.processes.runner import CommandSpec, ProcessResult, run_command
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass
|
|
30
|
+
class TaskOutcome:
|
|
31
|
+
"""The terminal outcome of executing a single task."""
|
|
32
|
+
|
|
33
|
+
task_id: str
|
|
34
|
+
status: TaskStatus
|
|
35
|
+
exit_code: Optional[int] = None
|
|
36
|
+
attempts: int = 0
|
|
37
|
+
duration_seconds: float = 0.0
|
|
38
|
+
message: str = ""
|
|
39
|
+
cache_hit: bool = False
|
|
40
|
+
cache_key: Optional[str] = None
|
|
41
|
+
output_bytes: int = 0
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class CacheBackend(Protocol):
|
|
45
|
+
"""Structural interface for the content-addressed cache (Phase 3)."""
|
|
46
|
+
|
|
47
|
+
def try_restore(
|
|
48
|
+
self, task: TaskConfig, dependency_keys: Mapping[str, Optional[str]]
|
|
49
|
+
) -> Optional[str]:
|
|
50
|
+
"""Return a cache key if a valid entry was restored, else ``None``."""
|
|
51
|
+
|
|
52
|
+
def store(
|
|
53
|
+
self, task: TaskConfig, dependency_keys: Mapping[str, Optional[str]]
|
|
54
|
+
) -> Optional[str]:
|
|
55
|
+
"""Commit a successful result; return the cache key (or ``None``)."""
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def build_environment(
|
|
59
|
+
task: TaskConfig, base_env: Mapping[str, str]
|
|
60
|
+
) -> Mapping[str, str]:
|
|
61
|
+
"""Compose the subprocess environment for ``task``.
|
|
62
|
+
|
|
63
|
+
Secret values are never logged; only the set of overridden names is ever
|
|
64
|
+
exposed to presentation.
|
|
65
|
+
"""
|
|
66
|
+
env = dict(base_env) if task.inherit_env else {}
|
|
67
|
+
env.update(task.env)
|
|
68
|
+
return env
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def resolve_cwd(root: Path, task: TaskConfig) -> Path:
|
|
72
|
+
if task.cwd:
|
|
73
|
+
return (root / task.cwd).resolve()
|
|
74
|
+
return root
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class TaskExecutor:
|
|
78
|
+
"""Runs a single task with retries and optional cache integration."""
|
|
79
|
+
|
|
80
|
+
def __init__(
|
|
81
|
+
self,
|
|
82
|
+
*,
|
|
83
|
+
root: Path,
|
|
84
|
+
default_timeout: Optional[float],
|
|
85
|
+
events: EventBus,
|
|
86
|
+
cancel_token: CancellationToken,
|
|
87
|
+
base_env: Optional[Mapping[str, str]] = None,
|
|
88
|
+
cache: Optional[CacheBackend] = None,
|
|
89
|
+
grace_seconds: float = 5.0,
|
|
90
|
+
) -> None:
|
|
91
|
+
self._root = root
|
|
92
|
+
self._default_timeout = default_timeout
|
|
93
|
+
self._events = events
|
|
94
|
+
self._cancel = cancel_token
|
|
95
|
+
self._base_env = base_env if base_env is not None else os.environ
|
|
96
|
+
self._cache = cache
|
|
97
|
+
self._grace = grace_seconds
|
|
98
|
+
|
|
99
|
+
def effective_timeout(self, task: TaskConfig) -> Optional[float]:
|
|
100
|
+
if task.timeout_seconds is not None:
|
|
101
|
+
return task.timeout_seconds
|
|
102
|
+
return self._default_timeout
|
|
103
|
+
|
|
104
|
+
def _interruptible_sleep(self, seconds: float) -> bool:
|
|
105
|
+
"""Sleep up to ``seconds``; return ``False`` if cancellation occurred."""
|
|
106
|
+
remaining = seconds
|
|
107
|
+
step = 0.1
|
|
108
|
+
while remaining > 0:
|
|
109
|
+
if self._cancel.is_cancelled:
|
|
110
|
+
return False
|
|
111
|
+
time.sleep(min(step, remaining))
|
|
112
|
+
remaining -= step
|
|
113
|
+
return not self._cancel.is_cancelled
|
|
114
|
+
|
|
115
|
+
def execute(
|
|
116
|
+
self,
|
|
117
|
+
task: TaskConfig,
|
|
118
|
+
dependency_keys: Optional[Mapping[str, Optional[str]]] = None,
|
|
119
|
+
*,
|
|
120
|
+
use_cache: bool = True,
|
|
121
|
+
) -> TaskOutcome:
|
|
122
|
+
"""Execute ``task`` and return its terminal :class:`TaskOutcome`."""
|
|
123
|
+
dependency_keys = dependency_keys or {}
|
|
124
|
+
|
|
125
|
+
if self._cancel.is_cancelled:
|
|
126
|
+
return TaskOutcome(
|
|
127
|
+
task_id=task.id,
|
|
128
|
+
status=TaskStatus.CANCELLED,
|
|
129
|
+
message=self._cancel.reason or "cancelled before start",
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
if use_cache and self._cache is not None and task.cache_eligible:
|
|
133
|
+
key = self._cache.try_restore(task, dependency_keys)
|
|
134
|
+
if key is not None:
|
|
135
|
+
return TaskOutcome(
|
|
136
|
+
task_id=task.id,
|
|
137
|
+
status=TaskStatus.CACHED,
|
|
138
|
+
cache_hit=True,
|
|
139
|
+
cache_key=key,
|
|
140
|
+
message="restored from cache",
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
spec = CommandSpec(
|
|
144
|
+
argv=task.command,
|
|
145
|
+
cwd=resolve_cwd(self._root, task),
|
|
146
|
+
env=build_environment(task, self._base_env),
|
|
147
|
+
shell=task.shell,
|
|
148
|
+
timeout_seconds=self.effective_timeout(task),
|
|
149
|
+
max_output_bytes=task.resources.max_output_bytes,
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
max_attempts = max(1, task.retry.max_attempts)
|
|
153
|
+
attempt = 0
|
|
154
|
+
total_duration = 0.0
|
|
155
|
+
last_exit: Optional[int] = None
|
|
156
|
+
|
|
157
|
+
while attempt < max_attempts:
|
|
158
|
+
if self._cancel.is_cancelled:
|
|
159
|
+
return TaskOutcome(
|
|
160
|
+
task_id=task.id,
|
|
161
|
+
status=TaskStatus.CANCELLED,
|
|
162
|
+
attempts=attempt,
|
|
163
|
+
duration_seconds=total_duration,
|
|
164
|
+
exit_code=last_exit,
|
|
165
|
+
message=self._cancel.reason or "cancelled",
|
|
166
|
+
)
|
|
167
|
+
attempt += 1
|
|
168
|
+
self._events.emit(
|
|
169
|
+
TaskAttemptStarted(
|
|
170
|
+
task_id=task.id, attempt=attempt, max_attempts=max_attempts
|
|
171
|
+
)
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
def _on_output(stream: str, chunk: bytes, _id: str = task.id) -> None:
|
|
175
|
+
self._events.emit(TaskOutput(task_id=_id, stream=stream, data=chunk))
|
|
176
|
+
|
|
177
|
+
started = time.monotonic()
|
|
178
|
+
try:
|
|
179
|
+
result = run_command(
|
|
180
|
+
spec,
|
|
181
|
+
on_output=_on_output,
|
|
182
|
+
cancel_event=self._cancel.event,
|
|
183
|
+
grace_seconds=self._grace,
|
|
184
|
+
)
|
|
185
|
+
except ProcessStartError as exc:
|
|
186
|
+
total_duration += time.monotonic() - started
|
|
187
|
+
self._events.emit(
|
|
188
|
+
TaskAttemptFinished(
|
|
189
|
+
task_id=task.id,
|
|
190
|
+
attempt=attempt,
|
|
191
|
+
status=TaskStatus.FAILED,
|
|
192
|
+
exit_code=None,
|
|
193
|
+
duration_seconds=total_duration,
|
|
194
|
+
)
|
|
195
|
+
)
|
|
196
|
+
return TaskOutcome(
|
|
197
|
+
task_id=task.id,
|
|
198
|
+
status=TaskStatus.FAILED,
|
|
199
|
+
attempts=attempt,
|
|
200
|
+
duration_seconds=total_duration,
|
|
201
|
+
message=str(exc),
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
duration = result.duration_seconds
|
|
205
|
+
total_duration += duration
|
|
206
|
+
last_exit = result.exit_code
|
|
207
|
+
status = self._classify(result)
|
|
208
|
+
self._events.emit(
|
|
209
|
+
TaskAttemptFinished(
|
|
210
|
+
task_id=task.id,
|
|
211
|
+
attempt=attempt,
|
|
212
|
+
status=status,
|
|
213
|
+
exit_code=result.exit_code,
|
|
214
|
+
duration_seconds=duration,
|
|
215
|
+
)
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
if status is TaskStatus.SUCCEEDED:
|
|
219
|
+
cache_key = None
|
|
220
|
+
if use_cache and self._cache is not None and task.cache_eligible:
|
|
221
|
+
cache_key = self._cache.store(task, dependency_keys)
|
|
222
|
+
return TaskOutcome(
|
|
223
|
+
task_id=task.id,
|
|
224
|
+
status=TaskStatus.SUCCEEDED,
|
|
225
|
+
exit_code=result.exit_code,
|
|
226
|
+
attempts=attempt,
|
|
227
|
+
duration_seconds=total_duration,
|
|
228
|
+
cache_key=cache_key,
|
|
229
|
+
output_bytes=result.output_bytes,
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
if status is TaskStatus.CANCELLED:
|
|
233
|
+
return TaskOutcome(
|
|
234
|
+
task_id=task.id,
|
|
235
|
+
status=TaskStatus.CANCELLED,
|
|
236
|
+
exit_code=result.exit_code,
|
|
237
|
+
attempts=attempt,
|
|
238
|
+
duration_seconds=total_duration,
|
|
239
|
+
message=self._cancel.reason or "cancelled",
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
# Decide whether to retry.
|
|
243
|
+
retryable = self._is_retryable(task, result, status)
|
|
244
|
+
if not retryable or attempt >= max_attempts:
|
|
245
|
+
return TaskOutcome(
|
|
246
|
+
task_id=task.id,
|
|
247
|
+
status=status,
|
|
248
|
+
exit_code=result.exit_code,
|
|
249
|
+
attempts=attempt,
|
|
250
|
+
duration_seconds=total_duration,
|
|
251
|
+
output_bytes=result.output_bytes,
|
|
252
|
+
message=self._failure_message(result, status),
|
|
253
|
+
)
|
|
254
|
+
delay = task.retry.delay_for_attempt(attempt + 1)
|
|
255
|
+
if delay > 0 and not self._interruptible_sleep(delay):
|
|
256
|
+
return TaskOutcome(
|
|
257
|
+
task_id=task.id,
|
|
258
|
+
status=TaskStatus.CANCELLED,
|
|
259
|
+
attempts=attempt,
|
|
260
|
+
duration_seconds=total_duration,
|
|
261
|
+
message=self._cancel.reason or "cancelled during retry delay",
|
|
262
|
+
)
|
|
263
|
+
|
|
264
|
+
# Unreachable: loop always returns. Defensive fallback.
|
|
265
|
+
return TaskOutcome(
|
|
266
|
+
task_id=task.id,
|
|
267
|
+
status=TaskStatus.FAILED,
|
|
268
|
+
exit_code=last_exit,
|
|
269
|
+
attempts=attempt,
|
|
270
|
+
duration_seconds=total_duration,
|
|
271
|
+
message="exhausted attempts",
|
|
272
|
+
)
|
|
273
|
+
|
|
274
|
+
@staticmethod
|
|
275
|
+
def _classify(result: ProcessResult) -> TaskStatus:
|
|
276
|
+
if result.cancelled:
|
|
277
|
+
return TaskStatus.CANCELLED
|
|
278
|
+
if result.timed_out:
|
|
279
|
+
return TaskStatus.TIMED_OUT
|
|
280
|
+
if result.exit_code == 0:
|
|
281
|
+
return TaskStatus.SUCCEEDED
|
|
282
|
+
return TaskStatus.FAILED
|
|
283
|
+
|
|
284
|
+
def _is_retryable(
|
|
285
|
+
self, task: TaskConfig, result: ProcessResult, status: TaskStatus
|
|
286
|
+
) -> bool:
|
|
287
|
+
if not task.retry.enabled:
|
|
288
|
+
return False
|
|
289
|
+
if status is TaskStatus.TIMED_OUT:
|
|
290
|
+
return True
|
|
291
|
+
if status is TaskStatus.FAILED and result.exit_code is not None:
|
|
292
|
+
return task.retry.should_retry_exit_code(result.exit_code)
|
|
293
|
+
return False
|
|
294
|
+
|
|
295
|
+
@staticmethod
|
|
296
|
+
def _failure_message(result: ProcessResult, status: TaskStatus) -> str:
|
|
297
|
+
if status is TaskStatus.TIMED_OUT:
|
|
298
|
+
return "timed out"
|
|
299
|
+
if result.exit_code is not None:
|
|
300
|
+
return "exited with code %d" % result.exit_code
|
|
301
|
+
return "failed"
|