@alexey_platkovsky/taskpilot 0.1.0-beta.1
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.
- package/LICENSE +201 -0
- package/README.md +141 -0
- package/bin/taskpilot +454 -0
- package/package.json +30 -0
- package/requirements.lock +66 -0
- package/src/taskpilot/__init__.py +1 -0
- package/src/taskpilot/__pycache__/__init__.cpython-311.pyc +0 -0
- package/src/taskpilot/__pycache__/__init__.cpython-314.pyc +0 -0
- package/src/taskpilot/cli/__init__.py +6 -0
- package/src/taskpilot/cli/__pycache__/__init__.cpython-311.pyc +0 -0
- package/src/taskpilot/cli/__pycache__/__init__.cpython-314.pyc +0 -0
- package/src/taskpilot/cli/__pycache__/app.cpython-311.pyc +0 -0
- package/src/taskpilot/cli/__pycache__/app.cpython-314.pyc +0 -0
- package/src/taskpilot/cli/__pycache__/context.cpython-311.pyc +0 -0
- package/src/taskpilot/cli/__pycache__/context.cpython-314.pyc +0 -0
- package/src/taskpilot/cli/__pycache__/errors.cpython-311.pyc +0 -0
- package/src/taskpilot/cli/__pycache__/errors.cpython-314.pyc +0 -0
- package/src/taskpilot/cli/__pycache__/exit_codes.cpython-311.pyc +0 -0
- package/src/taskpilot/cli/__pycache__/exit_codes.cpython-314.pyc +0 -0
- package/src/taskpilot/cli/__pycache__/output.cpython-311.pyc +0 -0
- package/src/taskpilot/cli/__pycache__/output.cpython-314.pyc +0 -0
- package/src/taskpilot/cli/__pycache__/registry.cpython-314.pyc +0 -0
- package/src/taskpilot/cli/__pycache__/workspace.cpython-311.pyc +0 -0
- package/src/taskpilot/cli/__pycache__/workspace.cpython-314.pyc +0 -0
- package/src/taskpilot/cli/app.py +61 -0
- package/src/taskpilot/cli/commands/__init__.py +6 -0
- package/src/taskpilot/cli/commands/__pycache__/__init__.cpython-311.pyc +0 -0
- package/src/taskpilot/cli/commands/__pycache__/__init__.cpython-314.pyc +0 -0
- package/src/taskpilot/cli/commands/__pycache__/init.cpython-311.pyc +0 -0
- package/src/taskpilot/cli/commands/__pycache__/init.cpython-314.pyc +0 -0
- package/src/taskpilot/cli/commands/__pycache__/item.cpython-311.pyc +0 -0
- package/src/taskpilot/cli/commands/__pycache__/item.cpython-314.pyc +0 -0
- package/src/taskpilot/cli/commands/__pycache__/project.cpython-311.pyc +0 -0
- package/src/taskpilot/cli/commands/__pycache__/project.cpython-314.pyc +0 -0
- package/src/taskpilot/cli/commands/__pycache__/serve.cpython-311.pyc +0 -0
- package/src/taskpilot/cli/commands/__pycache__/serve.cpython-314.pyc +0 -0
- package/src/taskpilot/cli/commands/__pycache__/validate.cpython-311.pyc +0 -0
- package/src/taskpilot/cli/commands/__pycache__/validate.cpython-314.pyc +0 -0
- package/src/taskpilot/cli/commands/init.py +116 -0
- package/src/taskpilot/cli/commands/item.py +305 -0
- package/src/taskpilot/cli/commands/project.py +50 -0
- package/src/taskpilot/cli/commands/serve.py +78 -0
- package/src/taskpilot/cli/commands/validate.py +61 -0
- package/src/taskpilot/cli/context.py +36 -0
- package/src/taskpilot/cli/errors.py +53 -0
- package/src/taskpilot/cli/exit_codes.py +20 -0
- package/src/taskpilot/cli/output.py +77 -0
- package/src/taskpilot/cli/workspace.py +33 -0
- package/src/taskpilot/core/__init__.py +5 -0
- package/src/taskpilot/core/__pycache__/__init__.cpython-311.pyc +0 -0
- package/src/taskpilot/core/__pycache__/__init__.cpython-314.pyc +0 -0
- package/src/taskpilot/core/__pycache__/comments.cpython-311.pyc +0 -0
- package/src/taskpilot/core/__pycache__/comments.cpython-314.pyc +0 -0
- package/src/taskpilot/core/__pycache__/item_io.cpython-311.pyc +0 -0
- package/src/taskpilot/core/__pycache__/item_io.cpython-314.pyc +0 -0
- package/src/taskpilot/core/__pycache__/layout.cpython-311.pyc +0 -0
- package/src/taskpilot/core/__pycache__/layout.cpython-314.pyc +0 -0
- package/src/taskpilot/core/__pycache__/loader.cpython-314.pyc +0 -0
- package/src/taskpilot/core/__pycache__/models.cpython-311.pyc +0 -0
- package/src/taskpilot/core/__pycache__/models.cpython-314.pyc +0 -0
- package/src/taskpilot/core/__pycache__/project.cpython-311.pyc +0 -0
- package/src/taskpilot/core/__pycache__/project.cpython-314.pyc +0 -0
- package/src/taskpilot/core/__pycache__/timestamps.cpython-311.pyc +0 -0
- package/src/taskpilot/core/__pycache__/timestamps.cpython-314.pyc +0 -0
- package/src/taskpilot/core/__pycache__/validation.cpython-311.pyc +0 -0
- package/src/taskpilot/core/__pycache__/validation.cpython-314.pyc +0 -0
- package/src/taskpilot/core/__pycache__/yaml_io.cpython-311.pyc +0 -0
- package/src/taskpilot/core/__pycache__/yaml_io.cpython-314.pyc +0 -0
- package/src/taskpilot/core/comments.py +238 -0
- package/src/taskpilot/core/item_io.py +123 -0
- package/src/taskpilot/core/layout.py +137 -0
- package/src/taskpilot/core/loader.py +102 -0
- package/src/taskpilot/core/models.py +114 -0
- package/src/taskpilot/core/project.py +151 -0
- package/src/taskpilot/core/timestamps.py +54 -0
- package/src/taskpilot/core/validation.py +385 -0
- package/src/taskpilot/core/yaml_io.py +57 -0
- package/src/taskpilot/server/__init__.py +0 -0
- package/src/taskpilot/server/__pycache__/__init__.cpython-311.pyc +0 -0
- package/src/taskpilot/server/__pycache__/__init__.cpython-314.pyc +0 -0
- package/src/taskpilot/server/__pycache__/app.cpython-311.pyc +0 -0
- package/src/taskpilot/server/__pycache__/app.cpython-314.pyc +0 -0
- package/src/taskpilot/server/__pycache__/schemas.cpython-311.pyc +0 -0
- package/src/taskpilot/server/__pycache__/schemas.cpython-314.pyc +0 -0
- package/src/taskpilot/server/app.py +134 -0
- package/src/taskpilot/server/routes/__init__.py +0 -0
- package/src/taskpilot/server/routes/__pycache__/__init__.cpython-311.pyc +0 -0
- package/src/taskpilot/server/routes/__pycache__/__init__.cpython-314.pyc +0 -0
- package/src/taskpilot/server/routes/__pycache__/projects.cpython-311.pyc +0 -0
- package/src/taskpilot/server/routes/__pycache__/projects.cpython-314.pyc +0 -0
- package/src/taskpilot/server/routes/projects.py +160 -0
- package/src/taskpilot/server/schemas.py +76 -0
- package/src/taskpilot/services/__init__.py +8 -0
- package/src/taskpilot/services/__pycache__/__init__.cpython-311.pyc +0 -0
- package/src/taskpilot/services/__pycache__/__init__.cpython-314.pyc +0 -0
- package/src/taskpilot/services/__pycache__/comment_service.cpython-311.pyc +0 -0
- package/src/taskpilot/services/__pycache__/comment_service.cpython-314.pyc +0 -0
- package/src/taskpilot/services/__pycache__/errors.cpython-311.pyc +0 -0
- package/src/taskpilot/services/__pycache__/errors.cpython-314.pyc +0 -0
- package/src/taskpilot/services/__pycache__/hierarchy.cpython-311.pyc +0 -0
- package/src/taskpilot/services/__pycache__/hierarchy.cpython-314.pyc +0 -0
- package/src/taskpilot/services/__pycache__/item_service.cpython-311.pyc +0 -0
- package/src/taskpilot/services/__pycache__/item_service.cpython-314.pyc +0 -0
- package/src/taskpilot/services/__pycache__/link_service.cpython-311.pyc +0 -0
- package/src/taskpilot/services/__pycache__/link_service.cpython-314.pyc +0 -0
- package/src/taskpilot/services/__pycache__/operation_validation.cpython-311.pyc +0 -0
- package/src/taskpilot/services/__pycache__/operation_validation.cpython-314.pyc +0 -0
- package/src/taskpilot/services/__pycache__/project_service.cpython-311.pyc +0 -0
- package/src/taskpilot/services/__pycache__/project_service.cpython-314.pyc +0 -0
- package/src/taskpilot/services/__pycache__/registry.cpython-311.pyc +0 -0
- package/src/taskpilot/services/__pycache__/registry.cpython-314.pyc +0 -0
- package/src/taskpilot/services/__pycache__/reverse_links.cpython-314.pyc +0 -0
- package/src/taskpilot/services/comment_service.py +62 -0
- package/src/taskpilot/services/errors.py +26 -0
- package/src/taskpilot/services/hierarchy.py +107 -0
- package/src/taskpilot/services/item_service.py +264 -0
- package/src/taskpilot/services/link_service.py +97 -0
- package/src/taskpilot/services/operation_validation.py +52 -0
- package/src/taskpilot/services/project_service.py +111 -0
- package/src/taskpilot/services/registry.py +194 -0
- package/src/taskpilot/services/reverse_links.py +60 -0
|
@@ -0,0 +1,385 @@
|
|
|
1
|
+
"""Item validation producing non-crashing findings (task F001-T7, F001-R6).
|
|
2
|
+
|
|
3
|
+
Validation never blocks loading: every problem becomes a :class:`Finding` with a
|
|
4
|
+
severity, stable code, file path, and message. Errors flip ``ok`` to ``False``
|
|
5
|
+
(and make ``taskpilot validate`` exit non-zero); warnings do not.
|
|
6
|
+
|
|
7
|
+
Covered rules (see ``docs/specs/0002`` "Validation"):
|
|
8
|
+
|
|
9
|
+
- unparseable YAML / non-mapping documents (error);
|
|
10
|
+
- required fields and valid enums/timestamps via the :class:`Item` model (error);
|
|
11
|
+
- ``id`` must match the filename stem (error);
|
|
12
|
+
- duplicate ``id`` across files (error on every offending file);
|
|
13
|
+
- ``parent_id`` and link targets must reference existing items (missing -> error;
|
|
14
|
+
target is ``deleted`` -> warning);
|
|
15
|
+
- attachment paths must be relative and inside the repo (absolute/escape -> error;
|
|
16
|
+
missing file -> warning).
|
|
17
|
+
|
|
18
|
+
Hierarchy *type* rules (epic/feature/task/bug parent-child) are F002-T3.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
from collections import defaultdict
|
|
24
|
+
from enum import Enum
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
|
|
27
|
+
import yaml
|
|
28
|
+
from pydantic import BaseModel, ValidationError
|
|
29
|
+
|
|
30
|
+
from taskpilot.core.comments import (
|
|
31
|
+
CommentParseError,
|
|
32
|
+
comment_filename_timestamp,
|
|
33
|
+
parse_comment_text,
|
|
34
|
+
)
|
|
35
|
+
from taskpilot.core.layout import WorkspacePaths
|
|
36
|
+
from taskpilot.core.models import Item
|
|
37
|
+
from taskpilot.core.yaml_io import load_yaml
|
|
38
|
+
|
|
39
|
+
__all__ = ["Severity", "Finding", "ValidationReport", "validate_workspace"]
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class Severity(str, Enum):
|
|
43
|
+
error = "error"
|
|
44
|
+
warning = "warning"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class Finding(BaseModel):
|
|
48
|
+
"""A single validation result. ``field``/``item_id`` are null when not applicable."""
|
|
49
|
+
|
|
50
|
+
severity: Severity
|
|
51
|
+
code: str
|
|
52
|
+
path: str
|
|
53
|
+
field: str | None = None
|
|
54
|
+
item_id: str | None = None
|
|
55
|
+
message: str
|
|
56
|
+
|
|
57
|
+
def to_dict(self) -> dict:
|
|
58
|
+
return {
|
|
59
|
+
"severity": self.severity.value,
|
|
60
|
+
"code": self.code,
|
|
61
|
+
"path": self.path,
|
|
62
|
+
"field": self.field,
|
|
63
|
+
"item_id": self.item_id,
|
|
64
|
+
"message": self.message,
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class ValidationReport(BaseModel):
|
|
69
|
+
"""Aggregated findings. ``ok`` is True when there are no error-severity findings."""
|
|
70
|
+
|
|
71
|
+
findings: list[Finding]
|
|
72
|
+
|
|
73
|
+
@property
|
|
74
|
+
def ok(self) -> bool:
|
|
75
|
+
return not any(f.severity == Severity.error for f in self.findings)
|
|
76
|
+
|
|
77
|
+
@property
|
|
78
|
+
def error_count(self) -> int:
|
|
79
|
+
return sum(1 for f in self.findings if f.severity == Severity.error)
|
|
80
|
+
|
|
81
|
+
@property
|
|
82
|
+
def warning_count(self) -> int:
|
|
83
|
+
return sum(1 for f in self.findings if f.severity == Severity.warning)
|
|
84
|
+
|
|
85
|
+
def to_dict(self) -> dict:
|
|
86
|
+
return {
|
|
87
|
+
"ok": self.ok,
|
|
88
|
+
"summary": {"errors": self.error_count, "warnings": self.warning_count},
|
|
89
|
+
"findings": [f.to_dict() for f in self.findings],
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _finding_from_pydantic(err: dict, *, path: str, item_id: str | None) -> Finding:
|
|
94
|
+
field = ".".join(str(p) for p in err["loc"])
|
|
95
|
+
etype = err["type"]
|
|
96
|
+
if etype == "missing":
|
|
97
|
+
return Finding(
|
|
98
|
+
severity=Severity.error,
|
|
99
|
+
code="missing_required_field",
|
|
100
|
+
path=path,
|
|
101
|
+
field=field,
|
|
102
|
+
item_id=item_id,
|
|
103
|
+
message=f"Missing required field: {field}",
|
|
104
|
+
)
|
|
105
|
+
if etype == "enum":
|
|
106
|
+
expected = err.get("ctx", {}).get("expected", "")
|
|
107
|
+
return Finding(
|
|
108
|
+
severity=Severity.error,
|
|
109
|
+
code="invalid_enum",
|
|
110
|
+
path=path,
|
|
111
|
+
field=field,
|
|
112
|
+
item_id=item_id,
|
|
113
|
+
message=f"Invalid value for {field}: expected one of {expected}",
|
|
114
|
+
)
|
|
115
|
+
return Finding(
|
|
116
|
+
severity=Severity.error,
|
|
117
|
+
code="invalid_field",
|
|
118
|
+
path=path,
|
|
119
|
+
field=field,
|
|
120
|
+
item_id=item_id,
|
|
121
|
+
message=f"{field}: {err['msg']}",
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _validate_attachment(
|
|
126
|
+
value: str, *, paths: WorkspacePaths, path: str, item_id: str
|
|
127
|
+
) -> Finding | None:
|
|
128
|
+
if not value.strip():
|
|
129
|
+
return Finding(
|
|
130
|
+
severity=Severity.error,
|
|
131
|
+
code="attachment_empty",
|
|
132
|
+
path=path,
|
|
133
|
+
field="attachments",
|
|
134
|
+
item_id=item_id,
|
|
135
|
+
message="Attachment path is empty",
|
|
136
|
+
)
|
|
137
|
+
if Path(value).is_absolute():
|
|
138
|
+
return Finding(
|
|
139
|
+
severity=Severity.error,
|
|
140
|
+
code="attachment_not_relative",
|
|
141
|
+
path=path,
|
|
142
|
+
field="attachments",
|
|
143
|
+
item_id=item_id,
|
|
144
|
+
message=f"Attachment path must be relative: {value}",
|
|
145
|
+
)
|
|
146
|
+
resolved = (paths.root / value).resolve()
|
|
147
|
+
if not resolved.is_relative_to(paths.root):
|
|
148
|
+
return Finding(
|
|
149
|
+
severity=Severity.error,
|
|
150
|
+
code="attachment_outside_repo",
|
|
151
|
+
path=path,
|
|
152
|
+
field="attachments",
|
|
153
|
+
item_id=item_id,
|
|
154
|
+
message=f"Attachment path escapes the repository: {value}",
|
|
155
|
+
)
|
|
156
|
+
if not resolved.exists():
|
|
157
|
+
return Finding(
|
|
158
|
+
severity=Severity.warning,
|
|
159
|
+
code="missing_attachment",
|
|
160
|
+
path=path,
|
|
161
|
+
field="attachments",
|
|
162
|
+
item_id=item_id,
|
|
163
|
+
message=f"Attachment file not found: {value}",
|
|
164
|
+
)
|
|
165
|
+
return None
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _validate_comments(paths: WorkspacePaths) -> list[Finding]:
|
|
169
|
+
"""Validate ``comments/<ITEM_ID>/*.md`` files without crashing the run.
|
|
170
|
+
|
|
171
|
+
Reports unreadable/malformed comment files as errors and a filename whose
|
|
172
|
+
timestamp does not match the frontmatter ``created_at`` as a warning. The
|
|
173
|
+
owning item id is the comment folder name.
|
|
174
|
+
"""
|
|
175
|
+
findings: list[Finding] = []
|
|
176
|
+
if not paths.comments_dir.is_dir():
|
|
177
|
+
return findings
|
|
178
|
+
|
|
179
|
+
for item_dir in sorted(p for p in paths.comments_dir.iterdir() if p.is_dir()):
|
|
180
|
+
item_id = item_dir.name
|
|
181
|
+
for file in sorted(f for f in item_dir.glob("*.md") if f.is_file()):
|
|
182
|
+
rel = paths.relative_posix(file)
|
|
183
|
+
try:
|
|
184
|
+
text = file.read_text(encoding="utf-8")
|
|
185
|
+
except (UnicodeDecodeError, OSError) as exc:
|
|
186
|
+
findings.append(
|
|
187
|
+
Finding(
|
|
188
|
+
severity=Severity.error,
|
|
189
|
+
code="comment_unreadable",
|
|
190
|
+
path=rel,
|
|
191
|
+
item_id=item_id,
|
|
192
|
+
message=f"Cannot read comment file as UTF-8: {exc}",
|
|
193
|
+
)
|
|
194
|
+
)
|
|
195
|
+
continue
|
|
196
|
+
|
|
197
|
+
try:
|
|
198
|
+
comment = parse_comment_text(text)
|
|
199
|
+
except CommentParseError as exc:
|
|
200
|
+
findings.append(
|
|
201
|
+
Finding(
|
|
202
|
+
severity=Severity.error,
|
|
203
|
+
code="invalid_comment",
|
|
204
|
+
path=rel,
|
|
205
|
+
item_id=item_id,
|
|
206
|
+
message=str(exc),
|
|
207
|
+
)
|
|
208
|
+
)
|
|
209
|
+
continue
|
|
210
|
+
except ValidationError as exc:
|
|
211
|
+
for err in exc.errors():
|
|
212
|
+
findings.append(
|
|
213
|
+
_finding_from_pydantic(err, path=rel, item_id=item_id)
|
|
214
|
+
)
|
|
215
|
+
continue
|
|
216
|
+
|
|
217
|
+
expected = comment_filename_timestamp(file.name)
|
|
218
|
+
if expected is None:
|
|
219
|
+
findings.append(
|
|
220
|
+
Finding(
|
|
221
|
+
severity=Severity.warning,
|
|
222
|
+
code="comment_filename_not_timestamp",
|
|
223
|
+
path=rel,
|
|
224
|
+
item_id=item_id,
|
|
225
|
+
message=f"Comment filename does not encode a timestamp: {file.name}",
|
|
226
|
+
)
|
|
227
|
+
)
|
|
228
|
+
elif expected != comment.created_at:
|
|
229
|
+
findings.append(
|
|
230
|
+
Finding(
|
|
231
|
+
severity=Severity.warning,
|
|
232
|
+
code="comment_timestamp_mismatch",
|
|
233
|
+
path=rel,
|
|
234
|
+
field="created_at",
|
|
235
|
+
item_id=item_id,
|
|
236
|
+
message=f"created_at {comment.created_at!r} does not match filename timestamp {expected!r}",
|
|
237
|
+
)
|
|
238
|
+
)
|
|
239
|
+
return findings
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def validate_workspace(paths: WorkspacePaths) -> ValidationReport:
|
|
243
|
+
"""Validate every ``items/*.yaml`` and ``comments/**/*.md`` file in the workspace."""
|
|
244
|
+
findings: list[Finding] = []
|
|
245
|
+
valid_items: list[tuple[Item, str]] = []
|
|
246
|
+
status_by_id: dict[str, str] = {}
|
|
247
|
+
paths_by_id: dict[str, list[str]] = defaultdict(list)
|
|
248
|
+
|
|
249
|
+
if paths.items_dir.is_dir():
|
|
250
|
+
item_files = sorted(p for p in paths.items_dir.glob("*.yaml") if p.is_file())
|
|
251
|
+
else:
|
|
252
|
+
item_files = []
|
|
253
|
+
|
|
254
|
+
for file in item_files:
|
|
255
|
+
rel = paths.relative_posix(file)
|
|
256
|
+
filename_id = file.stem
|
|
257
|
+
try:
|
|
258
|
+
text = file.read_text(encoding="utf-8")
|
|
259
|
+
except (UnicodeDecodeError, OSError) as exc:
|
|
260
|
+
findings.append(
|
|
261
|
+
Finding(
|
|
262
|
+
severity=Severity.error,
|
|
263
|
+
code="unreadable_file",
|
|
264
|
+
path=rel,
|
|
265
|
+
message=f"Cannot read item file as UTF-8: {exc}",
|
|
266
|
+
)
|
|
267
|
+
)
|
|
268
|
+
continue
|
|
269
|
+
|
|
270
|
+
try:
|
|
271
|
+
data = load_yaml(text)
|
|
272
|
+
except yaml.YAMLError as exc:
|
|
273
|
+
findings.append(
|
|
274
|
+
Finding(
|
|
275
|
+
severity=Severity.error,
|
|
276
|
+
code="invalid_yaml",
|
|
277
|
+
path=rel,
|
|
278
|
+
message=f"Invalid YAML: {exc}",
|
|
279
|
+
)
|
|
280
|
+
)
|
|
281
|
+
continue
|
|
282
|
+
if not isinstance(data, dict):
|
|
283
|
+
findings.append(
|
|
284
|
+
Finding(
|
|
285
|
+
severity=Severity.error,
|
|
286
|
+
code="invalid_yaml",
|
|
287
|
+
path=rel,
|
|
288
|
+
message="Item file is not a YAML mapping",
|
|
289
|
+
)
|
|
290
|
+
)
|
|
291
|
+
continue
|
|
292
|
+
|
|
293
|
+
recoverable_id = data["id"] if isinstance(data.get("id"), str) else None
|
|
294
|
+
|
|
295
|
+
try:
|
|
296
|
+
item = Item.model_validate(data)
|
|
297
|
+
except ValidationError as exc:
|
|
298
|
+
for err in exc.errors():
|
|
299
|
+
findings.append(
|
|
300
|
+
_finding_from_pydantic(err, path=rel, item_id=recoverable_id)
|
|
301
|
+
)
|
|
302
|
+
if recoverable_id is not None:
|
|
303
|
+
paths_by_id[recoverable_id].append(rel)
|
|
304
|
+
continue
|
|
305
|
+
|
|
306
|
+
if item.id != filename_id:
|
|
307
|
+
findings.append(
|
|
308
|
+
Finding(
|
|
309
|
+
severity=Severity.error,
|
|
310
|
+
code="id_filename_mismatch",
|
|
311
|
+
path=rel,
|
|
312
|
+
field="id",
|
|
313
|
+
item_id=item.id,
|
|
314
|
+
message=f"Item id {item.id!r} does not match filename {file.name!r}",
|
|
315
|
+
)
|
|
316
|
+
)
|
|
317
|
+
paths_by_id[item.id].append(rel)
|
|
318
|
+
status_by_id[item.id] = item.status
|
|
319
|
+
valid_items.append((item, rel))
|
|
320
|
+
|
|
321
|
+
# Duplicate IDs: flag every offending file.
|
|
322
|
+
for item_id, rels in paths_by_id.items():
|
|
323
|
+
if len(rels) > 1:
|
|
324
|
+
for rel in rels:
|
|
325
|
+
findings.append(
|
|
326
|
+
Finding(
|
|
327
|
+
severity=Severity.error,
|
|
328
|
+
code="duplicate_id",
|
|
329
|
+
path=rel,
|
|
330
|
+
item_id=item_id,
|
|
331
|
+
message=f"Duplicate item id {item_id!r} (also in {len(rels) - 1} other file(s))",
|
|
332
|
+
)
|
|
333
|
+
)
|
|
334
|
+
|
|
335
|
+
known_ids = set(paths_by_id)
|
|
336
|
+
|
|
337
|
+
def _check_reference(target: str, *, rel: str, source_id: str, field: str) -> None:
|
|
338
|
+
if target not in known_ids:
|
|
339
|
+
findings.append(
|
|
340
|
+
Finding(
|
|
341
|
+
severity=Severity.error,
|
|
342
|
+
code="missing_reference",
|
|
343
|
+
path=rel,
|
|
344
|
+
field=field,
|
|
345
|
+
item_id=source_id,
|
|
346
|
+
message=f"{field} references unknown item: {target}",
|
|
347
|
+
)
|
|
348
|
+
)
|
|
349
|
+
elif status_by_id.get(target) == "deleted":
|
|
350
|
+
findings.append(
|
|
351
|
+
Finding(
|
|
352
|
+
severity=Severity.warning,
|
|
353
|
+
code="link_to_deleted",
|
|
354
|
+
path=rel,
|
|
355
|
+
field=field,
|
|
356
|
+
item_id=source_id,
|
|
357
|
+
message=f"{field} references deleted item: {target}",
|
|
358
|
+
)
|
|
359
|
+
)
|
|
360
|
+
|
|
361
|
+
for item, rel in valid_items:
|
|
362
|
+
if item.parent_id:
|
|
363
|
+
_check_reference(
|
|
364
|
+
item.parent_id, rel=rel, source_id=item.id, field="parent_id"
|
|
365
|
+
)
|
|
366
|
+
if item.links:
|
|
367
|
+
for target in item.links.blocks:
|
|
368
|
+
_check_reference(
|
|
369
|
+
target, rel=rel, source_id=item.id, field="links.blocks"
|
|
370
|
+
)
|
|
371
|
+
for target in item.links.relates_to:
|
|
372
|
+
_check_reference(
|
|
373
|
+
target, rel=rel, source_id=item.id, field="links.relates_to"
|
|
374
|
+
)
|
|
375
|
+
for attachment in item.attachments or []:
|
|
376
|
+
finding = _validate_attachment(
|
|
377
|
+
attachment, paths=paths, path=rel, item_id=item.id
|
|
378
|
+
)
|
|
379
|
+
if finding is not None:
|
|
380
|
+
findings.append(finding)
|
|
381
|
+
|
|
382
|
+
findings.extend(_validate_comments(paths))
|
|
383
|
+
|
|
384
|
+
findings.sort(key=lambda f: (f.path, f.code, f.field or "", f.message))
|
|
385
|
+
return ValidationReport(findings=findings)
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""Deterministic YAML serialization for canonical TaskPilot files.
|
|
2
|
+
|
|
3
|
+
TaskPilot owns canonical YAML formatting (see ``docs/specs/0002``). Canonical
|
|
4
|
+
files are written with a single, stable style so that re-serializing parsed data
|
|
5
|
+
is reproducible and Git-friendly. This module centralizes that style; project
|
|
6
|
+
and item writers must serialize through :func:`dump_yaml`.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
import yaml
|
|
14
|
+
|
|
15
|
+
__all__ = ["dump_yaml", "load_yaml"]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class _CanonicalLoader(yaml.SafeLoader):
|
|
19
|
+
"""SafeLoader that does not auto-convert ISO timestamps to ``datetime``.
|
|
20
|
+
|
|
21
|
+
TaskPilot owns canonical timestamp formatting and stores timestamps as
|
|
22
|
+
strings, so implicit timestamp resolution would corrupt round-trip fidelity.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
_CanonicalLoader.yaml_implicit_resolvers = {
|
|
27
|
+
first_char: [
|
|
28
|
+
(tag, regexp)
|
|
29
|
+
for tag, regexp in resolvers
|
|
30
|
+
if tag != "tag:yaml.org,2002:timestamp"
|
|
31
|
+
]
|
|
32
|
+
for first_char, resolvers in yaml.SafeLoader.yaml_implicit_resolvers.items()
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def dump_yaml(data: Any) -> str:
|
|
37
|
+
"""Serialize ``data`` to canonical YAML text.
|
|
38
|
+
|
|
39
|
+
Key order is preserved (not sorted) so callers control field order; output
|
|
40
|
+
uses block style and allows unicode. The result ends with a single trailing
|
|
41
|
+
newline.
|
|
42
|
+
"""
|
|
43
|
+
return yaml.safe_dump(
|
|
44
|
+
data,
|
|
45
|
+
sort_keys=False,
|
|
46
|
+
allow_unicode=True,
|
|
47
|
+
default_flow_style=False,
|
|
48
|
+
width=4096,
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def load_yaml(text: str) -> Any:
|
|
53
|
+
"""Parse YAML ``text`` into Python data using the canonical safe loader.
|
|
54
|
+
|
|
55
|
+
ISO timestamps are kept as strings (see :class:`_CanonicalLoader`).
|
|
56
|
+
"""
|
|
57
|
+
return yaml.load(text, Loader=_CanonicalLoader)
|
|
File without changes
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from fastapi import FastAPI, Request
|
|
7
|
+
from fastapi.responses import FileResponse, HTMLResponse, JSONResponse
|
|
8
|
+
from fastapi.staticfiles import StaticFiles
|
|
9
|
+
|
|
10
|
+
from taskpilot.server.routes import projects
|
|
11
|
+
from taskpilot.services.errors import NotFound, ValidationFailed
|
|
12
|
+
|
|
13
|
+
#: Environment variable carrying the registry directory for :func:`create_app_from_env`.
|
|
14
|
+
REGISTRY_DIR_ENV = "TASKPILOT_REGISTRY_DIR"
|
|
15
|
+
|
|
16
|
+
#: Environment variable pointing to the staged WebUI production assets directory.
|
|
17
|
+
WEB_DIST_ENV = "TASKPILOT_WEB_DIST"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def create_app(*, registry_dir: str) -> FastAPI:
|
|
21
|
+
app = FastAPI(
|
|
22
|
+
title="TaskPilot API",
|
|
23
|
+
version="0.0.0",
|
|
24
|
+
docs_url="/docs",
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
app.state.registry_dir = registry_dir
|
|
28
|
+
|
|
29
|
+
app.include_router(projects.router, prefix="/api")
|
|
30
|
+
|
|
31
|
+
_mount_webui(app)
|
|
32
|
+
|
|
33
|
+
@app.exception_handler(NotFound)
|
|
34
|
+
def _not_found(request: Request, exc: NotFound) -> JSONResponse:
|
|
35
|
+
return JSONResponse(status_code=404, content={"detail": str(exc)})
|
|
36
|
+
|
|
37
|
+
@app.exception_handler(ValidationFailed)
|
|
38
|
+
def _validation_failed(request: Request, exc: ValidationFailed) -> JSONResponse:
|
|
39
|
+
return JSONResponse(status_code=400, content={"detail": str(exc)})
|
|
40
|
+
|
|
41
|
+
return app
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _mount_webui(app: FastAPI) -> None:
|
|
45
|
+
"""Mount the WebUI static files from ``TASKPILOT_WEB_DIST`` if available.
|
|
46
|
+
|
|
47
|
+
When the directory exists and contains ``index.html`` the app serves:
|
|
48
|
+
- static assets under ``/assets/``
|
|
49
|
+
- ``index.html`` as the SPA fallback for all non-API routes
|
|
50
|
+
|
|
51
|
+
When the directory is missing or unreadable the WebUI route returns
|
|
52
|
+
a clear packaging error instead of a blank page.
|
|
53
|
+
"""
|
|
54
|
+
web_dist = os.environ.get(WEB_DIST_ENV)
|
|
55
|
+
if not web_dist:
|
|
56
|
+
return
|
|
57
|
+
|
|
58
|
+
web_dist_path = Path(web_dist)
|
|
59
|
+
|
|
60
|
+
if web_dist_path.is_dir() and (web_dist_path / "index.html").is_file():
|
|
61
|
+
# Mount static assets (JS, CSS, SVGs, etc.)
|
|
62
|
+
app.mount(
|
|
63
|
+
"/assets",
|
|
64
|
+
StaticFiles(directory=str(web_dist_path / "assets")),
|
|
65
|
+
name="webui_assets",
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
# SPA fallback: serve index.html for all non-API routes
|
|
69
|
+
index_path = web_dist_path / "index.html"
|
|
70
|
+
|
|
71
|
+
@app.get("/{full_path:path}", include_in_schema=False)
|
|
72
|
+
async def _spa_fallback(request: Request, full_path: str) -> FileResponse:
|
|
73
|
+
return FileResponse(index_path)
|
|
74
|
+
|
|
75
|
+
@app.get("/", include_in_schema=False)
|
|
76
|
+
async def _root_fallback(request: Request) -> FileResponse:
|
|
77
|
+
return FileResponse(index_path)
|
|
78
|
+
else:
|
|
79
|
+
# WebUI assets are missing or unreadable — show a clear error
|
|
80
|
+
error_body = _PACKAGING_ERROR_HTML
|
|
81
|
+
|
|
82
|
+
@app.get("/{full_path:path}", include_in_schema=False)
|
|
83
|
+
async def _packaging_error(request: Request, full_path: str) -> HTMLResponse:
|
|
84
|
+
return HTMLResponse(content=error_body, status_code=503)
|
|
85
|
+
|
|
86
|
+
@app.get("/", include_in_schema=False)
|
|
87
|
+
async def _root_packaging_error(request: Request) -> HTMLResponse:
|
|
88
|
+
return HTMLResponse(content=error_body, status_code=503)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
_PACKAGING_ERROR_HTML = """\
|
|
92
|
+
<!DOCTYPE html>
|
|
93
|
+
<html lang="en">
|
|
94
|
+
<head>
|
|
95
|
+
<meta charset="utf-8">
|
|
96
|
+
<title>TaskPilot — WebUI unavailable</title>
|
|
97
|
+
<style>
|
|
98
|
+
body { font-family: system-ui, sans-serif; max-width: 600px; margin: 80px auto; padding: 0 20px; color: #333; }
|
|
99
|
+
h1 { font-size: 1.4rem; }
|
|
100
|
+
code { background: #f0f0f0; padding: 2px 6px; border-radius: 4px; }
|
|
101
|
+
</style>
|
|
102
|
+
</head>
|
|
103
|
+
<body>
|
|
104
|
+
<h1>WebUI assets are not available</h1>
|
|
105
|
+
<p>
|
|
106
|
+
The TaskPilot API is running, but the packaged WebUI assets could not be found.
|
|
107
|
+
This can happen when the npm package was built without WebUI assets, or
|
|
108
|
+
the <code>TASKPILOT_WEB_DIST</code> environment variable points to a missing
|
|
109
|
+
or unreadable directory.
|
|
110
|
+
</p>
|
|
111
|
+
<p>
|
|
112
|
+
To fix this, reinstall the TaskPilot npm package or set
|
|
113
|
+
<code>TASKPILOT_WEB_DIST</code> to a directory containing a production
|
|
114
|
+
WebUI build.
|
|
115
|
+
</p>
|
|
116
|
+
<p>
|
|
117
|
+
The REST API is still available at <a href="/docs">/docs</a>.
|
|
118
|
+
</p>
|
|
119
|
+
</body>
|
|
120
|
+
</html>
|
|
121
|
+
"""
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def create_app_from_env() -> FastAPI:
|
|
125
|
+
"""Build the app from the ``TASKPILOT_REGISTRY_DIR`` environment variable.
|
|
126
|
+
|
|
127
|
+
Used as a uvicorn import-string factory (``taskpilot.server.app:create_app_from_env``)
|
|
128
|
+
so adapters such as the CLI ``serve`` command can launch the server without importing
|
|
129
|
+
this module directly, keeping the cli/server adapter boundary intact (TP-4).
|
|
130
|
+
"""
|
|
131
|
+
registry_dir = os.environ.get(REGISTRY_DIR_ENV)
|
|
132
|
+
if not registry_dir:
|
|
133
|
+
raise RuntimeError(f"{REGISTRY_DIR_ENV} is not set; cannot build the API app")
|
|
134
|
+
return create_app(registry_dir=registry_dir)
|
|
File without changes
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|