@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,160 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+
5
+ from fastapi import APIRouter, HTTPException, Request
6
+
7
+ from taskpilot.services.registry import RegistryEntry, list_projects as registry_list
8
+ from taskpilot.core.layout import WorkspacePaths
9
+ from taskpilot.server.schemas import (
10
+ ItemDetail,
11
+ ItemSummary,
12
+ ItemUpdateInput,
13
+ ProjectSummary,
14
+ ValidationFindingOut,
15
+ ValidationReportOut,
16
+ )
17
+ from taskpilot.core.validation import validate_workspace
18
+ from taskpilot.services import comment_service as comment_svc
19
+ from taskpilot.services import item_service as item_svc
20
+ from taskpilot.services.errors import ValidationFailed
21
+
22
+ router = APIRouter(tags=["projects"])
23
+
24
+
25
+ def _registry_entry(request: Request, project_id: str) -> RegistryEntry:
26
+ registry_dir: str = request.app.state.registry_dir
27
+ for entry in registry_list(Path(registry_dir)):
28
+ if entry.id == project_id:
29
+ return entry
30
+ raise HTTPException(status_code=404, detail=f"Project not found: {project_id}")
31
+
32
+
33
+ def _paths(entry: RegistryEntry) -> WorkspacePaths:
34
+ return WorkspacePaths.for_root(Path(entry.path))
35
+
36
+
37
+ def _require_item_in_project(entry: RegistryEntry, item_id: str) -> None:
38
+ """Reject item ids that do not belong to the path project's key (404).
39
+
40
+ Item ids are project-key prefixed (e.g. ``VP-1``). Scoping the lookup to the
41
+ path ``project_id`` keeps the route honest if a workspace ever holds items
42
+ from more than one project key.
43
+ """
44
+ if not item_id.startswith(f"{entry.key}-"):
45
+ raise HTTPException(
46
+ status_code=404, detail=f"No item found with id {item_id!r}"
47
+ )
48
+
49
+
50
+ def _item_summary(item) -> dict:
51
+ d = item.model_dump()
52
+ d["valid"] = True
53
+ return d
54
+
55
+
56
+ def _item_detail(item, ws: WorkspacePaths) -> dict:
57
+ d = item.model_dump()
58
+ try:
59
+ comments = comment_svc.list_comments(ws, item.id)
60
+ d["comments"] = [c.model_dump() for c in comments]
61
+ except ValidationFailed:
62
+ d["comments"] = []
63
+ d["valid"] = True
64
+ return d
65
+
66
+
67
+ @router.get("/health")
68
+ def health() -> dict[str, str]:
69
+ return {"status": "ok"}
70
+
71
+
72
+ @router.get("/projects", response_model=list[ProjectSummary])
73
+ def list_all_projects(request: Request) -> list[ProjectSummary]:
74
+ registry_dir: str = request.app.state.registry_dir
75
+ entries = registry_list(Path(registry_dir))
76
+ return [
77
+ ProjectSummary(
78
+ id=e.id,
79
+ key=e.key,
80
+ name=e.name,
81
+ active=e.active,
82
+ )
83
+ for e in entries
84
+ ]
85
+
86
+
87
+ @router.get(
88
+ "/projects/{project_id}/items",
89
+ response_model=list[ItemSummary],
90
+ )
91
+ def list_project_items(request: Request, project_id: str) -> list[ItemSummary]:
92
+ entry = _registry_entry(request, project_id)
93
+ ws = _paths(entry)
94
+ items = item_svc.list_items(ws, project=entry.key, include_deleted=False)
95
+ valid_summaries: list[ItemSummary] = [
96
+ ItemSummary(**_item_summary(i)) for i in items
97
+ ]
98
+
99
+ invalid_stubs = item_svc.list_invalid_item_stubs(ws, project=entry.key)
100
+ invalid_summaries: list[ItemSummary] = [
101
+ ItemSummary(
102
+ id=item_id,
103
+ title=f"[unparseable: {item_id}.yaml]",
104
+ type="unknown",
105
+ status="unknown",
106
+ priority="unknown",
107
+ valid=False,
108
+ findings=[
109
+ ValidationFindingOut(
110
+ severity="error",
111
+ code="parse_error",
112
+ path=rel_path,
113
+ message=error_msg,
114
+ )
115
+ ],
116
+ )
117
+ for item_id, rel_path, error_msg in invalid_stubs
118
+ ]
119
+
120
+ return valid_summaries + invalid_summaries
121
+
122
+
123
+ @router.get(
124
+ "/projects/{project_id}/validate",
125
+ response_model=ValidationReportOut,
126
+ )
127
+ def validate_project(request: Request, project_id: str) -> ValidationReportOut:
128
+ entry = _registry_entry(request, project_id)
129
+ ws = _paths(entry)
130
+ return ValidationReportOut(**validate_workspace(ws).to_dict())
131
+
132
+
133
+ @router.get(
134
+ "/projects/{project_id}/items/{item_id}",
135
+ response_model=ItemDetail,
136
+ )
137
+ def get_item_detail(request: Request, project_id: str, item_id: str) -> ItemDetail:
138
+ entry = _registry_entry(request, project_id)
139
+ _require_item_in_project(entry, item_id)
140
+ ws = _paths(entry)
141
+ item = item_svc.read_item(ws, item_id)
142
+ return ItemDetail(**_item_detail(item, ws))
143
+
144
+
145
+ @router.patch(
146
+ "/projects/{project_id}/items/{item_id}",
147
+ response_model=ItemDetail,
148
+ )
149
+ def patch_item(
150
+ request: Request,
151
+ project_id: str,
152
+ item_id: str,
153
+ body: ItemUpdateInput,
154
+ ) -> ItemDetail:
155
+ entry = _registry_entry(request, project_id)
156
+ _require_item_in_project(entry, item_id)
157
+ ws = _paths(entry)
158
+ fields = body.model_dump(exclude_unset=True)
159
+ item = item_svc.update_item(ws, item_id, **fields)
160
+ return ItemDetail(**_item_detail(item, ws))
@@ -0,0 +1,76 @@
1
+ from __future__ import annotations
2
+
3
+ from pydantic import BaseModel, ConfigDict
4
+
5
+ from taskpilot.core.models import Item
6
+
7
+
8
+ class ProjectSummary(BaseModel):
9
+ id: str
10
+ key: str
11
+ name: str
12
+ active: bool
13
+
14
+
15
+ class CommentOut(BaseModel):
16
+ schema_version: int
17
+ created_at: str
18
+ created_by: str | None = None
19
+ body: str
20
+
21
+
22
+ class ValidationFindingOut(BaseModel):
23
+ severity: str
24
+ code: str
25
+ path: str
26
+ field: str | None = None
27
+ item_id: str | None = None
28
+ message: str
29
+
30
+
31
+ class ValidationSummaryOut(BaseModel):
32
+ errors: int
33
+ warnings: int
34
+
35
+
36
+ class ValidationReportOut(BaseModel):
37
+ ok: bool
38
+ summary: ValidationSummaryOut
39
+ findings: list[ValidationFindingOut]
40
+
41
+
42
+ class ItemSummary(BaseModel):
43
+ id: str
44
+ title: str
45
+ type: str
46
+ status: str
47
+ priority: str
48
+ created_at: str | None = None
49
+ updated_at: str | None = None
50
+ parent_id: str | None = None
51
+ valid: bool = True
52
+ findings: list[ValidationFindingOut] = []
53
+
54
+
55
+ class ItemDetail(Item):
56
+ """Full item representation returned by detail and patch endpoints.
57
+
58
+ Extends the domain Item with API-only fields: comments (populated from the
59
+ comment service) and validity metadata (valid flag + findings). The config
60
+ override drops extra=allow so unknown YAML fields never leak into responses.
61
+ """
62
+
63
+ model_config = ConfigDict(
64
+ extra="ignore", use_enum_values=True, validate_default=True
65
+ )
66
+
67
+ comments: list[CommentOut] = []
68
+ valid: bool = True
69
+ findings: list[ValidationFindingOut] = []
70
+
71
+
72
+ class ItemUpdateInput(BaseModel):
73
+ title: str | None = None
74
+ description: str | None = None
75
+ priority: str | None = None
76
+ status: str | None = None
@@ -0,0 +1,8 @@
1
+ """TaskPilot domain/service layer (feature F002).
2
+
3
+ Stateless business-rule operations for projects, items, comments, and links.
4
+ This is the single source of business rules shared by the CLI, REST API, and
5
+ future MCP adapters. Services orchestrate the F001 storage primitives in
6
+ ``taskpilot.core`` and own domain rules (hierarchy, links, soft delete,
7
+ pre-write validation); they never import adapter or framework code.
8
+ """
@@ -0,0 +1,62 @@
1
+ """Comment domain service: add, list (task F002-T6, requirement F002-R6).
2
+
3
+ Comments are append-only Markdown files under ``comments/<item_id>/``; the
4
+ filename timestamp is the comment identity. Adding a comment never touches the
5
+ item YAML. This service wraps the F001 comment primitives with domain rules:
6
+ the target item must exist, and low-level parse/validation failures surface as
7
+ :class:`ValidationFailed`.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from pathlib import Path
13
+
14
+ from pydantic import ValidationError
15
+
16
+ from taskpilot.core import comments as core_comments
17
+ from taskpilot.core.comments import Comment, CommentParseError
18
+ from taskpilot.core.layout import WorkspacePaths
19
+ from taskpilot.core.timestamps import utc_now_iso
20
+ from taskpilot.services.errors import NotFound, ValidationFailed
21
+
22
+ __all__ = ["add_comment", "list_comments"]
23
+
24
+
25
+ def add_comment(
26
+ paths: WorkspacePaths,
27
+ item_id: str,
28
+ *,
29
+ body: str,
30
+ created_by: str,
31
+ now: str | None = None,
32
+ ) -> Path:
33
+ """Add a comment to ``item_id`` and return the written file path (F002-R6).
34
+
35
+ A comment's identity *is* its filename (spec ``0002``), so the path — not the
36
+ in-memory model — is the useful result for callers such as the CLI, matching
37
+ the core ``write_comment`` contract. The owning item must exist; a timestamped
38
+ ``.md`` file is written (same-second collisions get ``-N`` suffixes) and the
39
+ item YAML is left untouched. Raises :class:`NotFound` for an unknown item and
40
+ :class:`ValidationFailed` for invalid comment fields (e.g. empty author).
41
+ """
42
+ if not paths.item_file(item_id).is_file():
43
+ raise NotFound(f"No item found with id {item_id!r}")
44
+ try:
45
+ comment = Comment(
46
+ created_at=now or utc_now_iso(), created_by=created_by, body=body
47
+ )
48
+ except ValidationError as exc:
49
+ raise ValidationFailed(f"Cannot add comment to {item_id!r}: {exc}") from exc
50
+ return core_comments.write_comment(paths, item_id, comment)
51
+
52
+
53
+ def list_comments(paths: WorkspacePaths, item_id: str) -> list[Comment]:
54
+ """List ``item_id``'s comments ordered chronologically (F002-R6).
55
+
56
+ Returns an empty list when the item has no comments. Raises
57
+ :class:`ValidationFailed` if a stored comment file is malformed.
58
+ """
59
+ try:
60
+ return core_comments.list_comments(paths, item_id)
61
+ except (CommentParseError, ValidationError) as exc:
62
+ raise ValidationFailed(f"Invalid comment for {item_id!r}: {exc}") from exc
@@ -0,0 +1,26 @@
1
+ """Domain-service error types (feature F002).
2
+
3
+ Services raise these instead of leaking pydantic ``ValidationError``, ``OSError``,
4
+ or other low-level exceptions to adapters. Each error carries a human-readable
5
+ message so CLI/API adapters can render a descriptive response (F002-R8).
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ __all__ = ["ServiceError", "ValidationFailed", "NotFound", "ConflictError"]
11
+
12
+
13
+ class ServiceError(Exception):
14
+ """Base class for all domain-service errors."""
15
+
16
+
17
+ class ValidationFailed(ServiceError):
18
+ """Operation input failed validation before any file was written (F002-R8)."""
19
+
20
+
21
+ class NotFound(ServiceError):
22
+ """A requested project or item does not exist."""
23
+
24
+
25
+ class ConflictError(ServiceError):
26
+ """The operation conflicts with existing canonical state (e.g. create over an existing project)."""
@@ -0,0 +1,107 @@
1
+ """Type-based parent/child hierarchy rules (task F002-T3, requirement F002-R3).
2
+
3
+ Enforced whenever an item's ``parent_id`` is set (on create or update). Allowed
4
+ parent -> child types (see ``docs/architecture.md`` "Hierarchy"):
5
+
6
+ epic -> feature, task
7
+ feature -> task
8
+ task -> bug
9
+ bug -> (none)
10
+
11
+ Also rejects an item parenting itself and any change that would close a cycle.
12
+ The strictly-decreasing type table already makes cycles impossible for valid
13
+ types; the self/cycle guards are explicit so intent survives future table
14
+ changes and so error messages are precise.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ from taskpilot.core.layout import WorkspacePaths
20
+ from taskpilot.services.errors import NotFound, ValidationFailed
21
+
22
+ __all__ = ["ALLOWED_CHILD_TYPES", "validate_parent", "validate_can_parent_children"]
23
+
24
+ #: Allowed child types keyed by parent type.
25
+ ALLOWED_CHILD_TYPES: dict[str, set[str]] = {
26
+ "epic": {"feature", "task"},
27
+ "feature": {"task"},
28
+ "task": {"bug"},
29
+ "bug": set(),
30
+ }
31
+
32
+
33
+ def validate_parent(
34
+ paths: WorkspacePaths,
35
+ *,
36
+ child_id: str,
37
+ child_type: str,
38
+ parent_id: str | None,
39
+ ) -> None:
40
+ """Validate that ``child`` may have ``parent_id`` as its parent.
41
+
42
+ No-op when ``parent_id`` is ``None``. Raises :class:`ValidationFailed` when the
43
+ parent is the child itself, does not exist, is a type that cannot parent
44
+ ``child_type``, or when the assignment would close an ancestor cycle.
45
+ """
46
+ from taskpilot.services.item_service import read_item # local import avoids a cycle
47
+
48
+ if parent_id is None:
49
+ return
50
+ if parent_id == child_id:
51
+ raise ValidationFailed(f"Item {child_id!r} cannot be its own parent")
52
+
53
+ try:
54
+ parent = read_item(paths, parent_id)
55
+ except NotFound as exc:
56
+ raise ValidationFailed(
57
+ f"parent_id references unknown item {parent_id!r}"
58
+ ) from exc
59
+
60
+ allowed = ALLOWED_CHILD_TYPES.get(parent.type, set())
61
+ if child_type not in allowed:
62
+ raise ValidationFailed(
63
+ f"A {parent.type} cannot be the parent of a {child_type} "
64
+ f"(allowed children of {parent.type}: {sorted(allowed) or 'none'})"
65
+ )
66
+
67
+ # Walk the parent's ancestors; reaching child_id means this closes a cycle.
68
+ seen: set[str] = set()
69
+ cursor = parent
70
+ while cursor.parent_id:
71
+ if cursor.parent_id == child_id:
72
+ raise ValidationFailed(
73
+ f"Setting parent of {child_id!r} to {parent_id!r} would create a cycle"
74
+ )
75
+ if cursor.parent_id in seen:
76
+ break # pre-existing cycle elsewhere; stop rather than loop forever
77
+ seen.add(cursor.parent_id)
78
+ try:
79
+ cursor = read_item(paths, cursor.parent_id)
80
+ except (NotFound, ValidationFailed):
81
+ break
82
+
83
+
84
+ def validate_can_parent_children(
85
+ paths: WorkspacePaths,
86
+ *,
87
+ parent_id: str,
88
+ parent_type: str,
89
+ ) -> None:
90
+ """Ensure ``parent_type`` may still parent every item currently pointing at it.
91
+
92
+ Called when an item's ``type`` changes: a parent must not be retyped into a
93
+ type that cannot legally parent its existing children (which would persist an
94
+ item-graph the hierarchy table forbids). Raises :class:`ValidationFailed`
95
+ naming the first offending child.
96
+ """
97
+ from taskpilot.services.item_service import (
98
+ list_items,
99
+ ) # local import avoids a cycle
100
+
101
+ allowed = ALLOWED_CHILD_TYPES.get(parent_type, set())
102
+ for child in list_items(paths, include_deleted=True):
103
+ if child.parent_id == parent_id and child.type not in allowed:
104
+ raise ValidationFailed(
105
+ f"Cannot change {parent_id!r} to type {parent_type!r}: it parents "
106
+ f"{child.id!r} (a {child.type}), which a {parent_type} cannot parent"
107
+ )