@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,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)]