ttasks 0.2.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.
- ttasks/__init__.py +53 -0
- ttasks/_events.py +96 -0
- ttasks/_exceptions.py +45 -0
- ttasks/_executor.py +645 -0
- ttasks/_graph.py +409 -0
- ttasks/_sqlite.py +587 -0
- ttasks/_store.py +174 -0
- ttasks/_task.py +398 -0
- ttasks/_version.py +24 -0
- ttasks/py.typed +0 -0
- ttasks-0.2.0.dist-info/METADATA +511 -0
- ttasks-0.2.0.dist-info/RECORD +13 -0
- ttasks-0.2.0.dist-info/WHEEL +4 -0
ttasks/_store.py
ADDED
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
"""Unified task and graph storage.
|
|
2
|
+
|
|
3
|
+
A :class:`Store` exposes two collections, :attr:`tasks` and :attr:`graphs`,
|
|
4
|
+
each a ``MutableMapping[str, T]`` keyed by the object's own immutable ID.
|
|
5
|
+
The in-memory implementation lives here; SQLite-backed storage lives in
|
|
6
|
+
``ttasks._sqlite`` and is re-exported as :class:`ttasks.SQLiteStore`.
|
|
7
|
+
|
|
8
|
+
The store is the single seam between the runtime objects (``Task``,
|
|
9
|
+
``TaskGraph``) and any durable backend. ``TaskExecutor`` writes to
|
|
10
|
+
``store.tasks`` automatically on every lifecycle transition, so callers
|
|
11
|
+
do not have to wire event-bus subscribers for normal persistence.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from collections.abc import Iterator, MutableMapping
|
|
17
|
+
from typing import Protocol, runtime_checkable
|
|
18
|
+
|
|
19
|
+
from ._graph import TaskGraph
|
|
20
|
+
from ._task import Task
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class TaskCollection(Protocol):
|
|
24
|
+
"""Mapping of task ID to :class:`Task` with a ``save(task)`` shortcut.
|
|
25
|
+
|
|
26
|
+
Implementations are real :class:`collections.abc.MutableMapping` subclasses;
|
|
27
|
+
this Protocol describes the structural surface callers should rely on.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
def save(self, task: Task) -> None:
|
|
31
|
+
"""Persist ``task`` under its own ID."""
|
|
32
|
+
|
|
33
|
+
def __getitem__(self, task_id: str) -> Task: ...
|
|
34
|
+
def __setitem__(self, task_id: str, task: Task) -> None: ...
|
|
35
|
+
def __delitem__(self, task_id: str) -> None: ...
|
|
36
|
+
def __iter__(self) -> Iterator[str]: ...
|
|
37
|
+
def __len__(self) -> int: ...
|
|
38
|
+
def __contains__(self, key: object) -> bool: ...
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class GraphCollection(Protocol):
|
|
42
|
+
"""Mapping of graph ID to :class:`TaskGraph` with a ``save(graph)`` shortcut."""
|
|
43
|
+
|
|
44
|
+
def save(self, graph: TaskGraph) -> None:
|
|
45
|
+
"""Persist ``graph`` under its own ID."""
|
|
46
|
+
|
|
47
|
+
def __getitem__(self, graph_id: str) -> TaskGraph: ...
|
|
48
|
+
def __setitem__(self, graph_id: str, graph: TaskGraph) -> None: ...
|
|
49
|
+
def __delitem__(self, graph_id: str) -> None: ...
|
|
50
|
+
def __iter__(self) -> Iterator[str]: ...
|
|
51
|
+
def __len__(self) -> int: ...
|
|
52
|
+
def __contains__(self, key: object) -> bool: ...
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@runtime_checkable
|
|
56
|
+
class Store(Protocol):
|
|
57
|
+
"""A unified store exposing :attr:`tasks` and :attr:`graphs` collections."""
|
|
58
|
+
|
|
59
|
+
@property
|
|
60
|
+
def tasks(self) -> TaskCollection:
|
|
61
|
+
"""Return the task collection."""
|
|
62
|
+
|
|
63
|
+
@property
|
|
64
|
+
def graphs(self) -> GraphCollection:
|
|
65
|
+
"""Return the graph collection."""
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class InMemoryTaskCollection(MutableMapping[str, Task]):
|
|
69
|
+
"""Dict-backed :class:`TaskCollection` that holds live task references."""
|
|
70
|
+
|
|
71
|
+
def __init__(self) -> None:
|
|
72
|
+
"""Create an empty task collection."""
|
|
73
|
+
self._tasks: dict[str, Task] = {}
|
|
74
|
+
|
|
75
|
+
def save(self, task: Task) -> None:
|
|
76
|
+
"""Persist ``task`` under its own ID."""
|
|
77
|
+
self[task.id] = task
|
|
78
|
+
|
|
79
|
+
def __setitem__(self, task_id: str, task: Task) -> None:
|
|
80
|
+
"""Store ``task`` under its own ID."""
|
|
81
|
+
if not isinstance(task, Task):
|
|
82
|
+
raise TypeError(f"Expected Task, got {type(task).__name__}")
|
|
83
|
+
if task_id != task.id:
|
|
84
|
+
raise ValueError("task_id must match task.id")
|
|
85
|
+
self._tasks[task_id] = task
|
|
86
|
+
|
|
87
|
+
def __getitem__(self, task_id: str) -> Task:
|
|
88
|
+
"""Return the task for ``task_id`` or raise ``KeyError``."""
|
|
89
|
+
return self._tasks[task_id]
|
|
90
|
+
|
|
91
|
+
def __delitem__(self, task_id: str) -> None:
|
|
92
|
+
"""Remove the task identified by ``task_id``."""
|
|
93
|
+
del self._tasks[task_id]
|
|
94
|
+
|
|
95
|
+
def __iter__(self) -> Iterator[str]:
|
|
96
|
+
"""Iterate over task IDs in insertion order."""
|
|
97
|
+
return iter(self._tasks)
|
|
98
|
+
|
|
99
|
+
def __len__(self) -> int:
|
|
100
|
+
"""Return the number of stored tasks."""
|
|
101
|
+
return len(self._tasks)
|
|
102
|
+
|
|
103
|
+
def __contains__(self, key: object) -> bool:
|
|
104
|
+
"""Return whether ``key`` (task or task id) is present."""
|
|
105
|
+
if isinstance(key, Task):
|
|
106
|
+
return key.id in self._tasks
|
|
107
|
+
return key in self._tasks
|
|
108
|
+
|
|
109
|
+
def __repr__(self) -> str:
|
|
110
|
+
"""Return a concise representation with the stored-task count."""
|
|
111
|
+
return f"InMemoryTaskCollection({len(self._tasks)} tasks)"
|
|
112
|
+
|
|
113
|
+
def cancel(self, task_id: str) -> None:
|
|
114
|
+
"""Cancel a task in place, keeping it in the collection."""
|
|
115
|
+
self._tasks[task_id].cancel()
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
class InMemoryGraphCollection(MutableMapping[str, "TaskGraph"]):
|
|
119
|
+
"""Dict-backed :class:`GraphCollection` that holds live graph references."""
|
|
120
|
+
|
|
121
|
+
def __init__(self) -> None:
|
|
122
|
+
"""Create an empty graph collection."""
|
|
123
|
+
self._graphs: dict[str, TaskGraph] = {}
|
|
124
|
+
|
|
125
|
+
def save(self, graph: TaskGraph) -> None:
|
|
126
|
+
"""Persist ``graph`` under its own ID."""
|
|
127
|
+
self[graph.id] = graph
|
|
128
|
+
|
|
129
|
+
def __setitem__(self, graph_id: str, graph: TaskGraph) -> None:
|
|
130
|
+
"""Store ``graph`` under its own ID."""
|
|
131
|
+
if not isinstance(graph, TaskGraph):
|
|
132
|
+
raise TypeError(f"Expected TaskGraph, got {type(graph).__name__}")
|
|
133
|
+
if graph_id != graph.id:
|
|
134
|
+
raise ValueError("graph_id must match graph.id")
|
|
135
|
+
self._graphs[graph_id] = graph
|
|
136
|
+
|
|
137
|
+
def __getitem__(self, graph_id: str) -> TaskGraph:
|
|
138
|
+
"""Return the graph for ``graph_id`` or raise ``KeyError``."""
|
|
139
|
+
return self._graphs[graph_id]
|
|
140
|
+
|
|
141
|
+
def __delitem__(self, graph_id: str) -> None:
|
|
142
|
+
"""Remove the graph identified by ``graph_id``."""
|
|
143
|
+
del self._graphs[graph_id]
|
|
144
|
+
|
|
145
|
+
def __iter__(self) -> Iterator[str]:
|
|
146
|
+
"""Iterate over graph IDs in insertion order."""
|
|
147
|
+
return iter(self._graphs)
|
|
148
|
+
|
|
149
|
+
def __len__(self) -> int:
|
|
150
|
+
"""Return the number of stored graphs."""
|
|
151
|
+
return len(self._graphs)
|
|
152
|
+
|
|
153
|
+
def __contains__(self, key: object) -> bool:
|
|
154
|
+
"""Return whether ``key`` (graph or graph id) is present."""
|
|
155
|
+
if isinstance(key, TaskGraph):
|
|
156
|
+
return key.id in self._graphs
|
|
157
|
+
return key in self._graphs
|
|
158
|
+
|
|
159
|
+
def __repr__(self) -> str:
|
|
160
|
+
"""Return a concise representation with the stored-graph count."""
|
|
161
|
+
return f"InMemoryGraphCollection({len(self._graphs)} graphs)"
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
class InMemoryStore:
|
|
165
|
+
"""Live-reference :class:`Store` backed by in-memory dictionaries."""
|
|
166
|
+
|
|
167
|
+
def __init__(self) -> None:
|
|
168
|
+
"""Create empty task and graph collections."""
|
|
169
|
+
self.tasks = InMemoryTaskCollection()
|
|
170
|
+
self.graphs = InMemoryGraphCollection()
|
|
171
|
+
|
|
172
|
+
def __repr__(self) -> str:
|
|
173
|
+
"""Return a concise representation with task and graph counts."""
|
|
174
|
+
return f"InMemoryStore({len(self.tasks)} tasks, {len(self.graphs)} graphs)"
|
ttasks/_task.py
ADDED
|
@@ -0,0 +1,398 @@
|
|
|
1
|
+
"""Task domain model and state-machine rules."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import subprocess
|
|
6
|
+
import uuid
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from datetime import datetime
|
|
9
|
+
from enum import Enum
|
|
10
|
+
from typing import Any, Literal, cast
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class TaskStatus(Enum):
|
|
14
|
+
"""Lifecycle states a task can move through."""
|
|
15
|
+
|
|
16
|
+
PENDING = "pending"
|
|
17
|
+
RUNNING = "running"
|
|
18
|
+
SUCCEEDED = "succeeded"
|
|
19
|
+
FAILED = "failed"
|
|
20
|
+
CANCELLED = "cancelled"
|
|
21
|
+
BLOCKED = "blocked"
|
|
22
|
+
|
|
23
|
+
@property
|
|
24
|
+
def is_sink(self) -> bool:
|
|
25
|
+
"""No outgoing transitions in the SM (SUCCEEDED, CANCELLED).
|
|
26
|
+
|
|
27
|
+
Drift-guarded by a test that compares this set to
|
|
28
|
+
``_ALLOWED_TRANSITIONS``. The scheduler uses this to identify
|
|
29
|
+
statuses it must never retry; ``executor.cancel`` uses it as
|
|
30
|
+
the boundary past which cancellation is a no-op.
|
|
31
|
+
"""
|
|
32
|
+
return self in {TaskStatus.SUCCEEDED, TaskStatus.CANCELLED}
|
|
33
|
+
|
|
34
|
+
@property
|
|
35
|
+
def is_bad(self) -> bool:
|
|
36
|
+
"""An upstream parent in this state blocks ready descendants."""
|
|
37
|
+
return self in {TaskStatus.FAILED, TaskStatus.CANCELLED, TaskStatus.BLOCKED}
|
|
38
|
+
|
|
39
|
+
@property
|
|
40
|
+
def is_active(self) -> bool:
|
|
41
|
+
"""Task may still progress within the current run without intervention."""
|
|
42
|
+
return self in {TaskStatus.PENDING, TaskStatus.RUNNING}
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class TaskType(Enum):
|
|
46
|
+
"""Kinds of work the executor can dispatch to handlers."""
|
|
47
|
+
|
|
48
|
+
BASH = "bash"
|
|
49
|
+
POWERSHELL = "powershell"
|
|
50
|
+
PROMPT = "prompt"
|
|
51
|
+
AGENT = "agent"
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
# Centralized state-machine definition. All task status changes should flow
|
|
55
|
+
# through Task.transition_to() so these rules are enforced consistently.
|
|
56
|
+
_ALLOWED_TRANSITIONS = {
|
|
57
|
+
TaskStatus.PENDING: {
|
|
58
|
+
TaskStatus.RUNNING,
|
|
59
|
+
TaskStatus.CANCELLED,
|
|
60
|
+
TaskStatus.BLOCKED,
|
|
61
|
+
TaskStatus.FAILED,
|
|
62
|
+
},
|
|
63
|
+
TaskStatus.RUNNING: {TaskStatus.SUCCEEDED, TaskStatus.FAILED, TaskStatus.CANCELLED},
|
|
64
|
+
TaskStatus.FAILED: {TaskStatus.RUNNING, TaskStatus.CANCELLED},
|
|
65
|
+
TaskStatus.BLOCKED: {TaskStatus.RUNNING, TaskStatus.CANCELLED},
|
|
66
|
+
TaskStatus.SUCCEEDED: set(),
|
|
67
|
+
TaskStatus.CANCELLED: set(),
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@dataclass(eq=False)
|
|
72
|
+
class Task:
|
|
73
|
+
"""A unit of work tracked by the ledger and executed by TaskExecutor.
|
|
74
|
+
|
|
75
|
+
status is intentionally exposed as a read-only property. Use transition_to()
|
|
76
|
+
or cancel() to mutate it so invalid state transitions cannot be bypassed.
|
|
77
|
+
Once a task reaches SUCCEEDED, normal public attribute assignment is rejected so
|
|
78
|
+
completed upstream tasks can be safely shared by reference.
|
|
79
|
+
|
|
80
|
+
timeout=None is intentional and means no automatic timeout is applied;
|
|
81
|
+
callers should set a positive timeout for bounded subprocess execution.
|
|
82
|
+
"""
|
|
83
|
+
|
|
84
|
+
title: str
|
|
85
|
+
payload: str
|
|
86
|
+
type: TaskType
|
|
87
|
+
description: str = ""
|
|
88
|
+
error: str | None = None
|
|
89
|
+
timeout: float | None = None
|
|
90
|
+
_id: str = field(default_factory=lambda: str(uuid.uuid4()), repr=False)
|
|
91
|
+
created_at: datetime = field(default_factory=datetime.now)
|
|
92
|
+
_status: TaskStatus = field(default=TaskStatus.PENDING, init=False, repr=False)
|
|
93
|
+
# The most recent TaskResult attached to this task, set by TaskExecutor on
|
|
94
|
+
# every non-BLOCKED terminal path (SUCCEEDED, FAILED, CANCELLED). None until
|
|
95
|
+
# first run. BLOCKED tasks leave this as None — no handler ran.
|
|
96
|
+
_result: TaskResult | None = field(default=None, init=False, repr=False)
|
|
97
|
+
# The id of the direct upstream parent whose state (FAILED/CANCELLED/
|
|
98
|
+
# BLOCKED) caused this task to be marked BLOCKED. None unless blocked.
|
|
99
|
+
_blocked_by: str | None = field(default=None, init=False, repr=False)
|
|
100
|
+
|
|
101
|
+
def __setattr__(self, name: str, value: object) -> None:
|
|
102
|
+
"""Enforce id immutability and the private-setter discipline.
|
|
103
|
+
|
|
104
|
+
``_id`` is immutable once set so the identity used by
|
|
105
|
+
``__hash__`` / ``__eq__`` is stable. ``result`` and
|
|
106
|
+
``blocked_by`` are read-only properties backed by private
|
|
107
|
+
fields — callers must use the executor-internal
|
|
108
|
+
``_set_result`` / ``_set_blocked_by`` helpers. SUCCEEDED tasks
|
|
109
|
+
reject all remaining public writes so completed upstream tasks
|
|
110
|
+
can be safely shared by reference.
|
|
111
|
+
"""
|
|
112
|
+
if name == "_id" and "_id" in self.__dict__:
|
|
113
|
+
raise AttributeError("Task._id is immutable")
|
|
114
|
+
if name in {"result", "blocked_by"}:
|
|
115
|
+
raise AttributeError(
|
|
116
|
+
f"Task.{name} is read-only; use _set_{name}() to mutate"
|
|
117
|
+
)
|
|
118
|
+
if (
|
|
119
|
+
not name.startswith("_")
|
|
120
|
+
and getattr(self, "_status", None) == TaskStatus.SUCCEEDED
|
|
121
|
+
):
|
|
122
|
+
raise AttributeError("SUCCEEDED tasks are immutable")
|
|
123
|
+
super().__setattr__(name, value)
|
|
124
|
+
|
|
125
|
+
def __eq__(self, other: object) -> bool:
|
|
126
|
+
"""Tasks are equal iff they share an id (identity-by-id)."""
|
|
127
|
+
if not isinstance(other, Task):
|
|
128
|
+
return NotImplemented
|
|
129
|
+
return self._id == other._id
|
|
130
|
+
|
|
131
|
+
def __hash__(self) -> int:
|
|
132
|
+
"""Hash by id so tasks work as set / dict keys."""
|
|
133
|
+
return hash(self._id)
|
|
134
|
+
|
|
135
|
+
def __post_init__(self) -> None:
|
|
136
|
+
"""Validate task configuration after dataclass initialization."""
|
|
137
|
+
if not isinstance(self.type, TaskType):
|
|
138
|
+
raise TypeError("type must be a TaskType")
|
|
139
|
+
if self.timeout is not None and self.timeout <= 0:
|
|
140
|
+
raise ValueError("timeout must be greater than 0")
|
|
141
|
+
|
|
142
|
+
@property
|
|
143
|
+
def id(self) -> str:
|
|
144
|
+
"""Return the immutable task identity."""
|
|
145
|
+
return self._id
|
|
146
|
+
|
|
147
|
+
@property
|
|
148
|
+
def status(self) -> TaskStatus:
|
|
149
|
+
"""Return the current lifecycle state without allowing direct writes."""
|
|
150
|
+
return self._status
|
|
151
|
+
|
|
152
|
+
@property
|
|
153
|
+
def is_pending(self) -> bool:
|
|
154
|
+
"""Return whether the task is in the PENDING state."""
|
|
155
|
+
return self._status == TaskStatus.PENDING
|
|
156
|
+
|
|
157
|
+
@property
|
|
158
|
+
def is_running(self) -> bool:
|
|
159
|
+
"""Return whether the task is in the RUNNING state."""
|
|
160
|
+
return self._status == TaskStatus.RUNNING
|
|
161
|
+
|
|
162
|
+
@property
|
|
163
|
+
def is_succeeded(self) -> bool:
|
|
164
|
+
"""Return whether the task has completed successfully."""
|
|
165
|
+
return self._status == TaskStatus.SUCCEEDED
|
|
166
|
+
|
|
167
|
+
@property
|
|
168
|
+
def is_failed(self) -> bool:
|
|
169
|
+
"""Return whether the task is in the FAILED state."""
|
|
170
|
+
return self._status == TaskStatus.FAILED
|
|
171
|
+
|
|
172
|
+
@property
|
|
173
|
+
def is_cancelled(self) -> bool:
|
|
174
|
+
"""Return whether the task is in the CANCELLED state."""
|
|
175
|
+
return self._status == TaskStatus.CANCELLED
|
|
176
|
+
|
|
177
|
+
@property
|
|
178
|
+
def is_terminal(self) -> bool:
|
|
179
|
+
"""Return whether the task is in a terminal state.
|
|
180
|
+
|
|
181
|
+
Terminal states: SUCCEEDED, FAILED, CANCELLED, BLOCKED.
|
|
182
|
+
"""
|
|
183
|
+
return self._status in {
|
|
184
|
+
TaskStatus.SUCCEEDED,
|
|
185
|
+
TaskStatus.FAILED,
|
|
186
|
+
TaskStatus.CANCELLED,
|
|
187
|
+
TaskStatus.BLOCKED,
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
@property
|
|
191
|
+
def is_blocked(self) -> bool:
|
|
192
|
+
"""Return whether the task is in the BLOCKED state."""
|
|
193
|
+
return self._status == TaskStatus.BLOCKED
|
|
194
|
+
|
|
195
|
+
@property
|
|
196
|
+
def result(self) -> TaskResult | None:
|
|
197
|
+
"""Return the latest run's TaskResult, if any."""
|
|
198
|
+
return self._result
|
|
199
|
+
|
|
200
|
+
def _set_result(self, result: TaskResult | None) -> None:
|
|
201
|
+
"""Attach ``result`` (executor-internal seam)."""
|
|
202
|
+
object.__setattr__(self, "_result", result)
|
|
203
|
+
|
|
204
|
+
@property
|
|
205
|
+
def blocked_by(self) -> str | None:
|
|
206
|
+
"""ID of the direct upstream parent that triggered the block, if any."""
|
|
207
|
+
return self._blocked_by
|
|
208
|
+
|
|
209
|
+
def _set_blocked_by(self, parent_id: str | None) -> None:
|
|
210
|
+
"""Attach the blocking parent id (executor-internal seam)."""
|
|
211
|
+
object.__setattr__(self, "_blocked_by", parent_id)
|
|
212
|
+
|
|
213
|
+
def can_transition_to(self, status: TaskStatus) -> bool:
|
|
214
|
+
"""Return whether the task may move from its current state to status."""
|
|
215
|
+
if not isinstance(status, TaskStatus):
|
|
216
|
+
raise TypeError("status must be a TaskStatus")
|
|
217
|
+
return status in _ALLOWED_TRANSITIONS[self._status]
|
|
218
|
+
|
|
219
|
+
def transition_to(self, status: TaskStatus, error: str | None = None) -> None:
|
|
220
|
+
"""Move the task to a new state if the state-machine allows it.
|
|
221
|
+
|
|
222
|
+
error is stored for failed transitions and cleared by successful ones
|
|
223
|
+
because the default value is None. Entering RUNNING also clears any
|
|
224
|
+
prior run's ``result`` and ``blocked_by`` so a retry starts clean.
|
|
225
|
+
"""
|
|
226
|
+
if not isinstance(status, TaskStatus):
|
|
227
|
+
raise TypeError("status must be a TaskStatus")
|
|
228
|
+
if not self.can_transition_to(status):
|
|
229
|
+
message = (
|
|
230
|
+
f"Cannot transition task from {self._status.value!r} "
|
|
231
|
+
f"to {status.value!r}"
|
|
232
|
+
)
|
|
233
|
+
raise ValueError(message)
|
|
234
|
+
|
|
235
|
+
self.error = error
|
|
236
|
+
self._status = status
|
|
237
|
+
if status == TaskStatus.RUNNING:
|
|
238
|
+
object.__setattr__(self, "_result", None)
|
|
239
|
+
object.__setattr__(self, "_blocked_by", None)
|
|
240
|
+
|
|
241
|
+
def cancel(self) -> None:
|
|
242
|
+
"""Cancel the task without discarding any existing error detail.
|
|
243
|
+
|
|
244
|
+
Cancellation is intentionally idempotent so duplicate user/API requests
|
|
245
|
+
are harmless, while transition_to(CANCELLED) remains strict.
|
|
246
|
+
"""
|
|
247
|
+
if self.status == TaskStatus.CANCELLED:
|
|
248
|
+
return
|
|
249
|
+
|
|
250
|
+
self.transition_to(TaskStatus.CANCELLED, error=self.error)
|
|
251
|
+
|
|
252
|
+
def __repr__(self):
|
|
253
|
+
"""Return a concise representation focused on identity and status."""
|
|
254
|
+
return f"Task(id={self.id!r}, title={self.title!r}, status={self.status.value})"
|
|
255
|
+
|
|
256
|
+
@classmethod
|
|
257
|
+
def _make(
|
|
258
|
+
cls,
|
|
259
|
+
task_type: TaskType,
|
|
260
|
+
payload: str,
|
|
261
|
+
*,
|
|
262
|
+
title: str,
|
|
263
|
+
description: str,
|
|
264
|
+
timeout: float | None,
|
|
265
|
+
) -> Task:
|
|
266
|
+
"""Construct a task of ``task_type`` from the shared factory kwargs."""
|
|
267
|
+
return cls(
|
|
268
|
+
title=title,
|
|
269
|
+
payload=payload,
|
|
270
|
+
type=task_type,
|
|
271
|
+
description=description,
|
|
272
|
+
timeout=timeout,
|
|
273
|
+
)
|
|
274
|
+
|
|
275
|
+
@classmethod
|
|
276
|
+
def bash(
|
|
277
|
+
cls,
|
|
278
|
+
payload: str,
|
|
279
|
+
*,
|
|
280
|
+
title: str = "",
|
|
281
|
+
description: str = "",
|
|
282
|
+
timeout: float | None = None,
|
|
283
|
+
) -> Task:
|
|
284
|
+
"""Construct a BASH task without requiring callers to import TaskType."""
|
|
285
|
+
return cls._make(
|
|
286
|
+
TaskType.BASH, payload,
|
|
287
|
+
title=title, description=description, timeout=timeout,
|
|
288
|
+
)
|
|
289
|
+
|
|
290
|
+
@classmethod
|
|
291
|
+
def powershell(
|
|
292
|
+
cls,
|
|
293
|
+
payload: str,
|
|
294
|
+
*,
|
|
295
|
+
title: str = "",
|
|
296
|
+
description: str = "",
|
|
297
|
+
timeout: float | None = None,
|
|
298
|
+
) -> Task:
|
|
299
|
+
"""Construct a POWERSHELL task without requiring callers to import TaskType."""
|
|
300
|
+
return cls._make(
|
|
301
|
+
TaskType.POWERSHELL, payload,
|
|
302
|
+
title=title, description=description, timeout=timeout,
|
|
303
|
+
)
|
|
304
|
+
|
|
305
|
+
@classmethod
|
|
306
|
+
def prompt(
|
|
307
|
+
cls,
|
|
308
|
+
payload: str,
|
|
309
|
+
*,
|
|
310
|
+
title: str = "",
|
|
311
|
+
description: str = "",
|
|
312
|
+
timeout: float | None = None,
|
|
313
|
+
) -> Task:
|
|
314
|
+
"""Construct a PROMPT task without requiring callers to import TaskType."""
|
|
315
|
+
return cls._make(
|
|
316
|
+
TaskType.PROMPT, payload,
|
|
317
|
+
title=title, description=description, timeout=timeout,
|
|
318
|
+
)
|
|
319
|
+
|
|
320
|
+
@classmethod
|
|
321
|
+
def agent(
|
|
322
|
+
cls,
|
|
323
|
+
payload: str,
|
|
324
|
+
*,
|
|
325
|
+
title: str = "",
|
|
326
|
+
description: str = "",
|
|
327
|
+
timeout: float | None = None,
|
|
328
|
+
) -> Task:
|
|
329
|
+
"""Construct an AGENT task without requiring callers to import TaskType."""
|
|
330
|
+
return cls._make(
|
|
331
|
+
TaskType.AGENT, payload,
|
|
332
|
+
title=title, description=description, timeout=timeout,
|
|
333
|
+
)
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
TerminationReason = Literal["exit_code", "timeout", "cancelled", "handler"]
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
@dataclass(frozen=True)
|
|
340
|
+
class TaskResult:
|
|
341
|
+
"""Normalized record of a single task execution.
|
|
342
|
+
|
|
343
|
+
Attached to Task.result by TaskExecutor on every terminal path so the
|
|
344
|
+
Task itself is the canonical post-run view. Frozen so a completed run
|
|
345
|
+
record cannot be mutated after the fact.
|
|
346
|
+
|
|
347
|
+
``termination_reason`` distinguishes the cause of every terminal
|
|
348
|
+
transition: ``None`` means SUCCEEDED; ``"exit_code"`` means a
|
|
349
|
+
subprocess exited non-zero; ``"timeout"`` means the wall-clock budget
|
|
350
|
+
was exceeded and SIGTERM/SIGKILL fired; ``"cancelled"`` means a
|
|
351
|
+
cooperative cancel signal was honored; ``"handler"`` means the
|
|
352
|
+
handler raised an unstructured exception.
|
|
353
|
+
"""
|
|
354
|
+
|
|
355
|
+
task_id: str
|
|
356
|
+
status: TaskStatus
|
|
357
|
+
started_at: datetime
|
|
358
|
+
finished_at: datetime
|
|
359
|
+
duration: float
|
|
360
|
+
output: str = ""
|
|
361
|
+
error: str | None = None
|
|
362
|
+
returncode: int | None = None
|
|
363
|
+
raw: object | None = None
|
|
364
|
+
termination_reason: TerminationReason | None = None
|
|
365
|
+
|
|
366
|
+
@classmethod
|
|
367
|
+
def from_raw(
|
|
368
|
+
cls,
|
|
369
|
+
task: Task,
|
|
370
|
+
raw: object,
|
|
371
|
+
*,
|
|
372
|
+
status: TaskStatus,
|
|
373
|
+
started_at: datetime,
|
|
374
|
+
finished_at: datetime,
|
|
375
|
+
duration: float,
|
|
376
|
+
) -> TaskResult:
|
|
377
|
+
"""Normalize a handler return value into a timed TaskResult."""
|
|
378
|
+
base: dict[str, Any] = dict(
|
|
379
|
+
task_id=task.id,
|
|
380
|
+
status=status,
|
|
381
|
+
started_at=started_at,
|
|
382
|
+
finished_at=finished_at,
|
|
383
|
+
duration=duration,
|
|
384
|
+
)
|
|
385
|
+
if isinstance(raw, subprocess.CompletedProcess):
|
|
386
|
+
completed = cast("subprocess.CompletedProcess[str]", raw)
|
|
387
|
+
return cls(
|
|
388
|
+
**base,
|
|
389
|
+
output=completed.stdout or "",
|
|
390
|
+
error=completed.stderr or None,
|
|
391
|
+
returncode=completed.returncode,
|
|
392
|
+
raw=completed,
|
|
393
|
+
)
|
|
394
|
+
|
|
395
|
+
if isinstance(raw, str):
|
|
396
|
+
return cls(**base, output=raw, raw=raw)
|
|
397
|
+
|
|
398
|
+
return cls(**base, raw=raw)
|
ttasks/_version.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# file generated by vcs-versioning
|
|
2
|
+
# don't change, don't track in version control
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
__all__ = [
|
|
6
|
+
"__version__",
|
|
7
|
+
"__version_tuple__",
|
|
8
|
+
"version",
|
|
9
|
+
"version_tuple",
|
|
10
|
+
"__commit_id__",
|
|
11
|
+
"commit_id",
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
version: str
|
|
15
|
+
__version__: str
|
|
16
|
+
__version_tuple__: tuple[int | str, ...]
|
|
17
|
+
version_tuple: tuple[int | str, ...]
|
|
18
|
+
commit_id: str | None
|
|
19
|
+
__commit_id__: str | None
|
|
20
|
+
|
|
21
|
+
__version__ = version = '0.2.0'
|
|
22
|
+
__version_tuple__ = version_tuple = (0, 2, 0)
|
|
23
|
+
|
|
24
|
+
__commit_id__ = commit_id = None
|
ttasks/py.typed
ADDED
|
File without changes
|