@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,264 @@
|
|
|
1
|
+
"""Item domain service: create, read, update, list (task F002-T2, requirement F002-R2).
|
|
2
|
+
|
|
3
|
+
Stateless operations over canonical ``items/<id>.yaml`` files. IDs are allocated
|
|
4
|
+
as ``<PROJECT_KEY>-<n>`` where ``n`` is one past the highest existing numeric
|
|
5
|
+
suffix (gaps from deleted items are not reused). Model construction validates
|
|
6
|
+
enums/timestamps, so invalid input is rejected before any file is written
|
|
7
|
+
(F002-R8); richer pre-write validation is consolidated in F002-T8.
|
|
8
|
+
|
|
9
|
+
Hierarchy rules for ``parent_id`` (F002-T3) and links (F002-T4/T5) are layered on
|
|
10
|
+
in their own tasks; this service owns plain field create/read/update/list.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from pydantic import ValidationError
|
|
16
|
+
|
|
17
|
+
from taskpilot.core.item_io import ItemParseError, parse_item_file, write_item
|
|
18
|
+
from taskpilot.core.layout import WorkspacePaths
|
|
19
|
+
from taskpilot.core.models import Item
|
|
20
|
+
from taskpilot.core.timestamps import utc_now_iso
|
|
21
|
+
from taskpilot.services.errors import NotFound, ValidationFailed
|
|
22
|
+
from taskpilot.services.hierarchy import validate_can_parent_children, validate_parent
|
|
23
|
+
from taskpilot.services.operation_validation import build_validated_item
|
|
24
|
+
from taskpilot.services.project_service import read_project
|
|
25
|
+
|
|
26
|
+
__all__ = [
|
|
27
|
+
"create_item",
|
|
28
|
+
"read_item",
|
|
29
|
+
"update_item",
|
|
30
|
+
"delete_item",
|
|
31
|
+
"list_items",
|
|
32
|
+
"list_invalid_item_stubs",
|
|
33
|
+
"next_id",
|
|
34
|
+
]
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _numeric_suffix(item_id: str, key: str) -> int | None:
|
|
38
|
+
"""Return the numeric suffix of ``item_id`` for project ``key``, else ``None``."""
|
|
39
|
+
prefix = f"{key}-"
|
|
40
|
+
if not item_id.startswith(prefix):
|
|
41
|
+
return None
|
|
42
|
+
rest = item_id[len(prefix) :]
|
|
43
|
+
return int(rest) if rest.isdigit() else None
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def next_id(paths: WorkspacePaths, key: str) -> str:
|
|
47
|
+
"""Allocate the next item id for project ``key`` as ``<key>-<max+1>``.
|
|
48
|
+
|
|
49
|
+
Scans existing ``items/*.yaml`` filenames for the highest numeric suffix.
|
|
50
|
+
Gaps left by deleted items are not reused (see ``tasks.md`` notes).
|
|
51
|
+
"""
|
|
52
|
+
highest = 0
|
|
53
|
+
if paths.items_dir.is_dir():
|
|
54
|
+
for file in paths.items_dir.glob("*.yaml"):
|
|
55
|
+
suffix = _numeric_suffix(file.stem, key)
|
|
56
|
+
if suffix is not None and suffix > highest:
|
|
57
|
+
highest = suffix
|
|
58
|
+
return f"{key}-{highest + 1}"
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def create_item(
|
|
62
|
+
paths: WorkspacePaths,
|
|
63
|
+
*,
|
|
64
|
+
title: str,
|
|
65
|
+
type: str,
|
|
66
|
+
priority: str = "normal",
|
|
67
|
+
status: str = "backlog",
|
|
68
|
+
description: str | None = None,
|
|
69
|
+
parent_id: str | None = None,
|
|
70
|
+
tags: list[str] | None = None,
|
|
71
|
+
created_by: str | None = None,
|
|
72
|
+
now: str | None = None,
|
|
73
|
+
) -> Item:
|
|
74
|
+
"""Create a new item with an allocated id and return it (F002-R2).
|
|
75
|
+
|
|
76
|
+
Requires an initialized project (its key prefixes the id). ``created_at`` and
|
|
77
|
+
``updated_at`` are set to ``now`` (current UTC by default). Raises
|
|
78
|
+
:class:`NotFound` when no project exists and :class:`ValidationFailed` for
|
|
79
|
+
invalid field values — no file is written in the latter case.
|
|
80
|
+
"""
|
|
81
|
+
project = read_project(paths) # raises NotFound when absent
|
|
82
|
+
timestamp = now or utc_now_iso()
|
|
83
|
+
item_id = next_id(paths, project.key)
|
|
84
|
+
item = build_validated_item(
|
|
85
|
+
{
|
|
86
|
+
"id": item_id,
|
|
87
|
+
"title": title,
|
|
88
|
+
"type": type,
|
|
89
|
+
"priority": priority,
|
|
90
|
+
"status": status,
|
|
91
|
+
"created_at": timestamp,
|
|
92
|
+
"updated_at": timestamp,
|
|
93
|
+
"description": description,
|
|
94
|
+
"parent_id": parent_id,
|
|
95
|
+
"tags": tags,
|
|
96
|
+
"created_by": created_by,
|
|
97
|
+
}
|
|
98
|
+
)
|
|
99
|
+
validate_parent(
|
|
100
|
+
paths, child_id=item.id, child_type=item.type, parent_id=item.parent_id
|
|
101
|
+
)
|
|
102
|
+
write_item(paths, item)
|
|
103
|
+
return item
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def read_item(paths: WorkspacePaths, item_id: str) -> Item:
|
|
107
|
+
"""Read an item by id (F002-R2).
|
|
108
|
+
|
|
109
|
+
Raises :class:`NotFound` when the file is absent and :class:`ValidationFailed`
|
|
110
|
+
when it exists but cannot be parsed/validated.
|
|
111
|
+
"""
|
|
112
|
+
target = paths.item_file(item_id)
|
|
113
|
+
if not target.is_file():
|
|
114
|
+
raise NotFound(f"No item found with id {item_id!r}")
|
|
115
|
+
try:
|
|
116
|
+
return parse_item_file(target)
|
|
117
|
+
except (ItemParseError, ValidationError, UnicodeDecodeError, OSError) as exc:
|
|
118
|
+
raise ValidationFailed(f"Invalid item file for {item_id!r}: {exc}") from exc
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def update_item(
|
|
122
|
+
paths: WorkspacePaths,
|
|
123
|
+
item_id: str,
|
|
124
|
+
*,
|
|
125
|
+
now: str | None = None,
|
|
126
|
+
**fields: object,
|
|
127
|
+
) -> Item:
|
|
128
|
+
"""Update ``item_id``'s fields and refresh ``updated_at`` (F002-R2).
|
|
129
|
+
|
|
130
|
+
Reads the current item, applies ``fields``, re-validates, and persists.
|
|
131
|
+
``id`` and ``created_at`` cannot be changed. Raises :class:`NotFound` when the
|
|
132
|
+
item is absent and :class:`ValidationFailed` for invalid values (the file is
|
|
133
|
+
left unchanged in that case).
|
|
134
|
+
"""
|
|
135
|
+
current = read_item(paths, item_id)
|
|
136
|
+
|
|
137
|
+
if not fields:
|
|
138
|
+
# No-op update: do not rewrite the file or bump updated_at (avoids a
|
|
139
|
+
# spurious Git diff on an empty change).
|
|
140
|
+
return current
|
|
141
|
+
|
|
142
|
+
for immutable in ("id", "created_at"):
|
|
143
|
+
if immutable in fields:
|
|
144
|
+
raise ValidationFailed(f"Field {immutable!r} cannot be updated")
|
|
145
|
+
|
|
146
|
+
data = current.model_dump()
|
|
147
|
+
data.update(fields)
|
|
148
|
+
data["updated_at"] = now or utc_now_iso()
|
|
149
|
+
updated = build_validated_item(data)
|
|
150
|
+
|
|
151
|
+
validate_parent(
|
|
152
|
+
paths, child_id=updated.id, child_type=updated.type, parent_id=updated.parent_id
|
|
153
|
+
)
|
|
154
|
+
if updated.type != current.type:
|
|
155
|
+
validate_can_parent_children(
|
|
156
|
+
paths, parent_id=updated.id, parent_type=updated.type
|
|
157
|
+
)
|
|
158
|
+
_validate_links(paths, updated)
|
|
159
|
+
write_item(paths, updated)
|
|
160
|
+
return updated
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def list_invalid_item_stubs(
|
|
164
|
+
paths: WorkspacePaths,
|
|
165
|
+
*,
|
|
166
|
+
project: str | None = None,
|
|
167
|
+
) -> list[tuple[str, str, str]]:
|
|
168
|
+
"""Return (item_id, relative_path, error_message) for unparseable item files.
|
|
169
|
+
|
|
170
|
+
Scans the same ``items/*.yaml`` glob used by :func:`list_items` but collects
|
|
171
|
+
files that raise a parse or validation error instead of silently skipping them.
|
|
172
|
+
The caller is responsible for surfacing these as invalid-item representations
|
|
173
|
+
(e.g. API findings) to honor the product invariant that invalid files remain
|
|
174
|
+
visible and actionable.
|
|
175
|
+
"""
|
|
176
|
+
result: list[tuple[str, str, str]] = []
|
|
177
|
+
if not paths.items_dir.is_dir():
|
|
178
|
+
return result
|
|
179
|
+
for file in paths.items_dir.glob("*.yaml"):
|
|
180
|
+
if not file.is_file():
|
|
181
|
+
continue
|
|
182
|
+
if project is not None and not file.stem.startswith(f"{project}-"):
|
|
183
|
+
continue
|
|
184
|
+
try:
|
|
185
|
+
parse_item_file(file)
|
|
186
|
+
except (ItemParseError, ValidationError, UnicodeDecodeError, OSError) as exc:
|
|
187
|
+
rel = paths.relative_posix(file)
|
|
188
|
+
result.append((file.stem, rel, str(exc)))
|
|
189
|
+
return result
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _validate_links(paths: WorkspacePaths, item: Item) -> None:
|
|
193
|
+
"""Reject self-links or links to unknown items on the canonical write path.
|
|
194
|
+
|
|
195
|
+
``add_link``/``remove_link`` validate before delegating here, but ``links``
|
|
196
|
+
can also reach :func:`update_item` directly, so the shared write path must
|
|
197
|
+
enforce the same rules (F002-R4/R8) — no dangling or self links may persist.
|
|
198
|
+
"""
|
|
199
|
+
if item.links is None:
|
|
200
|
+
return
|
|
201
|
+
for link_type in ("blocks", "relates_to"):
|
|
202
|
+
for target in getattr(item.links, link_type):
|
|
203
|
+
if target == item.id:
|
|
204
|
+
raise ValidationFailed(f"Item {item.id!r} cannot link to itself")
|
|
205
|
+
if not paths.item_file(target).is_file():
|
|
206
|
+
raise ValidationFailed(
|
|
207
|
+
f"links.{link_type} references unknown item {target!r}"
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def delete_item(paths: WorkspacePaths, item_id: str, *, now: str | None = None) -> Item:
|
|
212
|
+
"""Soft-delete an item by setting ``status: deleted`` (F002-R7).
|
|
213
|
+
|
|
214
|
+
The file is preserved on disk and stays findable by direct lookup; default
|
|
215
|
+
listings exclude it. Idempotent: deleting an already-deleted item returns it
|
|
216
|
+
unchanged without rewriting the file. Raises :class:`NotFound` when absent.
|
|
217
|
+
"""
|
|
218
|
+
current = read_item(paths, item_id) # raises NotFound when absent
|
|
219
|
+
if current.status == "deleted":
|
|
220
|
+
return current # idempotent: no rewrite, updated_at preserved
|
|
221
|
+
return update_item(paths, item_id, now=now, status="deleted")
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def _numeric_id_key(item: Item) -> tuple[int, str]:
|
|
225
|
+
_, _, suffix = item.id.rpartition("-")
|
|
226
|
+
return (int(suffix), item.id) if suffix.isdigit() else (-1, item.id)
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def list_items(
|
|
230
|
+
paths: WorkspacePaths,
|
|
231
|
+
*,
|
|
232
|
+
project: str | None = None,
|
|
233
|
+
status: str | None = None,
|
|
234
|
+
type: str | None = None,
|
|
235
|
+
include_deleted: bool = False,
|
|
236
|
+
) -> list[Item]:
|
|
237
|
+
"""List items, filtered and sorted by numeric id (F002-R2).
|
|
238
|
+
|
|
239
|
+
Filters: ``project`` (item-id key prefix), ``status``, ``type``. ``deleted``
|
|
240
|
+
items are excluded unless ``include_deleted`` is true. Structurally invalid
|
|
241
|
+
files are skipped here; surfacing them is the validation layer's job (F001-T7).
|
|
242
|
+
"""
|
|
243
|
+
items: list[Item] = []
|
|
244
|
+
if paths.items_dir.is_dir():
|
|
245
|
+
for file in paths.items_dir.glob("*.yaml"):
|
|
246
|
+
if not file.is_file():
|
|
247
|
+
continue
|
|
248
|
+
try:
|
|
249
|
+
items.append(parse_item_file(file))
|
|
250
|
+
except (ItemParseError, ValidationError, UnicodeDecodeError, OSError):
|
|
251
|
+
continue
|
|
252
|
+
|
|
253
|
+
if project is not None:
|
|
254
|
+
prefix = f"{project}-"
|
|
255
|
+
items = [i for i in items if i.id.startswith(prefix)]
|
|
256
|
+
if status is not None:
|
|
257
|
+
items = [i for i in items if i.status == status]
|
|
258
|
+
if type is not None:
|
|
259
|
+
items = [i for i in items if i.type == type]
|
|
260
|
+
if not include_deleted:
|
|
261
|
+
items = [i for i in items if i.status != "deleted"]
|
|
262
|
+
|
|
263
|
+
items.sort(key=_numeric_id_key)
|
|
264
|
+
return items
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""Link domain service: add, remove, query (task F002-T4, requirement F002-R4).
|
|
2
|
+
|
|
3
|
+
Non-parent graph links (``blocks``, ``relates_to``) are stored as a map on the
|
|
4
|
+
*source* item. Adding or removing a link rewrites only the source file (the
|
|
5
|
+
target is never touched); reverse relationships are derived elsewhere (F002-T5),
|
|
6
|
+
never stored. Link targets must reference an existing item.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from taskpilot.core.layout import WorkspacePaths
|
|
12
|
+
from taskpilot.core.models import Item
|
|
13
|
+
from taskpilot.services import item_service
|
|
14
|
+
from taskpilot.services.errors import ValidationFailed
|
|
15
|
+
|
|
16
|
+
__all__ = ["LINK_TYPES", "add_link", "remove_link", "query_links"]
|
|
17
|
+
|
|
18
|
+
#: Link types supported through Alpha.
|
|
19
|
+
LINK_TYPES = ("blocks", "relates_to")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _require_link_type(link_type: str) -> None:
|
|
23
|
+
if link_type not in LINK_TYPES:
|
|
24
|
+
raise ValidationFailed(
|
|
25
|
+
f"Unknown link type {link_type!r}; expected one of {list(LINK_TYPES)}"
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _links_as_dict(item: Item) -> dict[str, list[str]]:
|
|
30
|
+
if item.links is None:
|
|
31
|
+
return {"blocks": [], "relates_to": []}
|
|
32
|
+
return {
|
|
33
|
+
"blocks": list(item.links.blocks),
|
|
34
|
+
"relates_to": list(item.links.relates_to),
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def add_link(
|
|
39
|
+
paths: WorkspacePaths,
|
|
40
|
+
source_id: str,
|
|
41
|
+
link_type: str,
|
|
42
|
+
target_id: str,
|
|
43
|
+
*,
|
|
44
|
+
now: str | None = None,
|
|
45
|
+
) -> Item:
|
|
46
|
+
"""Add ``link_type`` from ``source_id`` to ``target_id`` (F002-R4).
|
|
47
|
+
|
|
48
|
+
Validates the link type, rejects self-links, and requires the target to
|
|
49
|
+
exist. Idempotent: adding an existing link rewrites nothing. Raises
|
|
50
|
+
:class:`NotFound` when the source is missing and :class:`ValidationFailed`
|
|
51
|
+
for an unknown link type, self-link, or unknown target.
|
|
52
|
+
"""
|
|
53
|
+
_require_link_type(link_type)
|
|
54
|
+
source = item_service.read_item(paths, source_id) # NotFound propagates
|
|
55
|
+
if source_id == target_id:
|
|
56
|
+
raise ValidationFailed(f"Item {source_id!r} cannot link to itself")
|
|
57
|
+
if not paths.item_file(target_id).is_file():
|
|
58
|
+
raise ValidationFailed(f"Link target references unknown item {target_id!r}")
|
|
59
|
+
|
|
60
|
+
links = _links_as_dict(source)
|
|
61
|
+
if target_id in links[link_type]:
|
|
62
|
+
return source # idempotent: already linked
|
|
63
|
+
links[link_type].append(target_id)
|
|
64
|
+
return item_service.update_item(paths, source_id, now=now, links=links)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def remove_link(
|
|
68
|
+
paths: WorkspacePaths,
|
|
69
|
+
source_id: str,
|
|
70
|
+
link_type: str,
|
|
71
|
+
target_id: str,
|
|
72
|
+
*,
|
|
73
|
+
now: str | None = None,
|
|
74
|
+
) -> Item:
|
|
75
|
+
"""Remove ``link_type`` from ``source_id`` to ``target_id`` (F002-R4).
|
|
76
|
+
|
|
77
|
+
Idempotent: removing an absent link rewrites nothing and returns the source
|
|
78
|
+
unchanged. Raises :class:`NotFound` when the source is missing and
|
|
79
|
+
:class:`ValidationFailed` for an unknown link type.
|
|
80
|
+
"""
|
|
81
|
+
_require_link_type(link_type)
|
|
82
|
+
source = item_service.read_item(paths, source_id) # NotFound propagates
|
|
83
|
+
|
|
84
|
+
links = _links_as_dict(source)
|
|
85
|
+
if target_id not in links[link_type]:
|
|
86
|
+
return source # idempotent: nothing to remove
|
|
87
|
+
links[link_type] = [t for t in links[link_type] if t != target_id]
|
|
88
|
+
return item_service.update_item(paths, source_id, now=now, links=links)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def query_links(paths: WorkspacePaths, item_id: str) -> dict[str, list[str]]:
|
|
92
|
+
"""Return ``item_id``'s stored (forward) links as ``{type: [target_ids]}`` (F002-R4).
|
|
93
|
+
|
|
94
|
+
Reverse links (``blocked_by``, ``related_to``) are derived separately (F002-T5).
|
|
95
|
+
Raises :class:`NotFound` when the item is missing.
|
|
96
|
+
"""
|
|
97
|
+
return _links_as_dict(item_service.read_item(paths, item_id))
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""Centralized pre-write operation validation (task F002-T8, requirement F002-R8).
|
|
2
|
+
|
|
3
|
+
Services validate operation input here *before* touching the filesystem, so an
|
|
4
|
+
invalid operation is rejected with a descriptive error and never leaves a partial
|
|
5
|
+
or invalid file on disk. This is the single place that turns low-level pydantic
|
|
6
|
+
``ValidationError`` detail into human-readable, field-level messages for the
|
|
7
|
+
domain layer; adapters render the resulting :class:`ValidationFailed` message.
|
|
8
|
+
|
|
9
|
+
Cross-file rules (reference existence, hierarchy, link targets) live in their own
|
|
10
|
+
modules (validation layer, :mod:`hierarchy`, :mod:`link_service`); this module
|
|
11
|
+
owns single-item field/shape validation that must pass before a write.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from pydantic import ValidationError
|
|
17
|
+
|
|
18
|
+
from taskpilot.core.models import Item
|
|
19
|
+
from taskpilot.services.errors import ValidationFailed
|
|
20
|
+
|
|
21
|
+
__all__ = ["describe_validation_error", "build_validated_item"]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _describe_error(err: dict) -> str:
|
|
25
|
+
"""Render one pydantic error as a human-readable field-level message."""
|
|
26
|
+
field = ".".join(str(p) for p in err["loc"])
|
|
27
|
+
etype = err["type"]
|
|
28
|
+
if etype == "missing":
|
|
29
|
+
return f"Missing required field: {field}"
|
|
30
|
+
if etype == "enum":
|
|
31
|
+
expected = err.get("ctx", {}).get("expected", "")
|
|
32
|
+
return f"Invalid value for {field}: expected one of {expected}"
|
|
33
|
+
return f"{field}: {err['msg']}"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def describe_validation_error(exc: ValidationError) -> str:
|
|
37
|
+
"""Join all field-level problems in ``exc`` into one descriptive message."""
|
|
38
|
+
return "; ".join(_describe_error(err) for err in exc.errors())
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def build_validated_item(data: dict) -> Item:
|
|
42
|
+
"""Validate item ``data`` into an :class:`Item`, or raise :class:`ValidationFailed`.
|
|
43
|
+
|
|
44
|
+
The raised error carries a descriptive, field-level message so callers never
|
|
45
|
+
need to write a file just to discover the input was invalid (F002-R8).
|
|
46
|
+
"""
|
|
47
|
+
try:
|
|
48
|
+
return Item.model_validate(data)
|
|
49
|
+
except ValidationError as exc:
|
|
50
|
+
raise ValidationFailed(
|
|
51
|
+
f"Invalid item: {describe_validation_error(exc)}"
|
|
52
|
+
) from exc
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"""Project domain service: create, read, list (task F002-T1, requirement F002-R1).
|
|
2
|
+
|
|
3
|
+
Orchestrates the F001 storage primitives in :mod:`taskpilot.core.project`.
|
|
4
|
+
Business rules owned here:
|
|
5
|
+
|
|
6
|
+
- creating a project over an already-initialized workspace is a conflict;
|
|
7
|
+
- the project ``id`` is derived from the display name when not supplied;
|
|
8
|
+
- ``read``/``list`` translate "missing or invalid" into domain errors rather
|
|
9
|
+
than leaking storage exceptions.
|
|
10
|
+
|
|
11
|
+
One repository holds exactly one TaskPilot project (see ``docs/architecture.md``),
|
|
12
|
+
so :func:`list_projects` returns the single registered project or an empty list.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import re
|
|
18
|
+
|
|
19
|
+
import yaml
|
|
20
|
+
from pydantic import ValidationError
|
|
21
|
+
|
|
22
|
+
from taskpilot.core.layout import WorkspacePaths
|
|
23
|
+
from taskpilot.core.project import (
|
|
24
|
+
ProjectMeta,
|
|
25
|
+
init_workspace,
|
|
26
|
+
read_project as _read_project_file,
|
|
27
|
+
)
|
|
28
|
+
from taskpilot.services.errors import ConflictError, NotFound, ValidationFailed
|
|
29
|
+
|
|
30
|
+
__all__ = ["create_project", "read_project", "list_projects", "slugify"]
|
|
31
|
+
|
|
32
|
+
_SLUG_RE = re.compile(r"[^a-z0-9]+")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def slugify(value: str) -> str:
|
|
36
|
+
"""Derive a filesystem/identity-safe slug from ``value``.
|
|
37
|
+
|
|
38
|
+
Lowercases, replaces runs of non-alphanumeric characters with a single ``-``,
|
|
39
|
+
and trims leading/trailing dashes. ``"Voice Pilot"`` -> ``"voice-pilot"``.
|
|
40
|
+
"""
|
|
41
|
+
return _SLUG_RE.sub("-", value.lower()).strip("-")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def create_project(
|
|
45
|
+
paths: WorkspacePaths,
|
|
46
|
+
*,
|
|
47
|
+
key: str,
|
|
48
|
+
name: str,
|
|
49
|
+
project_id: str | None = None,
|
|
50
|
+
now: str | None = None,
|
|
51
|
+
) -> ProjectMeta:
|
|
52
|
+
"""Create a new project and return its model (F002-R1).
|
|
53
|
+
|
|
54
|
+
Writes ``project.yaml`` with the given ``key`` and ``name``. ``project_id``
|
|
55
|
+
defaults to a slug of ``name``. Raises :class:`ValidationFailed` for empty
|
|
56
|
+
identity fields and :class:`ConflictError` when a project already exists.
|
|
57
|
+
"""
|
|
58
|
+
if not key or not key.strip():
|
|
59
|
+
raise ValidationFailed("Project key must not be empty")
|
|
60
|
+
if not name or not name.strip():
|
|
61
|
+
raise ValidationFailed("Project name must not be empty")
|
|
62
|
+
|
|
63
|
+
if paths.project_file.exists():
|
|
64
|
+
raise ConflictError(
|
|
65
|
+
f"A project already exists at {paths.relative_posix(paths.project_file)}"
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
resolved_id = project_id if project_id else slugify(name)
|
|
69
|
+
if not resolved_id:
|
|
70
|
+
raise ValidationFailed(
|
|
71
|
+
f"Cannot derive a project id from name {name!r}; supply project_id"
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
result = init_workspace(
|
|
75
|
+
paths.root, project_id=resolved_id, key=key, name=name, now=now
|
|
76
|
+
)
|
|
77
|
+
if not result.created:
|
|
78
|
+
# Lost a race: another writer initialized the project between our check and init.
|
|
79
|
+
raise ConflictError(
|
|
80
|
+
f"A project already exists at {paths.relative_posix(paths.project_file)}"
|
|
81
|
+
)
|
|
82
|
+
return read_project(paths)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def read_project(paths: WorkspacePaths) -> ProjectMeta:
|
|
86
|
+
"""Read project metadata (F002-R1).
|
|
87
|
+
|
|
88
|
+
Raises :class:`NotFound` when ``project.yaml`` is absent and
|
|
89
|
+
:class:`ValidationFailed` when it exists but is unreadable/invalid.
|
|
90
|
+
"""
|
|
91
|
+
if not paths.project_file.exists():
|
|
92
|
+
raise NotFound(
|
|
93
|
+
f"No project found at {paths.relative_posix(paths.project_file)}"
|
|
94
|
+
)
|
|
95
|
+
try:
|
|
96
|
+
return _read_project_file(paths)
|
|
97
|
+
except (ValidationError, yaml.YAMLError, UnicodeDecodeError, OSError) as exc:
|
|
98
|
+
raise ValidationFailed(
|
|
99
|
+
f"Invalid project file {paths.relative_posix(paths.project_file)}: {exc}"
|
|
100
|
+
) from exc
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def list_projects(paths: WorkspacePaths) -> list[ProjectMeta]:
|
|
104
|
+
"""List registered projects (F002-R1).
|
|
105
|
+
|
|
106
|
+
Returns the single registered project, or an empty list when the workspace
|
|
107
|
+
has no project. (One repository holds one project.)
|
|
108
|
+
"""
|
|
109
|
+
if not paths.project_file.exists():
|
|
110
|
+
return []
|
|
111
|
+
return [read_project(paths)]
|