@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.
Files changed (121) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +141 -0
  3. package/bin/taskpilot +454 -0
  4. package/package.json +30 -0
  5. package/requirements.lock +66 -0
  6. package/src/taskpilot/__init__.py +1 -0
  7. package/src/taskpilot/__pycache__/__init__.cpython-311.pyc +0 -0
  8. package/src/taskpilot/__pycache__/__init__.cpython-314.pyc +0 -0
  9. package/src/taskpilot/cli/__init__.py +6 -0
  10. package/src/taskpilot/cli/__pycache__/__init__.cpython-311.pyc +0 -0
  11. package/src/taskpilot/cli/__pycache__/__init__.cpython-314.pyc +0 -0
  12. package/src/taskpilot/cli/__pycache__/app.cpython-311.pyc +0 -0
  13. package/src/taskpilot/cli/__pycache__/app.cpython-314.pyc +0 -0
  14. package/src/taskpilot/cli/__pycache__/context.cpython-311.pyc +0 -0
  15. package/src/taskpilot/cli/__pycache__/context.cpython-314.pyc +0 -0
  16. package/src/taskpilot/cli/__pycache__/errors.cpython-311.pyc +0 -0
  17. package/src/taskpilot/cli/__pycache__/errors.cpython-314.pyc +0 -0
  18. package/src/taskpilot/cli/__pycache__/exit_codes.cpython-311.pyc +0 -0
  19. package/src/taskpilot/cli/__pycache__/exit_codes.cpython-314.pyc +0 -0
  20. package/src/taskpilot/cli/__pycache__/output.cpython-311.pyc +0 -0
  21. package/src/taskpilot/cli/__pycache__/output.cpython-314.pyc +0 -0
  22. package/src/taskpilot/cli/__pycache__/registry.cpython-314.pyc +0 -0
  23. package/src/taskpilot/cli/__pycache__/workspace.cpython-311.pyc +0 -0
  24. package/src/taskpilot/cli/__pycache__/workspace.cpython-314.pyc +0 -0
  25. package/src/taskpilot/cli/app.py +61 -0
  26. package/src/taskpilot/cli/commands/__init__.py +6 -0
  27. package/src/taskpilot/cli/commands/__pycache__/__init__.cpython-311.pyc +0 -0
  28. package/src/taskpilot/cli/commands/__pycache__/__init__.cpython-314.pyc +0 -0
  29. package/src/taskpilot/cli/commands/__pycache__/init.cpython-311.pyc +0 -0
  30. package/src/taskpilot/cli/commands/__pycache__/init.cpython-314.pyc +0 -0
  31. package/src/taskpilot/cli/commands/__pycache__/item.cpython-311.pyc +0 -0
  32. package/src/taskpilot/cli/commands/__pycache__/item.cpython-314.pyc +0 -0
  33. package/src/taskpilot/cli/commands/__pycache__/project.cpython-311.pyc +0 -0
  34. package/src/taskpilot/cli/commands/__pycache__/project.cpython-314.pyc +0 -0
  35. package/src/taskpilot/cli/commands/__pycache__/serve.cpython-311.pyc +0 -0
  36. package/src/taskpilot/cli/commands/__pycache__/serve.cpython-314.pyc +0 -0
  37. package/src/taskpilot/cli/commands/__pycache__/validate.cpython-311.pyc +0 -0
  38. package/src/taskpilot/cli/commands/__pycache__/validate.cpython-314.pyc +0 -0
  39. package/src/taskpilot/cli/commands/init.py +116 -0
  40. package/src/taskpilot/cli/commands/item.py +305 -0
  41. package/src/taskpilot/cli/commands/project.py +50 -0
  42. package/src/taskpilot/cli/commands/serve.py +78 -0
  43. package/src/taskpilot/cli/commands/validate.py +61 -0
  44. package/src/taskpilot/cli/context.py +36 -0
  45. package/src/taskpilot/cli/errors.py +53 -0
  46. package/src/taskpilot/cli/exit_codes.py +20 -0
  47. package/src/taskpilot/cli/output.py +77 -0
  48. package/src/taskpilot/cli/workspace.py +33 -0
  49. package/src/taskpilot/core/__init__.py +5 -0
  50. package/src/taskpilot/core/__pycache__/__init__.cpython-311.pyc +0 -0
  51. package/src/taskpilot/core/__pycache__/__init__.cpython-314.pyc +0 -0
  52. package/src/taskpilot/core/__pycache__/comments.cpython-311.pyc +0 -0
  53. package/src/taskpilot/core/__pycache__/comments.cpython-314.pyc +0 -0
  54. package/src/taskpilot/core/__pycache__/item_io.cpython-311.pyc +0 -0
  55. package/src/taskpilot/core/__pycache__/item_io.cpython-314.pyc +0 -0
  56. package/src/taskpilot/core/__pycache__/layout.cpython-311.pyc +0 -0
  57. package/src/taskpilot/core/__pycache__/layout.cpython-314.pyc +0 -0
  58. package/src/taskpilot/core/__pycache__/loader.cpython-314.pyc +0 -0
  59. package/src/taskpilot/core/__pycache__/models.cpython-311.pyc +0 -0
  60. package/src/taskpilot/core/__pycache__/models.cpython-314.pyc +0 -0
  61. package/src/taskpilot/core/__pycache__/project.cpython-311.pyc +0 -0
  62. package/src/taskpilot/core/__pycache__/project.cpython-314.pyc +0 -0
  63. package/src/taskpilot/core/__pycache__/timestamps.cpython-311.pyc +0 -0
  64. package/src/taskpilot/core/__pycache__/timestamps.cpython-314.pyc +0 -0
  65. package/src/taskpilot/core/__pycache__/validation.cpython-311.pyc +0 -0
  66. package/src/taskpilot/core/__pycache__/validation.cpython-314.pyc +0 -0
  67. package/src/taskpilot/core/__pycache__/yaml_io.cpython-311.pyc +0 -0
  68. package/src/taskpilot/core/__pycache__/yaml_io.cpython-314.pyc +0 -0
  69. package/src/taskpilot/core/comments.py +238 -0
  70. package/src/taskpilot/core/item_io.py +123 -0
  71. package/src/taskpilot/core/layout.py +137 -0
  72. package/src/taskpilot/core/loader.py +102 -0
  73. package/src/taskpilot/core/models.py +114 -0
  74. package/src/taskpilot/core/project.py +151 -0
  75. package/src/taskpilot/core/timestamps.py +54 -0
  76. package/src/taskpilot/core/validation.py +385 -0
  77. package/src/taskpilot/core/yaml_io.py +57 -0
  78. package/src/taskpilot/server/__init__.py +0 -0
  79. package/src/taskpilot/server/__pycache__/__init__.cpython-311.pyc +0 -0
  80. package/src/taskpilot/server/__pycache__/__init__.cpython-314.pyc +0 -0
  81. package/src/taskpilot/server/__pycache__/app.cpython-311.pyc +0 -0
  82. package/src/taskpilot/server/__pycache__/app.cpython-314.pyc +0 -0
  83. package/src/taskpilot/server/__pycache__/schemas.cpython-311.pyc +0 -0
  84. package/src/taskpilot/server/__pycache__/schemas.cpython-314.pyc +0 -0
  85. package/src/taskpilot/server/app.py +134 -0
  86. package/src/taskpilot/server/routes/__init__.py +0 -0
  87. package/src/taskpilot/server/routes/__pycache__/__init__.cpython-311.pyc +0 -0
  88. package/src/taskpilot/server/routes/__pycache__/__init__.cpython-314.pyc +0 -0
  89. package/src/taskpilot/server/routes/__pycache__/projects.cpython-311.pyc +0 -0
  90. package/src/taskpilot/server/routes/__pycache__/projects.cpython-314.pyc +0 -0
  91. package/src/taskpilot/server/routes/projects.py +160 -0
  92. package/src/taskpilot/server/schemas.py +76 -0
  93. package/src/taskpilot/services/__init__.py +8 -0
  94. package/src/taskpilot/services/__pycache__/__init__.cpython-311.pyc +0 -0
  95. package/src/taskpilot/services/__pycache__/__init__.cpython-314.pyc +0 -0
  96. package/src/taskpilot/services/__pycache__/comment_service.cpython-311.pyc +0 -0
  97. package/src/taskpilot/services/__pycache__/comment_service.cpython-314.pyc +0 -0
  98. package/src/taskpilot/services/__pycache__/errors.cpython-311.pyc +0 -0
  99. package/src/taskpilot/services/__pycache__/errors.cpython-314.pyc +0 -0
  100. package/src/taskpilot/services/__pycache__/hierarchy.cpython-311.pyc +0 -0
  101. package/src/taskpilot/services/__pycache__/hierarchy.cpython-314.pyc +0 -0
  102. package/src/taskpilot/services/__pycache__/item_service.cpython-311.pyc +0 -0
  103. package/src/taskpilot/services/__pycache__/item_service.cpython-314.pyc +0 -0
  104. package/src/taskpilot/services/__pycache__/link_service.cpython-311.pyc +0 -0
  105. package/src/taskpilot/services/__pycache__/link_service.cpython-314.pyc +0 -0
  106. package/src/taskpilot/services/__pycache__/operation_validation.cpython-311.pyc +0 -0
  107. package/src/taskpilot/services/__pycache__/operation_validation.cpython-314.pyc +0 -0
  108. package/src/taskpilot/services/__pycache__/project_service.cpython-311.pyc +0 -0
  109. package/src/taskpilot/services/__pycache__/project_service.cpython-314.pyc +0 -0
  110. package/src/taskpilot/services/__pycache__/registry.cpython-311.pyc +0 -0
  111. package/src/taskpilot/services/__pycache__/registry.cpython-314.pyc +0 -0
  112. package/src/taskpilot/services/__pycache__/reverse_links.cpython-314.pyc +0 -0
  113. package/src/taskpilot/services/comment_service.py +62 -0
  114. package/src/taskpilot/services/errors.py +26 -0
  115. package/src/taskpilot/services/hierarchy.py +107 -0
  116. package/src/taskpilot/services/item_service.py +264 -0
  117. package/src/taskpilot/services/link_service.py +97 -0
  118. package/src/taskpilot/services/operation_validation.py +52 -0
  119. package/src/taskpilot/services/project_service.py +111 -0
  120. package/src/taskpilot/services/registry.py +194 -0
  121. 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
@@ -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