taskledger 0.1.0__py3-none-any.whl
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.
- taskledger/__init__.py +5 -0
- taskledger/__main__.py +6 -0
- taskledger/_version.py +24 -0
- taskledger/api/__init__.py +13 -0
- taskledger/api/handoff.py +247 -0
- taskledger/api/introductions.py +9 -0
- taskledger/api/locks.py +4 -0
- taskledger/api/plans.py +31 -0
- taskledger/api/project.py +185 -0
- taskledger/api/questions.py +19 -0
- taskledger/api/search.py +87 -0
- taskledger/api/task_runs.py +38 -0
- taskledger/api/tasks.py +61 -0
- taskledger/cli.py +600 -0
- taskledger/cli_actor.py +196 -0
- taskledger/cli_common.py +617 -0
- taskledger/cli_implement.py +409 -0
- taskledger/cli_migrate.py +328 -0
- taskledger/cli_misc.py +984 -0
- taskledger/cli_plan.py +478 -0
- taskledger/cli_question.py +350 -0
- taskledger/cli_task.py +257 -0
- taskledger/cli_validate.py +285 -0
- taskledger/command_inventory.py +125 -0
- taskledger/domain/__init__.py +2 -0
- taskledger/domain/models.py +1697 -0
- taskledger/domain/policies.py +542 -0
- taskledger/domain/states.py +320 -0
- taskledger/errors.py +165 -0
- taskledger/exchange.py +343 -0
- taskledger/ids.py +19 -0
- taskledger/py.typed +0 -0
- taskledger/search.py +349 -0
- taskledger/services/__init__.py +1 -0
- taskledger/services/actors.py +245 -0
- taskledger/services/dashboard.py +306 -0
- taskledger/services/doctor.py +435 -0
- taskledger/services/handoff.py +1029 -0
- taskledger/services/handoff_lifecycle.py +154 -0
- taskledger/services/navigation.py +930 -0
- taskledger/services/phase5_lock_transfer.py +96 -0
- taskledger/services/plan_lint.py +397 -0
- taskledger/services/serve_read_model.py +852 -0
- taskledger/services/tasks.py +4224 -0
- taskledger/services/validation.py +221 -0
- taskledger/services/web_dashboard.py +1742 -0
- taskledger/storage/__init__.py +39 -0
- taskledger/storage/atomic.py +57 -0
- taskledger/storage/common.py +90 -0
- taskledger/storage/events.py +98 -0
- taskledger/storage/frontmatter.py +57 -0
- taskledger/storage/indexes.py +42 -0
- taskledger/storage/init.py +187 -0
- taskledger/storage/locks.py +83 -0
- taskledger/storage/meta.py +103 -0
- taskledger/storage/migrations.py +207 -0
- taskledger/storage/paths.py +166 -0
- taskledger/storage/project_config.py +393 -0
- taskledger/storage/repos.py +256 -0
- taskledger/storage/task_store.py +836 -0
- taskledger/timeutils.py +7 -0
- taskledger-0.1.0.dist-info/METADATA +411 -0
- taskledger-0.1.0.dist-info/RECORD +67 -0
- taskledger-0.1.0.dist-info/WHEEL +5 -0
- taskledger-0.1.0.dist-info/entry_points.txt +2 -0
- taskledger-0.1.0.dist-info/licenses/LICENSE +201 -0
- taskledger-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"""Read and write .taskledger/storage.yaml workspace metadata."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import yaml
|
|
9
|
+
|
|
10
|
+
from taskledger.domain.states import (
|
|
11
|
+
TASKLEDGER_RECORD_SCHEMA_VERSION,
|
|
12
|
+
TASKLEDGER_STORAGE_LAYOUT_VERSION,
|
|
13
|
+
)
|
|
14
|
+
from taskledger.errors import LaunchError
|
|
15
|
+
from taskledger.storage.atomic import atomic_write_text
|
|
16
|
+
from taskledger.storage.paths import resolve_taskledger_root
|
|
17
|
+
from taskledger.timeutils import utc_now_iso
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(slots=True, frozen=True)
|
|
21
|
+
class StorageMeta:
|
|
22
|
+
storage_layout_version: int = TASKLEDGER_STORAGE_LAYOUT_VERSION
|
|
23
|
+
record_schema_version: int = TASKLEDGER_RECORD_SCHEMA_VERSION
|
|
24
|
+
created_with_taskledger: str = ""
|
|
25
|
+
created_at: str = field(default_factory=utc_now_iso)
|
|
26
|
+
last_migrated_with_taskledger: str | None = None
|
|
27
|
+
last_migrated_at: str | None = None
|
|
28
|
+
|
|
29
|
+
def to_dict(self) -> dict[str, object]:
|
|
30
|
+
return {
|
|
31
|
+
"storage_layout_version": self.storage_layout_version,
|
|
32
|
+
"record_schema_version": self.record_schema_version,
|
|
33
|
+
"created_with_taskledger": self.created_with_taskledger,
|
|
34
|
+
"created_at": self.created_at,
|
|
35
|
+
"last_migrated_with_taskledger": self.last_migrated_with_taskledger,
|
|
36
|
+
"last_migrated_at": self.last_migrated_at,
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _storage_yaml_path(workspace_root: Path) -> Path:
|
|
41
|
+
return resolve_taskledger_root(workspace_root) / "storage.yaml"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def read_storage_meta(workspace_root: Path) -> StorageMeta | None:
|
|
45
|
+
path = _storage_yaml_path(workspace_root)
|
|
46
|
+
if not path.exists():
|
|
47
|
+
return None
|
|
48
|
+
try:
|
|
49
|
+
payload = yaml.safe_load(path.read_text(encoding="utf-8"))
|
|
50
|
+
except yaml.YAMLError as exc:
|
|
51
|
+
raise LaunchError(f"Invalid storage.yaml: {exc}") from exc
|
|
52
|
+
if not isinstance(payload, dict):
|
|
53
|
+
raise LaunchError("Invalid storage.yaml: expected mapping.")
|
|
54
|
+
return StorageMeta(
|
|
55
|
+
storage_layout_version=_int_field(payload, "storage_layout_version"),
|
|
56
|
+
record_schema_version=_int_field(payload, "record_schema_version"),
|
|
57
|
+
created_with_taskledger=_str_field(payload, "created_with_taskledger"),
|
|
58
|
+
created_at=_str_field(payload, "created_at"),
|
|
59
|
+
last_migrated_with_taskledger=_optional_str(
|
|
60
|
+
payload, "last_migrated_with_taskledger"
|
|
61
|
+
),
|
|
62
|
+
last_migrated_at=_optional_str(payload, "last_migrated_at"),
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def write_storage_meta(workspace_root: Path, meta: StorageMeta) -> StorageMeta:
|
|
67
|
+
path = _storage_yaml_path(workspace_root)
|
|
68
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
69
|
+
atomic_write_text(path, yaml.safe_dump(meta.to_dict(), sort_keys=False))
|
|
70
|
+
return meta
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def require_storage_meta(workspace_root: Path) -> StorageMeta:
|
|
74
|
+
meta = read_storage_meta(workspace_root)
|
|
75
|
+
if meta is None:
|
|
76
|
+
raise LaunchError(
|
|
77
|
+
"Missing storage.yaml. Run 'taskledger init' or 'taskledger migrate apply'."
|
|
78
|
+
)
|
|
79
|
+
if meta.storage_layout_version > TASKLEDGER_STORAGE_LAYOUT_VERSION:
|
|
80
|
+
raise LaunchError(
|
|
81
|
+
f"Storage layout version {meta.storage_layout_version} is newer than "
|
|
82
|
+
f"supported {TASKLEDGER_STORAGE_LAYOUT_VERSION}. Upgrade taskledger."
|
|
83
|
+
)
|
|
84
|
+
return meta
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _int_field(payload: dict[str, object], key: str) -> int:
|
|
88
|
+
value = payload.get(key)
|
|
89
|
+
if not isinstance(value, int):
|
|
90
|
+
raise LaunchError(f"Missing or invalid '{key}' in storage.yaml.")
|
|
91
|
+
return value
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _str_field(payload: dict[str, object], key: str) -> str:
|
|
95
|
+
value = payload.get(key)
|
|
96
|
+
if not isinstance(value, str):
|
|
97
|
+
raise LaunchError(f"Missing or invalid '{key}' in storage.yaml.")
|
|
98
|
+
return value
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _optional_str(payload: dict[str, object], key: str) -> str | None:
|
|
102
|
+
value = payload.get(key)
|
|
103
|
+
return value if isinstance(value, str) else None
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
"""Migration framework for taskledger storage layout and record schema changes."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Callable
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from taskledger.domain.states import TASKLEDGER_STORAGE_LAYOUT_VERSION
|
|
11
|
+
from taskledger.errors import LaunchError
|
|
12
|
+
|
|
13
|
+
Record = dict[str, Any]
|
|
14
|
+
MigrationFunc = Callable[[Path], None]
|
|
15
|
+
RecordMigrationFunc = Callable[[Record], Record]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(frozen=True)
|
|
19
|
+
class LayoutMigration:
|
|
20
|
+
from_version: int
|
|
21
|
+
to_version: int
|
|
22
|
+
name: str
|
|
23
|
+
apply: MigrationFunc
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass(frozen=True)
|
|
27
|
+
class RecordMigration:
|
|
28
|
+
object_type: str
|
|
29
|
+
from_schema_version: int
|
|
30
|
+
to_schema_version: int
|
|
31
|
+
name: str
|
|
32
|
+
apply: RecordMigrationFunc
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass(frozen=True)
|
|
36
|
+
class MigrationNeeded:
|
|
37
|
+
path: Path
|
|
38
|
+
object_type: str
|
|
39
|
+
current_version: int
|
|
40
|
+
target_version: int
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass(frozen=True)
|
|
44
|
+
class MigrationScanIssue:
|
|
45
|
+
path: Path
|
|
46
|
+
object_type: str
|
|
47
|
+
message: str
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
LAYOUT_MIGRATIONS: tuple[LayoutMigration, ...] = ()
|
|
51
|
+
|
|
52
|
+
RECORD_MIGRATIONS: tuple[RecordMigration, ...] = ()
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def required_layout_migrations(current: int, target: int) -> list[LayoutMigration]:
|
|
56
|
+
migrations: list[LayoutMigration] = []
|
|
57
|
+
version = current
|
|
58
|
+
while version < target:
|
|
59
|
+
match = next(
|
|
60
|
+
(
|
|
61
|
+
migration
|
|
62
|
+
for migration in LAYOUT_MIGRATIONS
|
|
63
|
+
if migration.from_version == version
|
|
64
|
+
),
|
|
65
|
+
None,
|
|
66
|
+
)
|
|
67
|
+
if match is None:
|
|
68
|
+
raise LaunchError(
|
|
69
|
+
f"No migration path from storage layout {version} to {version + 1}."
|
|
70
|
+
)
|
|
71
|
+
migrations.append(match)
|
|
72
|
+
version = match.to_version
|
|
73
|
+
return migrations
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def scan_records_for_migration(
|
|
77
|
+
workspace_root: Path,
|
|
78
|
+
) -> list[MigrationNeeded]:
|
|
79
|
+
needed, _issues = inspect_records_for_migration(workspace_root)
|
|
80
|
+
return needed
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def inspect_records_for_migration(
|
|
84
|
+
workspace_root: Path,
|
|
85
|
+
) -> tuple[list[MigrationNeeded], list[MigrationScanIssue]]:
|
|
86
|
+
"""Scan all durable records for pending schema migrations.
|
|
87
|
+
|
|
88
|
+
Returns records whose schema_version is below the current target and any
|
|
89
|
+
malformed Markdown/frontmatter records encountered during the scan.
|
|
90
|
+
"""
|
|
91
|
+
from taskledger.storage.task_store import resolve_v2_paths
|
|
92
|
+
|
|
93
|
+
paths = resolve_v2_paths(workspace_root)
|
|
94
|
+
needed: list[MigrationNeeded] = []
|
|
95
|
+
issues: list[MigrationScanIssue] = []
|
|
96
|
+
if not paths.tasks_dir.exists():
|
|
97
|
+
return needed, issues
|
|
98
|
+
|
|
99
|
+
for task_dir in sorted(paths.tasks_dir.glob("task-*")):
|
|
100
|
+
if not task_dir.is_dir():
|
|
101
|
+
continue
|
|
102
|
+
_scan_dir(task_dir / "plans", "plan", needed, issues)
|
|
103
|
+
_scan_dir(task_dir / "questions", "question", needed, issues)
|
|
104
|
+
_scan_dir(task_dir / "runs", "run", needed, issues)
|
|
105
|
+
_scan_dir(task_dir / "changes", "change", needed, issues)
|
|
106
|
+
_scan_dir(task_dir / "todos", "todo", needed, issues)
|
|
107
|
+
_scan_dir(task_dir / "links", "link", needed, issues)
|
|
108
|
+
_scan_dir(task_dir / "requirements", "requirement", needed, issues)
|
|
109
|
+
_scan_dir(task_dir / "handoffs", "handoff", needed, issues)
|
|
110
|
+
|
|
111
|
+
task_md = task_dir / "task.md"
|
|
112
|
+
if task_md.exists():
|
|
113
|
+
_scan_file(task_md, "task", needed, issues)
|
|
114
|
+
|
|
115
|
+
return needed, issues
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def apply_layout_migrations(
|
|
119
|
+
workspace_root: Path,
|
|
120
|
+
current_version: int,
|
|
121
|
+
dry_run: bool = False,
|
|
122
|
+
) -> list[str]:
|
|
123
|
+
"""Apply layout migrations from current_version
|
|
124
|
+
to TASKLEDGER_STORAGE_LAYOUT_VERSION.
|
|
125
|
+
|
|
126
|
+
Returns list of migration names that were applied (or would be applied in dry_run).
|
|
127
|
+
"""
|
|
128
|
+
from taskledger.storage.meta import read_storage_meta, write_storage_meta
|
|
129
|
+
|
|
130
|
+
migrations = required_layout_migrations(
|
|
131
|
+
current_version, TASKLEDGER_STORAGE_LAYOUT_VERSION
|
|
132
|
+
)
|
|
133
|
+
applied: list[str] = []
|
|
134
|
+
for migration in migrations:
|
|
135
|
+
if not dry_run:
|
|
136
|
+
migration.apply(workspace_root)
|
|
137
|
+
applied.append(migration.name)
|
|
138
|
+
|
|
139
|
+
if not dry_run and applied:
|
|
140
|
+
meta = read_storage_meta(workspace_root)
|
|
141
|
+
if meta is not None:
|
|
142
|
+
try:
|
|
143
|
+
from taskledger._version import __version__ as tl_version
|
|
144
|
+
except ImportError:
|
|
145
|
+
tl_version = "unknown"
|
|
146
|
+
from taskledger.timeutils import utc_now_iso
|
|
147
|
+
|
|
148
|
+
updated = type(meta)(
|
|
149
|
+
storage_layout_version=TASKLEDGER_STORAGE_LAYOUT_VERSION,
|
|
150
|
+
record_schema_version=meta.record_schema_version,
|
|
151
|
+
created_with_taskledger=meta.created_with_taskledger,
|
|
152
|
+
created_at=meta.created_at,
|
|
153
|
+
last_migrated_with_taskledger=tl_version,
|
|
154
|
+
last_migrated_at=utc_now_iso(),
|
|
155
|
+
)
|
|
156
|
+
write_storage_meta(workspace_root, updated)
|
|
157
|
+
|
|
158
|
+
return applied
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _scan_dir(
|
|
162
|
+
directory: Path,
|
|
163
|
+
object_type: str,
|
|
164
|
+
needed: list[MigrationNeeded],
|
|
165
|
+
issues: list[MigrationScanIssue],
|
|
166
|
+
) -> None:
|
|
167
|
+
if not directory.exists():
|
|
168
|
+
return
|
|
169
|
+
for md_file in sorted(directory.glob("*.md")):
|
|
170
|
+
_scan_file(md_file, object_type, needed, issues)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _scan_file(
|
|
174
|
+
path: Path,
|
|
175
|
+
default_object_type: str,
|
|
176
|
+
needed: list[MigrationNeeded],
|
|
177
|
+
issues: list[MigrationScanIssue],
|
|
178
|
+
) -> None:
|
|
179
|
+
from taskledger.storage.frontmatter import read_markdown_front_matter
|
|
180
|
+
|
|
181
|
+
try:
|
|
182
|
+
metadata, _ = read_markdown_front_matter(path)
|
|
183
|
+
except Exception as exc:
|
|
184
|
+
issues.append(
|
|
185
|
+
MigrationScanIssue(
|
|
186
|
+
path=path,
|
|
187
|
+
object_type=default_object_type,
|
|
188
|
+
message=f"Cannot parse {default_object_type} record {path}: {exc}",
|
|
189
|
+
)
|
|
190
|
+
)
|
|
191
|
+
return
|
|
192
|
+
version = metadata.get("schema_version")
|
|
193
|
+
if not isinstance(version, int):
|
|
194
|
+
return
|
|
195
|
+
if version < 1:
|
|
196
|
+
return # Legacy/unversioned, not a migration candidate
|
|
197
|
+
from taskledger.domain.states import TASKLEDGER_SCHEMA_VERSION
|
|
198
|
+
|
|
199
|
+
if version < TASKLEDGER_SCHEMA_VERSION:
|
|
200
|
+
needed.append(
|
|
201
|
+
MigrationNeeded(
|
|
202
|
+
path=path,
|
|
203
|
+
object_type=str(metadata.get("object_type", default_object_type)),
|
|
204
|
+
current_version=version,
|
|
205
|
+
target_version=TASKLEDGER_SCHEMA_VERSION,
|
|
206
|
+
)
|
|
207
|
+
)
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Literal
|
|
7
|
+
|
|
8
|
+
from taskledger.storage.project_config import load_project_config_document
|
|
9
|
+
|
|
10
|
+
CANONICAL_PROJECT_CONFIG_FILENAME = "taskledger.toml"
|
|
11
|
+
PROJECT_CONFIG_FILENAMES = (".taskledger.toml", CANONICAL_PROJECT_CONFIG_FILENAME)
|
|
12
|
+
DEFAULT_TASKLEDGER_DIR_NAME = ".taskledger"
|
|
13
|
+
LEGACY_PROJECT_CONFIG_FILENAME = "project.toml"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass(slots=True, frozen=True)
|
|
17
|
+
class ProjectLocator:
|
|
18
|
+
workspace_root: Path
|
|
19
|
+
config_path: Path
|
|
20
|
+
taskledger_dir: Path
|
|
21
|
+
source: Literal["explicit", "dotfile", "toml", "legacy", "default"]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass(slots=True, frozen=True)
|
|
25
|
+
class ProjectPaths:
|
|
26
|
+
workspace_root: Path
|
|
27
|
+
project_dir: Path
|
|
28
|
+
taskledger_dir: Path
|
|
29
|
+
config_path: Path
|
|
30
|
+
repos_dir: Path
|
|
31
|
+
repo_index_path: Path
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def resolve_taskledger_root(workspace_root: Path) -> Path:
|
|
35
|
+
return load_project_locator(workspace_root).taskledger_dir
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def resolve_project_paths(workspace_root: Path) -> ProjectPaths:
|
|
39
|
+
locator = load_project_locator(workspace_root)
|
|
40
|
+
return project_paths_for_root(
|
|
41
|
+
locator.workspace_root,
|
|
42
|
+
locator.taskledger_dir,
|
|
43
|
+
config_path=locator.config_path,
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def discover_workspace_root(start: Path) -> Path:
|
|
48
|
+
return load_project_locator(start).workspace_root
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def find_project_config(start: Path) -> Path | None:
|
|
52
|
+
for current in _search_roots(start):
|
|
53
|
+
for filename in PROJECT_CONFIG_FILENAMES:
|
|
54
|
+
candidate = current / filename
|
|
55
|
+
if candidate.exists():
|
|
56
|
+
return candidate
|
|
57
|
+
return None
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def load_project_locator(
|
|
61
|
+
start: Path,
|
|
62
|
+
*,
|
|
63
|
+
taskledger_dir_override: Path | None = None,
|
|
64
|
+
config_filename: str = CANONICAL_PROJECT_CONFIG_FILENAME,
|
|
65
|
+
) -> ProjectLocator:
|
|
66
|
+
start_path = start.expanduser().resolve()
|
|
67
|
+
config_path = find_project_config(start_path)
|
|
68
|
+
if config_path is not None:
|
|
69
|
+
workspace_root = config_path.parent
|
|
70
|
+
taskledger_dir = (
|
|
71
|
+
_resolve_path(taskledger_dir_override, workspace_root=workspace_root)
|
|
72
|
+
if taskledger_dir_override is not None
|
|
73
|
+
else _taskledger_dir_from_config(config_path, workspace_root=workspace_root)
|
|
74
|
+
)
|
|
75
|
+
return ProjectLocator(
|
|
76
|
+
workspace_root=workspace_root,
|
|
77
|
+
config_path=config_path,
|
|
78
|
+
taskledger_dir=taskledger_dir,
|
|
79
|
+
source=(
|
|
80
|
+
"explicit"
|
|
81
|
+
if taskledger_dir_override is not None
|
|
82
|
+
else "dotfile"
|
|
83
|
+
if config_path.name.startswith(".")
|
|
84
|
+
else "toml"
|
|
85
|
+
),
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
legacy_workspace_root = _find_legacy_workspace_root(start_path)
|
|
89
|
+
if legacy_workspace_root is not None:
|
|
90
|
+
legacy_config_path = (
|
|
91
|
+
legacy_workspace_root
|
|
92
|
+
/ DEFAULT_TASKLEDGER_DIR_NAME
|
|
93
|
+
/ LEGACY_PROJECT_CONFIG_FILENAME
|
|
94
|
+
)
|
|
95
|
+
workspace_root = legacy_workspace_root
|
|
96
|
+
return ProjectLocator(
|
|
97
|
+
workspace_root=workspace_root,
|
|
98
|
+
config_path=(
|
|
99
|
+
legacy_config_path
|
|
100
|
+
if legacy_config_path.exists()
|
|
101
|
+
else workspace_root / config_filename
|
|
102
|
+
),
|
|
103
|
+
taskledger_dir=(
|
|
104
|
+
_resolve_path(taskledger_dir_override, workspace_root=workspace_root)
|
|
105
|
+
if taskledger_dir_override is not None
|
|
106
|
+
else workspace_root / DEFAULT_TASKLEDGER_DIR_NAME
|
|
107
|
+
),
|
|
108
|
+
source="explicit" if taskledger_dir_override is not None else "legacy",
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
workspace_root = start_path
|
|
112
|
+
return ProjectLocator(
|
|
113
|
+
workspace_root=workspace_root,
|
|
114
|
+
config_path=workspace_root / config_filename,
|
|
115
|
+
taskledger_dir=(
|
|
116
|
+
_resolve_path(taskledger_dir_override, workspace_root=workspace_root)
|
|
117
|
+
if taskledger_dir_override is not None
|
|
118
|
+
else workspace_root / DEFAULT_TASKLEDGER_DIR_NAME
|
|
119
|
+
),
|
|
120
|
+
source="explicit" if taskledger_dir_override is not None else "default",
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def project_paths_for_root(
|
|
125
|
+
workspace_root: Path,
|
|
126
|
+
project_dir: Path,
|
|
127
|
+
*,
|
|
128
|
+
config_path: Path | None = None,
|
|
129
|
+
) -> ProjectPaths:
|
|
130
|
+
indexes_dir = project_dir / "indexes"
|
|
131
|
+
return ProjectPaths(
|
|
132
|
+
workspace_root=workspace_root,
|
|
133
|
+
project_dir=project_dir,
|
|
134
|
+
taskledger_dir=project_dir,
|
|
135
|
+
config_path=config_path or workspace_root / CANONICAL_PROJECT_CONFIG_FILENAME,
|
|
136
|
+
repos_dir=project_dir / "repos",
|
|
137
|
+
repo_index_path=indexes_dir / "repos.json",
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _search_roots(start: Path) -> tuple[Path, ...]:
|
|
142
|
+
current = start if start.is_dir() else start.parent
|
|
143
|
+
return (current, *current.parents)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _find_legacy_workspace_root(start: Path) -> Path | None:
|
|
147
|
+
for current in _search_roots(start):
|
|
148
|
+
if (current / DEFAULT_TASKLEDGER_DIR_NAME / "storage.yaml").exists():
|
|
149
|
+
return current
|
|
150
|
+
return None
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _taskledger_dir_from_config(config_path: Path, *, workspace_root: Path) -> Path:
|
|
154
|
+
document = load_project_config_document(config_path)
|
|
155
|
+
raw_value = document.get("taskledger_dir")
|
|
156
|
+
if not isinstance(raw_value, str) or not raw_value.strip():
|
|
157
|
+
return workspace_root / DEFAULT_TASKLEDGER_DIR_NAME
|
|
158
|
+
return _resolve_path(raw_value, workspace_root=workspace_root)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _resolve_path(value: str | Path, *, workspace_root: Path) -> Path:
|
|
162
|
+
raw_value = os.path.expandvars(os.fspath(value))
|
|
163
|
+
path = Path(raw_value).expanduser()
|
|
164
|
+
if not path.is_absolute():
|
|
165
|
+
path = workspace_root / path
|
|
166
|
+
return path.resolve()
|