taskproof 0.3.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.
- taskproof/__init__.py +23 -0
- taskproof/__main__.py +8 -0
- taskproof/adapters/__init__.py +101 -0
- taskproof/adapters/base.py +228 -0
- taskproof/adapters/claude.py +46 -0
- taskproof/adapters/codex.py +140 -0
- taskproof/adapters/custom.py +61 -0
- taskproof/adapters/gemini.py +45 -0
- taskproof/adapters/opencode.py +46 -0
- taskproof/api/__init__.py +1 -0
- taskproof/api/server.py +822 -0
- taskproof/cli.py +1400 -0
- taskproof/concurrency.py +329 -0
- taskproof/dispatch.py +2289 -0
- taskproof/errors.py +55 -0
- taskproof/ledger.py +185 -0
- taskproof/models.py +155 -0
- taskproof/registry.py +1585 -0
- taskproof/schemas/result.json +58 -0
- taskproof/sql/schema.sql +70 -0
- taskproof/storage.py +372 -0
- taskproof/verify.py +615 -0
- taskproof/web/assets/MatrixView-BM8nu9s2.css +446 -0
- taskproof/web/assets/MatrixView-BM8nu9s2.css.gz +0 -0
- taskproof/web/assets/MatrixView-BMsawnxh.js +475 -0
- taskproof/web/assets/MatrixView-BMsawnxh.js.gz +0 -0
- taskproof/web/assets/ProjectsView-BcXcx5Gw.js +302 -0
- taskproof/web/assets/ProjectsView-BcXcx5Gw.js.gz +0 -0
- taskproof/web/assets/ProjectsView-CqNFL-fi.css +23 -0
- taskproof/web/assets/SettingsView-D_-waf0s.js +585 -0
- taskproof/web/assets/SettingsView-D_-waf0s.js.gz +0 -0
- taskproof/web/assets/SettingsView-aqTS3ubB.css +19 -0
- taskproof/web/assets/TaskDrawer-6JoGTH0s.js +597 -0
- taskproof/web/assets/TaskDrawer-6JoGTH0s.js.gz +0 -0
- taskproof/web/assets/TaskDrawer-N299VKzd.css +465 -0
- taskproof/web/assets/TaskDrawer-N299VKzd.css.gz +0 -0
- taskproof/web/assets/TasksView-BWhSHCHi.js +114 -0
- taskproof/web/assets/TasksView-BWhSHCHi.js.gz +0 -0
- taskproof/web/assets/TasksView-Bn08731V.css +30 -0
- taskproof/web/assets/icon-Dw-ohsdc.png +0 -0
- taskproof/web/assets/index-B-PLdDbR.js +75421 -0
- taskproof/web/assets/index-B-PLdDbR.js.gz +0 -0
- taskproof/web/assets/index-NKj7kbYl.css +1234 -0
- taskproof/web/assets/index-NKj7kbYl.css.gz +0 -0
- taskproof/web/index.html +23 -0
- taskproof-0.3.0.dist-info/METADATA +470 -0
- taskproof-0.3.0.dist-info/RECORD +51 -0
- taskproof-0.3.0.dist-info/WHEEL +5 -0
- taskproof-0.3.0.dist-info/entry_points.txt +2 -0
- taskproof-0.3.0.dist-info/licenses/LICENSE +201 -0
- taskproof-0.3.0.dist-info/top_level.txt +1 -0
taskproof/errors.py
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""Exit codes and the error hierarchy.
|
|
2
|
+
|
|
3
|
+
The exit codes are part of the public contract: callers (agents, scripts, humans)
|
|
4
|
+
must be able to distinguish "the agent failed" from "the agent succeeded but
|
|
5
|
+
verification failed" without parsing stdout.
|
|
6
|
+
|
|
7
|
+
Keep this table in sync with docs/DESIGN.md.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
EXIT_OK = 0
|
|
11
|
+
EXIT_REGISTRY = 2
|
|
12
|
+
EXIT_USAGE = 64
|
|
13
|
+
EXIT_ADAPTER = 70
|
|
14
|
+
EXIT_VERIFY = 71
|
|
15
|
+
EXIT_CONCURRENCY = 75
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class TaskproofError(Exception):
|
|
19
|
+
"""Base class. Carries the process exit code this failure maps to."""
|
|
20
|
+
|
|
21
|
+
exit_code = 1
|
|
22
|
+
|
|
23
|
+
def __init__(self, message, *, hint=None):
|
|
24
|
+
super().__init__(message)
|
|
25
|
+
self.hint = hint
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class UsageError(TaskproofError):
|
|
29
|
+
"""Bad invocation — wrong flags, unreadable input. -> 64"""
|
|
30
|
+
|
|
31
|
+
exit_code = EXIT_USAGE
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class RegistryError(TaskproofError):
|
|
35
|
+
"""Project not found, registry malformed, missing acceptance command. -> 2"""
|
|
36
|
+
|
|
37
|
+
exit_code = EXIT_REGISTRY
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class AdapterError(TaskproofError):
|
|
41
|
+
"""The agent CLI failed to run, or its result could not be parsed. -> 70"""
|
|
42
|
+
|
|
43
|
+
exit_code = EXIT_ADAPTER
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class VerifyError(TaskproofError):
|
|
47
|
+
"""Acceptance command or artifact self-check failed. -> 71"""
|
|
48
|
+
|
|
49
|
+
exit_code = EXIT_VERIFY
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class ConcurrencyError(TaskproofError):
|
|
53
|
+
"""Same-group serialisation or the global cap blocked this dispatch. -> 75"""
|
|
54
|
+
|
|
55
|
+
exit_code = EXIT_CONCURRENCY
|
taskproof/ledger.py
ADDED
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
"""Append-only audit stream.
|
|
2
|
+
|
|
3
|
+
WRITE-ONLY WITH RESPECT TO LOGIC. Nothing in this package decides anything by
|
|
4
|
+
reading these files. The database is the query surface; this is the audit trail
|
|
5
|
+
and the "read it once tomorrow morning" surface.
|
|
6
|
+
|
|
7
|
+
Layout (workspace = ~/.taskproof by default):
|
|
8
|
+
|
|
9
|
+
events-2026-10.jsonl current month
|
|
10
|
+
events-2026-09.jsonl.gz rotated + compressed
|
|
11
|
+
|
|
12
|
+
Rotation is driven by `gc` (see `rotate`), not by a background daemon.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
import gzip
|
|
16
|
+
import json
|
|
17
|
+
import os
|
|
18
|
+
import re
|
|
19
|
+
import shutil
|
|
20
|
+
from typing import Iterator, Optional
|
|
21
|
+
|
|
22
|
+
from .storage import now_iso
|
|
23
|
+
|
|
24
|
+
EVENT_PREFIX = "events-"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def month_key(iso_ts: Optional[str] = None) -> str:
|
|
28
|
+
"""`2026-10` for the given timestamp (or now)."""
|
|
29
|
+
ts = iso_ts or now_iso()
|
|
30
|
+
return ts[:7]
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def stream_path(audit_dir: str, month: Optional[str] = None) -> str:
|
|
34
|
+
return os.path.join(audit_dir, f"{EVENT_PREFIX}{month or month_key()}.jsonl")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def append(audit_dir: str, event: dict) -> str:
|
|
38
|
+
"""Append one JSON object as a single line. Returns the file written.
|
|
39
|
+
|
|
40
|
+
Must be atomic enough that a concurrent reader never sees a partial line:
|
|
41
|
+
one `write()` of a complete line, opened in append mode.
|
|
42
|
+
"""
|
|
43
|
+
if audit_dir:
|
|
44
|
+
ensure_dir(audit_dir)
|
|
45
|
+
path = stream_path(audit_dir, month_key(event.get("ts")))
|
|
46
|
+
line = json.dumps(event, ensure_ascii=False, separators=(",", ":")) + "\n"
|
|
47
|
+
with open(path, "a", encoding="utf-8") as fh:
|
|
48
|
+
fh.write(line) # one write() of a complete line
|
|
49
|
+
fh.flush()
|
|
50
|
+
os.fsync(fh.fileno())
|
|
51
|
+
return path
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def rotate(audit_dir: str, *, keep_months: int = 6, compress: bool = True) -> list:
|
|
55
|
+
"""Compress rotated months, prune beyond `keep_months`.
|
|
56
|
+
|
|
57
|
+
Returns the list of paths touched, so `gc` can report what it did.
|
|
58
|
+
NEVER deletes the current month. Never touches a file it did not create.
|
|
59
|
+
|
|
60
|
+
A "month we own" is a file whose name matches ``events-<YYYY>-<MM>.jsonl``
|
|
61
|
+
(optionally ``.gz``). Anything else in the directory — a workspace's other
|
|
62
|
+
``*.jsonl``, a stray lock file — is invisible to this function.
|
|
63
|
+
|
|
64
|
+
Retention is deliberately coarse: files are per-month, but a month file is
|
|
65
|
+
pruned once its calendar year is at least ``keep_months`` years behind the
|
|
66
|
+
current one, so the newest ``keep_months`` years of months are kept.
|
|
67
|
+
"""
|
|
68
|
+
touched: list = []
|
|
69
|
+
months = _months_on_disk(audit_dir)
|
|
70
|
+
if not months:
|
|
71
|
+
return touched
|
|
72
|
+
current = month_key()
|
|
73
|
+
current_year = int(current[:4])
|
|
74
|
+
for month in sorted(months):
|
|
75
|
+
if month == current:
|
|
76
|
+
# The live month is never compressed and never deleted.
|
|
77
|
+
continue
|
|
78
|
+
files = months[month]
|
|
79
|
+
plain = files.get("plain")
|
|
80
|
+
gz = files.get("gz")
|
|
81
|
+
if plain is not None and compress:
|
|
82
|
+
if gz is None:
|
|
83
|
+
gz = _gzip_copy(plain)
|
|
84
|
+
os.remove(plain)
|
|
85
|
+
touched.append(plain)
|
|
86
|
+
touched.append(gz)
|
|
87
|
+
if current_year - int(month[:4]) >= keep_months:
|
|
88
|
+
# Beyond the window: drop whatever representation survives.
|
|
89
|
+
for stale in (plain, gz):
|
|
90
|
+
if stale and os.path.exists(stale):
|
|
91
|
+
os.remove(stale)
|
|
92
|
+
touched.append(stale)
|
|
93
|
+
return touched
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def iter_events(audit_dir: str, *, task_id: Optional[str] = None) -> Iterator[dict]:
|
|
97
|
+
"""Read events back, oldest first. Transparently reads `.gz` months.
|
|
98
|
+
|
|
99
|
+
This exists for `taskproof log` and for human/audit use. No decision logic
|
|
100
|
+
may depend on it.
|
|
101
|
+
|
|
102
|
+
Only files this module created are read (same pattern as :func:`rotate`).
|
|
103
|
+
A torn final line — a process killed mid-append — is skipped, and a
|
|
104
|
+
truncated gzip stream stops that month rather than raising.
|
|
105
|
+
"""
|
|
106
|
+
months = _months_on_disk(audit_dir)
|
|
107
|
+
for month in sorted(months):
|
|
108
|
+
files = months[month]
|
|
109
|
+
# Prefer the compressed copy: if a crash left both behind, the `.gz` is
|
|
110
|
+
# the completed rotation and the `.jsonl` is a leftover duplicate.
|
|
111
|
+
path = files.get("gz") or files.get("plain")
|
|
112
|
+
if not path:
|
|
113
|
+
continue
|
|
114
|
+
for event in _iter_jsonl_tolerant(path):
|
|
115
|
+
if task_id is not None and event.get("task_id") != task_id:
|
|
116
|
+
continue
|
|
117
|
+
yield event
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
_MONTH_PATTERN = re.compile(r"^events-(\d{4})-(\d{2})\.jsonl(\.gz)?$")
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _months_on_disk(audit_dir: str) -> dict:
|
|
124
|
+
"""``{month: {"plain": path, "gz": path}}`` for files THIS module owns."""
|
|
125
|
+
found: dict = {}
|
|
126
|
+
try:
|
|
127
|
+
names = os.listdir(audit_dir)
|
|
128
|
+
except OSError:
|
|
129
|
+
return found
|
|
130
|
+
for name in names:
|
|
131
|
+
m = _MONTH_PATTERN.match(name)
|
|
132
|
+
if m is None:
|
|
133
|
+
continue
|
|
134
|
+
path = os.path.join(audit_dir, name)
|
|
135
|
+
if not os.path.isfile(path):
|
|
136
|
+
continue
|
|
137
|
+
kind = "gz" if name.endswith(".gz") else "plain"
|
|
138
|
+
found.setdefault(f"{m.group(1)}-{m.group(2)}", {})[kind] = path
|
|
139
|
+
return found
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _gzip_copy(plain_path: str) -> str:
|
|
143
|
+
"""Gzip ``plain_path`` in place to ``<plain_path>.gz``; return the new path."""
|
|
144
|
+
gz_path = plain_path + ".gz"
|
|
145
|
+
tmp = gz_path + ".tmp"
|
|
146
|
+
with open(plain_path, "rb") as src, gzip.open(tmp, "wb") as dst:
|
|
147
|
+
shutil.copyfileobj(src, dst)
|
|
148
|
+
os.replace(tmp, gz_path) # atomic: readers never see a half-written .gz
|
|
149
|
+
return gz_path
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _iter_jsonl_tolerant(path: str) -> Iterator[dict]:
|
|
153
|
+
"""``_read_jsonl`` that also survives a truncated gzip stream."""
|
|
154
|
+
try:
|
|
155
|
+
for event in _read_jsonl(path):
|
|
156
|
+
yield event
|
|
157
|
+
except (OSError, EOFError):
|
|
158
|
+
# gzip.BadGzipFile is an OSError; a torn plain line is already skipped
|
|
159
|
+
# inside `_read_jsonl`. Either way an audit read must not explode.
|
|
160
|
+
return
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _read_jsonl(path: str) -> Iterator[dict]:
|
|
164
|
+
opener = gzip.open if path.endswith(".gz") else open
|
|
165
|
+
with opener(path, "rt", encoding="utf-8") as fh:
|
|
166
|
+
for line in fh:
|
|
167
|
+
line = line.strip()
|
|
168
|
+
if not line:
|
|
169
|
+
continue
|
|
170
|
+
try:
|
|
171
|
+
yield json.loads(line)
|
|
172
|
+
except json.JSONDecodeError:
|
|
173
|
+
# A torn line must never break an audit read.
|
|
174
|
+
continue
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def ensure_dir(path: str) -> str:
|
|
178
|
+
os.makedirs(path, exist_ok=True)
|
|
179
|
+
return path
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def copy_for_export(audit_dir: str, dest: str) -> str:
|
|
183
|
+
"""Bundle the audit dir for sharing (used by `board --out` snapshots)."""
|
|
184
|
+
shutil.make_archive(dest.rstrip("/"), "gztar", audit_dir)
|
|
185
|
+
return dest + ".tar.gz"
|
taskproof/models.py
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
"""Plain data carriers.
|
|
2
|
+
|
|
3
|
+
Deliberately dependency-free dataclasses (no ORM, no pydantic) — the package
|
|
4
|
+
promises standard library only.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from dataclasses import dataclass, field, asdict
|
|
8
|
+
from typing import Any, Dict, List, Optional
|
|
9
|
+
|
|
10
|
+
# Task lifecycle. Terminal states: DONE, FAILED, BLOCKED, TIMEOUT, CANCELLED.
|
|
11
|
+
STATUS_RUNNING = "running"
|
|
12
|
+
STATUS_VERIFYING = "verifying"
|
|
13
|
+
STATUS_DONE = "done"
|
|
14
|
+
STATUS_FAILED = "failed"
|
|
15
|
+
STATUS_BLOCKED = "blocked"
|
|
16
|
+
STATUS_TIMEOUT = "timeout"
|
|
17
|
+
STATUS_CANCELLED = "cancelled"
|
|
18
|
+
|
|
19
|
+
TERMINAL_STATUSES = (
|
|
20
|
+
STATUS_DONE,
|
|
21
|
+
STATUS_FAILED,
|
|
22
|
+
STATUS_BLOCKED,
|
|
23
|
+
STATUS_TIMEOUT,
|
|
24
|
+
STATUS_CANCELLED,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass
|
|
29
|
+
class Project:
|
|
30
|
+
"""A repository / filter unit: where the work happens and what to call it.
|
|
31
|
+
|
|
32
|
+
A project is *not* dispatchable on its own — it is the shared identity and
|
|
33
|
+
default path that one or more :class:`Taskgroup` lanes hang off. Renaming a
|
|
34
|
+
project (or adding a lane) never splits its history.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
id: str
|
|
38
|
+
path: str
|
|
39
|
+
aliases: List[str] = field(default_factory=list)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass
|
|
43
|
+
class Taskgroup:
|
|
44
|
+
"""One lane ("道"): the unit a task is dispatched onto.
|
|
45
|
+
|
|
46
|
+
It owns the acceptance command, the forbidden-path rules and the structured
|
|
47
|
+
result contract; ``path`` defaults to the owning project's path but may
|
|
48
|
+
point anywhere (an independent clone, a subdirectory, ...).
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
id: str
|
|
52
|
+
project: str
|
|
53
|
+
path: str
|
|
54
|
+
aliases: List[str] = field(default_factory=list)
|
|
55
|
+
verify: Optional[str] = None
|
|
56
|
+
verify_kind: str = "none" # check | build | none
|
|
57
|
+
forbidden_paths: List[str] = field(default_factory=list)
|
|
58
|
+
auto_registered: bool = False
|
|
59
|
+
#: Structured-result contract: "default" (package-shipped schema), "none"
|
|
60
|
+
#: (fall back to free text, never silent), or an absolute path to a custom
|
|
61
|
+
#: JSON Schema. See `registry.result_schema_path`.
|
|
62
|
+
result_schema: str = "default"
|
|
63
|
+
#: Long-lived workspace policy. ``"none"`` (the default) runs in-place;
|
|
64
|
+
#: ``"worktree"`` asks taskproof to lazily create one persistent git
|
|
65
|
+
#: worktree for this lane. It is a per-lane opt-in, never a global default.
|
|
66
|
+
workspace: str = "none"
|
|
67
|
+
#: Relative paths to symlink from the main checkout into a lane workspace
|
|
68
|
+
#: (for example ``desktop/node_modules``). Empty means the dispatcher may
|
|
69
|
+
#: probe for package-manager dependency directories.
|
|
70
|
+
link: List[str] = field(default_factory=list)
|
|
71
|
+
#: Concurrency lock name. Empty means this lane's own id; loading fills the
|
|
72
|
+
#: default in so callers never need to special-case an unset value.
|
|
73
|
+
group: str = ""
|
|
74
|
+
#: Per-lane hard timeout in seconds. ``None`` (the default) means "no lane
|
|
75
|
+
#: budget": the effective timeout then falls through to ``[defaults]
|
|
76
|
+
#: timeout`` and finally the built-in fallback. A ``--timeout`` on the
|
|
77
|
+
#: command line overrides it for that one run. See `registry.resolve_timeout`.
|
|
78
|
+
timeout: Optional[int] = None
|
|
79
|
+
#: Per-lane no-progress watchdog budget in seconds (card 83). ``0`` — the
|
|
80
|
+
#: shipped default — disables it; a positive value stops a card whose log
|
|
81
|
+
#: AND working tree have both been quiet for that long. ``None`` means the
|
|
82
|
+
#: lane did not name one, so the chain falls through to the built-in default
|
|
83
|
+
#: (also off). A ``--idle-timeout`` on the command line overrides it for that
|
|
84
|
+
#: one run. See `registry.resolve_idle_timeout`.
|
|
85
|
+
idle_timeout: Optional[int] = None
|
|
86
|
+
|
|
87
|
+
def __post_init__(self):
|
|
88
|
+
if not isinstance(self.group, str) or not self.group.strip():
|
|
89
|
+
self.group = self.id
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@dataclass
|
|
93
|
+
class Task:
|
|
94
|
+
"""One dispatched unit of work. Mirrors the `tasks` table."""
|
|
95
|
+
|
|
96
|
+
id: str
|
|
97
|
+
project: str
|
|
98
|
+
group: str
|
|
99
|
+
brief: str
|
|
100
|
+
status: str = STATUS_RUNNING
|
|
101
|
+
adapter: str = "codex"
|
|
102
|
+
model: Optional[str] = None
|
|
103
|
+
reasoning: Optional[str] = None
|
|
104
|
+
attempt: int = 1
|
|
105
|
+
exit_code: Optional[int] = None
|
|
106
|
+
pid: Optional[int] = None
|
|
107
|
+
#: Process-group id that locates the whole adapter tree (`_run_adapter`
|
|
108
|
+
#: spawns it with ``start_new_session=True``, so this is the adapter's own
|
|
109
|
+
#: session/group, not the dispatching process' group). `cancel` signals
|
|
110
|
+
#: exactly this group.
|
|
111
|
+
pgid: Optional[int] = None
|
|
112
|
+
workdir: Optional[str] = None
|
|
113
|
+
result_path: Optional[str] = None
|
|
114
|
+
verify_cmd: Optional[str] = None
|
|
115
|
+
verify_exit: Optional[int] = None
|
|
116
|
+
files_changed: Optional[int] = None
|
|
117
|
+
created_at: Optional[str] = None
|
|
118
|
+
started_at: Optional[str] = None
|
|
119
|
+
finished_at: Optional[str] = None
|
|
120
|
+
|
|
121
|
+
def to_row(self) -> Dict[str, Any]:
|
|
122
|
+
return asdict(self)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
@dataclass
|
|
126
|
+
class Claim:
|
|
127
|
+
"""A held concurrency slot. `scope` is either `group:<name>` or `global`."""
|
|
128
|
+
|
|
129
|
+
scope: str
|
|
130
|
+
task_id: str
|
|
131
|
+
pid: Optional[int]
|
|
132
|
+
claimed_at: str
|
|
133
|
+
expires_at: str
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
@dataclass
|
|
137
|
+
class AdapterResult:
|
|
138
|
+
"""Normalised outcome of one adapter invocation.
|
|
139
|
+
|
|
140
|
+
`summary` is the agent's own claim — it is displayed but NEVER treated as
|
|
141
|
+
proof of success. Only the independently run acceptance command decides.
|
|
142
|
+
"""
|
|
143
|
+
|
|
144
|
+
exit_code: int
|
|
145
|
+
summary: str = ""
|
|
146
|
+
payload: Optional[Dict[str, Any]] = None
|
|
147
|
+
files_changed: Optional[int] = None
|
|
148
|
+
raw_stdout: str = ""
|
|
149
|
+
raw_stderr: str = ""
|
|
150
|
+
parse_degraded: bool = False
|
|
151
|
+
result_path: Optional[str] = None
|
|
152
|
+
|
|
153
|
+
@property
|
|
154
|
+
def ok(self) -> bool:
|
|
155
|
+
return self.exit_code == 0
|