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,39 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from taskledger.storage.init import ensure_project_exists, init_project_state
|
|
4
|
+
from taskledger.storage.paths import resolve_project_paths, resolve_taskledger_root
|
|
5
|
+
from taskledger.storage.project_config import (
|
|
6
|
+
DEFAULT_PROJECT_TOML,
|
|
7
|
+
load_project_config_overrides,
|
|
8
|
+
merge_project_config,
|
|
9
|
+
)
|
|
10
|
+
from taskledger.storage.repos import (
|
|
11
|
+
add_repo,
|
|
12
|
+
clear_default_execution_repo,
|
|
13
|
+
load_repos,
|
|
14
|
+
remove_repo,
|
|
15
|
+
resolve_repo,
|
|
16
|
+
resolve_repo_root,
|
|
17
|
+
save_repos,
|
|
18
|
+
set_default_execution_repo,
|
|
19
|
+
set_repo_role,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
__all__ = [
|
|
23
|
+
"DEFAULT_PROJECT_TOML",
|
|
24
|
+
"add_repo",
|
|
25
|
+
"clear_default_execution_repo",
|
|
26
|
+
"ensure_project_exists",
|
|
27
|
+
"init_project_state",
|
|
28
|
+
"load_project_config_overrides",
|
|
29
|
+
"load_repos",
|
|
30
|
+
"merge_project_config",
|
|
31
|
+
"remove_repo",
|
|
32
|
+
"resolve_project_paths",
|
|
33
|
+
"resolve_repo",
|
|
34
|
+
"resolve_repo_root",
|
|
35
|
+
"resolve_taskledger_root",
|
|
36
|
+
"save_repos",
|
|
37
|
+
"set_default_execution_repo",
|
|
38
|
+
"set_repo_role",
|
|
39
|
+
]
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import tempfile
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from taskledger.errors import LaunchError
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def atomic_write_text(path: Path, contents: str) -> None:
|
|
11
|
+
temp_path: Path | None = None
|
|
12
|
+
try:
|
|
13
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
14
|
+
with tempfile.NamedTemporaryFile(
|
|
15
|
+
"w",
|
|
16
|
+
encoding="utf-8",
|
|
17
|
+
dir=path.parent,
|
|
18
|
+
delete=False,
|
|
19
|
+
) as handle:
|
|
20
|
+
handle.write(contents)
|
|
21
|
+
handle.flush()
|
|
22
|
+
os.fsync(handle.fileno())
|
|
23
|
+
temp_path = Path(handle.name)
|
|
24
|
+
os.replace(temp_path, path)
|
|
25
|
+
_fsync_directory(path.parent)
|
|
26
|
+
except OSError as exc:
|
|
27
|
+
if temp_path is not None and temp_path.exists():
|
|
28
|
+
temp_path.unlink(missing_ok=True)
|
|
29
|
+
raise LaunchError(f"Failed to atomically write {path}: {exc}") from exc
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def atomic_create_text(path: Path, contents: str) -> None:
|
|
33
|
+
try:
|
|
34
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
35
|
+
fd = os.open(path, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o644)
|
|
36
|
+
except FileExistsError as exc:
|
|
37
|
+
raise LaunchError(f"Lock already exists: {path}") from exc
|
|
38
|
+
except OSError as exc:
|
|
39
|
+
raise LaunchError(f"Failed to create {path}: {exc}") from exc
|
|
40
|
+
try:
|
|
41
|
+
with os.fdopen(fd, "w", encoding="utf-8") as handle:
|
|
42
|
+
handle.write(contents)
|
|
43
|
+
handle.flush()
|
|
44
|
+
os.fsync(handle.fileno())
|
|
45
|
+
except OSError as exc:
|
|
46
|
+
raise LaunchError(f"Failed to write {path}: {exc}") from exc
|
|
47
|
+
_fsync_directory(path.parent)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _fsync_directory(path: Path) -> None:
|
|
51
|
+
if os.name != "posix":
|
|
52
|
+
return
|
|
53
|
+
directory_fd = os.open(path, os.O_RDONLY)
|
|
54
|
+
try:
|
|
55
|
+
os.fsync(directory_fd)
|
|
56
|
+
finally:
|
|
57
|
+
os.close(directory_fd)
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from hashlib import sha256
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from taskledger.errors import LaunchError
|
|
8
|
+
from taskledger.storage.paths import ProjectPaths
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def load_json_array(path: Path, label: str) -> list[dict[str, object]]:
|
|
12
|
+
if not path.exists():
|
|
13
|
+
return []
|
|
14
|
+
text = read_text(path).strip()
|
|
15
|
+
if not text:
|
|
16
|
+
return []
|
|
17
|
+
try:
|
|
18
|
+
data = json.loads(text)
|
|
19
|
+
except json.JSONDecodeError as exc:
|
|
20
|
+
raise LaunchError(f"Invalid {label} {path}: {exc}") from exc
|
|
21
|
+
if not isinstance(data, list):
|
|
22
|
+
raise LaunchError(f"Invalid {label} {path}: expected a JSON array.")
|
|
23
|
+
return [item for item in data if isinstance(item, dict)]
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def load_json_object(path: Path, label: str) -> dict[str, object]:
|
|
27
|
+
text = read_text(path).strip()
|
|
28
|
+
if not text:
|
|
29
|
+
return {}
|
|
30
|
+
try:
|
|
31
|
+
data = json.loads(text)
|
|
32
|
+
except json.JSONDecodeError as exc:
|
|
33
|
+
raise LaunchError(f"Invalid {label} {path}: {exc}") from exc
|
|
34
|
+
if not isinstance(data, dict):
|
|
35
|
+
raise LaunchError(f"Invalid {label} {path}: expected a JSON object.")
|
|
36
|
+
return data
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def write_json(path: Path, payload: object) -> None:
|
|
40
|
+
write_text(path, json.dumps(payload, indent=2, sort_keys=True) + "\n")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def write_text(path: Path, contents: str) -> None:
|
|
44
|
+
try:
|
|
45
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
46
|
+
path.write_text(contents, encoding="utf-8")
|
|
47
|
+
except OSError as exc:
|
|
48
|
+
raise LaunchError(f"Failed to write {path}: {exc}") from exc
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def read_text(path: Path) -> str:
|
|
52
|
+
try:
|
|
53
|
+
return path.read_text(encoding="utf-8")
|
|
54
|
+
except OSError as exc:
|
|
55
|
+
raise LaunchError(f"Failed to read {path}: {exc}") from exc
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def relative_to_project(paths: ProjectPaths, target: Path) -> str:
|
|
59
|
+
return str(target.relative_to(paths.project_dir))
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def relative_to_workspace(paths: ProjectPaths, target: Path) -> str:
|
|
63
|
+
try:
|
|
64
|
+
return str(target.relative_to(paths.workspace_root))
|
|
65
|
+
except ValueError:
|
|
66
|
+
return str(target)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def summarize_text(text: str) -> str | None:
|
|
70
|
+
stripped = " ".join(text.split())
|
|
71
|
+
if not stripped:
|
|
72
|
+
return None
|
|
73
|
+
if len(stripped) <= 80:
|
|
74
|
+
return stripped
|
|
75
|
+
return stripped[:77] + "..."
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def content_hash(text: str) -> str | None:
|
|
79
|
+
if not text:
|
|
80
|
+
return None
|
|
81
|
+
return sha256(text.encode("utf-8")).hexdigest()
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def merge_text(current: str, incoming: str, *, prepend: bool) -> str:
|
|
85
|
+
if not current:
|
|
86
|
+
return incoming
|
|
87
|
+
if not incoming:
|
|
88
|
+
return current
|
|
89
|
+
combined = [incoming, current] if prepend else [current, incoming]
|
|
90
|
+
return "\n\n".join(part.rstrip("\n") for part in combined if part)
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from taskledger.domain.models import TaskEvent
|
|
7
|
+
from taskledger.errors import LaunchError
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def append_event(events_dir: Path, event: TaskEvent) -> Path:
|
|
11
|
+
path = event_log_path(events_dir, event.ts)
|
|
12
|
+
try:
|
|
13
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
14
|
+
with path.open("a", encoding="utf-8") as handle:
|
|
15
|
+
handle.write(json.dumps(event.to_dict(), sort_keys=True) + "\n")
|
|
16
|
+
except OSError as exc:
|
|
17
|
+
raise LaunchError(f"Failed to append event {path}: {exc}") from exc
|
|
18
|
+
return path
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def load_events(events_dir: Path) -> list[TaskEvent]:
|
|
22
|
+
if not events_dir.exists():
|
|
23
|
+
return []
|
|
24
|
+
events: list[TaskEvent] = []
|
|
25
|
+
seen_ids: set[str] = set()
|
|
26
|
+
for path in sorted(events_dir.glob("*.ndjson")):
|
|
27
|
+
try:
|
|
28
|
+
for line in path.read_text(encoding="utf-8").splitlines():
|
|
29
|
+
if not line.strip():
|
|
30
|
+
continue
|
|
31
|
+
payload = json.loads(line)
|
|
32
|
+
if isinstance(payload, dict):
|
|
33
|
+
event = TaskEvent.from_dict(payload)
|
|
34
|
+
if event.event_id in seen_ids:
|
|
35
|
+
raise LaunchError(
|
|
36
|
+
f"Duplicate event id detected: {event.event_id}"
|
|
37
|
+
)
|
|
38
|
+
seen_ids.add(event.event_id)
|
|
39
|
+
events.append(event)
|
|
40
|
+
except (OSError, json.JSONDecodeError) as exc:
|
|
41
|
+
raise LaunchError(f"Failed to read event log {path}: {exc}") from exc
|
|
42
|
+
return sorted(events, key=lambda item: (item.ts, item.event_id))
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def load_recent_events(
|
|
46
|
+
events_dir: Path,
|
|
47
|
+
*,
|
|
48
|
+
task_id: str | None = None,
|
|
49
|
+
limit: int = 50,
|
|
50
|
+
) -> list[TaskEvent]:
|
|
51
|
+
if limit <= 0 or not events_dir.exists():
|
|
52
|
+
return []
|
|
53
|
+
matches: list[TaskEvent] = []
|
|
54
|
+
for path in sorted(events_dir.glob("*.ndjson"), reverse=True):
|
|
55
|
+
try:
|
|
56
|
+
lines = path.read_text(encoding="utf-8").splitlines()
|
|
57
|
+
except OSError as exc:
|
|
58
|
+
raise LaunchError(f"Failed to read event log {path}: {exc}") from exc
|
|
59
|
+
for line in reversed(lines):
|
|
60
|
+
if not line.strip():
|
|
61
|
+
continue
|
|
62
|
+
try:
|
|
63
|
+
payload = json.loads(line)
|
|
64
|
+
except json.JSONDecodeError as exc:
|
|
65
|
+
raise LaunchError(f"Failed to read event log {path}: {exc}") from exc
|
|
66
|
+
if not isinstance(payload, dict):
|
|
67
|
+
continue
|
|
68
|
+
event = TaskEvent.from_dict(payload)
|
|
69
|
+
if task_id is not None and event.task_id != task_id:
|
|
70
|
+
continue
|
|
71
|
+
matches.append(event)
|
|
72
|
+
if len(matches) >= limit:
|
|
73
|
+
return list(reversed(matches))
|
|
74
|
+
return list(reversed(matches))
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def next_event_id(events_dir: Path, timestamp: str) -> str:
|
|
78
|
+
sequence = len(load_events(events_dir)) + 1
|
|
79
|
+
normalized = (
|
|
80
|
+
timestamp.replace(":", "")
|
|
81
|
+
.replace("-", "")
|
|
82
|
+
.replace("+00:00", "Z")
|
|
83
|
+
.replace(".", "")
|
|
84
|
+
)
|
|
85
|
+
return f"evt-{normalized}-{sequence:06d}"
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def event_log_path(events_dir: Path, timestamp: str) -> Path:
|
|
89
|
+
return events_dir / f"{timestamp[:10]}.ndjson"
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
__all__ = [
|
|
93
|
+
"append_event",
|
|
94
|
+
"event_log_path",
|
|
95
|
+
"load_events",
|
|
96
|
+
"load_recent_events",
|
|
97
|
+
"next_event_id",
|
|
98
|
+
]
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
import yaml
|
|
7
|
+
|
|
8
|
+
from taskledger.errors import LaunchError
|
|
9
|
+
from taskledger.storage.common import read_text as _read_text
|
|
10
|
+
from taskledger.storage.common import write_text as _write_text
|
|
11
|
+
|
|
12
|
+
_FRONT_MATTER_PATTERN = re.compile(r"^---\n(?P<meta>.*?)\n---(?:\n|$)", re.DOTALL)
|
|
13
|
+
MARKDOWN_FILE_VERSION = "v1"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def read_markdown_front_matter(path: Path) -> tuple[dict[str, object], str]:
|
|
17
|
+
text = normalize_front_matter_newlines(_read_text(path))
|
|
18
|
+
match = _FRONT_MATTER_PATTERN.match(text)
|
|
19
|
+
if match is None:
|
|
20
|
+
raise LaunchError(
|
|
21
|
+
f"Invalid front matter document {path}: expected leading YAML front matter."
|
|
22
|
+
)
|
|
23
|
+
metadata_text = match.group("meta")
|
|
24
|
+
try:
|
|
25
|
+
raw_metadata = yaml.safe_load(metadata_text)
|
|
26
|
+
except yaml.YAMLError as exc:
|
|
27
|
+
raise LaunchError(f"Invalid YAML front matter in {path}: {exc}") from exc
|
|
28
|
+
if raw_metadata is None:
|
|
29
|
+
metadata: dict[str, object] = {}
|
|
30
|
+
elif isinstance(raw_metadata, dict):
|
|
31
|
+
metadata = raw_metadata
|
|
32
|
+
else:
|
|
33
|
+
raise LaunchError(f"Invalid YAML front matter in {path}: expected a mapping.")
|
|
34
|
+
body = text[match.end() :]
|
|
35
|
+
return metadata, body
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def write_markdown_front_matter(
|
|
39
|
+
path: Path,
|
|
40
|
+
metadata: dict[str, object],
|
|
41
|
+
body: str,
|
|
42
|
+
) -> None:
|
|
43
|
+
normalized_body = normalize_front_matter_newlines(body)
|
|
44
|
+
yaml_text = yaml.safe_dump(metadata, sort_keys=False, allow_unicode=True)
|
|
45
|
+
yaml_text = normalize_front_matter_newlines(yaml_text)
|
|
46
|
+
contents = f"---\n{yaml_text}---\n{normalized_body}"
|
|
47
|
+
_write_text(path, contents)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def iter_markdown_files(directory: Path) -> list[Path]:
|
|
51
|
+
if not directory.exists():
|
|
52
|
+
return []
|
|
53
|
+
return sorted(path for path in directory.glob("*.md") if path.is_file())
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def normalize_front_matter_newlines(text: str) -> str:
|
|
57
|
+
return text.replace("\r\n", "\n").replace("\r", "\n")
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from taskledger.storage.common import write_json
|
|
4
|
+
from taskledger.storage.task_store import (
|
|
5
|
+
V2Paths,
|
|
6
|
+
list_introductions,
|
|
7
|
+
list_tasks,
|
|
8
|
+
load_active_locks,
|
|
9
|
+
load_requirements,
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def rebuild_v2_indexes(paths: V2Paths) -> dict[str, int]:
|
|
14
|
+
tasks = list_tasks(paths.workspace_root)
|
|
15
|
+
introductions = list_introductions(paths.workspace_root)
|
|
16
|
+
locks = load_active_locks(paths.workspace_root)
|
|
17
|
+
dependencies = [
|
|
18
|
+
{
|
|
19
|
+
"task_id": task.id,
|
|
20
|
+
"requirements": [
|
|
21
|
+
item.task_id
|
|
22
|
+
for item in load_requirements(
|
|
23
|
+
paths.workspace_root, task.id
|
|
24
|
+
).requirements
|
|
25
|
+
],
|
|
26
|
+
}
|
|
27
|
+
for task in tasks
|
|
28
|
+
]
|
|
29
|
+
write_json(
|
|
30
|
+
paths.introductions_index_path,
|
|
31
|
+
[
|
|
32
|
+
{"id": intro.id, "slug": intro.slug, "title": intro.title}
|
|
33
|
+
for intro in introductions
|
|
34
|
+
],
|
|
35
|
+
)
|
|
36
|
+
write_json(paths.active_locks_index_path, [lock.to_dict() for lock in locks])
|
|
37
|
+
write_json(paths.dependencies_index_path, dependencies)
|
|
38
|
+
return {
|
|
39
|
+
"introductions": len(introductions),
|
|
40
|
+
"locks": len(locks),
|
|
41
|
+
"dependencies": len(dependencies),
|
|
42
|
+
}
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from taskledger.errors import LaunchError
|
|
6
|
+
from taskledger.storage.common import write_text
|
|
7
|
+
from taskledger.storage.meta import StorageMeta, write_storage_meta
|
|
8
|
+
from taskledger.storage.paths import (
|
|
9
|
+
CANONICAL_PROJECT_CONFIG_FILENAME,
|
|
10
|
+
ProjectPaths,
|
|
11
|
+
load_project_locator,
|
|
12
|
+
project_paths_for_root,
|
|
13
|
+
)
|
|
14
|
+
from taskledger.storage.project_config import render_default_taskledger_toml
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _storage_yaml_path(workspace_root: Path) -> Path:
|
|
18
|
+
return load_project_locator(workspace_root).taskledger_dir / "storage.yaml"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def init_project_state(
|
|
22
|
+
workspace_root: Path,
|
|
23
|
+
*,
|
|
24
|
+
taskledger_dir: Path | None = None,
|
|
25
|
+
config_filename: str = CANONICAL_PROJECT_CONFIG_FILENAME,
|
|
26
|
+
) -> tuple[ProjectPaths, list[str]]:
|
|
27
|
+
existing = load_project_locator(workspace_root, config_filename=config_filename)
|
|
28
|
+
requested = load_project_locator(
|
|
29
|
+
workspace_root,
|
|
30
|
+
taskledger_dir_override=taskledger_dir,
|
|
31
|
+
config_filename=config_filename,
|
|
32
|
+
)
|
|
33
|
+
if (
|
|
34
|
+
taskledger_dir is not None
|
|
35
|
+
and existing.config_path.exists()
|
|
36
|
+
and existing.taskledger_dir != requested.taskledger_dir
|
|
37
|
+
):
|
|
38
|
+
raise LaunchError(
|
|
39
|
+
"Existing taskledger config points to "
|
|
40
|
+
f"{existing.taskledger_dir}. Refusing to reinitialize with "
|
|
41
|
+
f"{requested.taskledger_dir}."
|
|
42
|
+
)
|
|
43
|
+
if (
|
|
44
|
+
taskledger_dir is not None
|
|
45
|
+
and existing.source == "legacy"
|
|
46
|
+
and not existing.config_path.exists()
|
|
47
|
+
):
|
|
48
|
+
raise LaunchError(
|
|
49
|
+
"Legacy workspaces cannot change taskledger_dir through init without "
|
|
50
|
+
"an explicit migration."
|
|
51
|
+
)
|
|
52
|
+
paths = project_paths_for_root(
|
|
53
|
+
requested.workspace_root,
|
|
54
|
+
requested.taskledger_dir,
|
|
55
|
+
config_path=requested.config_path,
|
|
56
|
+
)
|
|
57
|
+
created: list[str] = []
|
|
58
|
+
for directory in (
|
|
59
|
+
paths.taskledger_dir,
|
|
60
|
+
paths.taskledger_dir / "intros",
|
|
61
|
+
paths.taskledger_dir / "tasks",
|
|
62
|
+
paths.taskledger_dir / "events",
|
|
63
|
+
paths.taskledger_dir / "indexes",
|
|
64
|
+
):
|
|
65
|
+
if directory.exists():
|
|
66
|
+
continue
|
|
67
|
+
directory.mkdir(parents=True, exist_ok=True)
|
|
68
|
+
created.append(str(directory))
|
|
69
|
+
config_spec: list[tuple[Path, str]] = []
|
|
70
|
+
if _should_write_root_config(existing, paths):
|
|
71
|
+
taskledger_dir_value = _taskledger_dir_setting(
|
|
72
|
+
taskledger_dir or Path(".taskledger")
|
|
73
|
+
)
|
|
74
|
+
config_spec = [
|
|
75
|
+
(
|
|
76
|
+
paths.config_path,
|
|
77
|
+
render_default_taskledger_toml(taskledger_dir=taskledger_dir_value),
|
|
78
|
+
)
|
|
79
|
+
]
|
|
80
|
+
for path, contents in (
|
|
81
|
+
*config_spec,
|
|
82
|
+
(paths.repo_index_path, "[]\n"),
|
|
83
|
+
(paths.taskledger_dir / "indexes" / "active_locks.json", "[]\n"),
|
|
84
|
+
(paths.taskledger_dir / "indexes" / "dependencies.json", "[]\n"),
|
|
85
|
+
(paths.taskledger_dir / "indexes" / "introductions.json", "[]\n"),
|
|
86
|
+
):
|
|
87
|
+
if path.exists():
|
|
88
|
+
continue
|
|
89
|
+
write_text(path, contents)
|
|
90
|
+
created.append(str(path))
|
|
91
|
+
# Write storage.yaml
|
|
92
|
+
storage_path = paths.taskledger_dir / "storage.yaml"
|
|
93
|
+
if not storage_path.exists():
|
|
94
|
+
try:
|
|
95
|
+
from taskledger._version import __version__ as tl_version
|
|
96
|
+
except ImportError:
|
|
97
|
+
tl_version = "0.1.0"
|
|
98
|
+
meta = StorageMeta(created_with_taskledger=tl_version)
|
|
99
|
+
write_storage_meta(paths.workspace_root, meta)
|
|
100
|
+
created.append(str(storage_path))
|
|
101
|
+
return paths, created
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def ensure_project_exists(workspace_root: Path) -> ProjectPaths:
|
|
105
|
+
locator = load_project_locator(workspace_root)
|
|
106
|
+
paths = project_paths_for_root(
|
|
107
|
+
locator.workspace_root,
|
|
108
|
+
locator.taskledger_dir,
|
|
109
|
+
config_path=locator.config_path,
|
|
110
|
+
)
|
|
111
|
+
_reject_legacy_item_memory_indexes(paths)
|
|
112
|
+
missing = [
|
|
113
|
+
path
|
|
114
|
+
for path in (
|
|
115
|
+
paths.taskledger_dir / "tasks",
|
|
116
|
+
paths.taskledger_dir / "intros",
|
|
117
|
+
paths.taskledger_dir / "events",
|
|
118
|
+
paths.taskledger_dir / "indexes",
|
|
119
|
+
)
|
|
120
|
+
if not path.exists()
|
|
121
|
+
]
|
|
122
|
+
if missing:
|
|
123
|
+
raise LaunchError(
|
|
124
|
+
"Project state is not initialized. Run 'taskledger init' first."
|
|
125
|
+
)
|
|
126
|
+
_ensure_additive_project_files(paths)
|
|
127
|
+
return paths
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _ensure_additive_project_files(paths: ProjectPaths) -> None:
|
|
131
|
+
for directory in (
|
|
132
|
+
paths.taskledger_dir,
|
|
133
|
+
paths.taskledger_dir / "intros",
|
|
134
|
+
paths.taskledger_dir / "tasks",
|
|
135
|
+
paths.taskledger_dir / "events",
|
|
136
|
+
paths.taskledger_dir / "indexes",
|
|
137
|
+
):
|
|
138
|
+
if directory.exists():
|
|
139
|
+
continue
|
|
140
|
+
try:
|
|
141
|
+
directory.mkdir(parents=True, exist_ok=True)
|
|
142
|
+
except OSError as exc:
|
|
143
|
+
raise LaunchError(f"Failed to create {directory}: {exc}") from exc
|
|
144
|
+
for path in (
|
|
145
|
+
paths.repo_index_path,
|
|
146
|
+
paths.taskledger_dir / "indexes" / "active_locks.json",
|
|
147
|
+
paths.taskledger_dir / "indexes" / "dependencies.json",
|
|
148
|
+
paths.taskledger_dir / "indexes" / "introductions.json",
|
|
149
|
+
):
|
|
150
|
+
if path.exists():
|
|
151
|
+
continue
|
|
152
|
+
write_text(path, "[]\n")
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _reject_legacy_item_memory_indexes(paths: ProjectPaths) -> None:
|
|
156
|
+
legacy_item_index = paths.taskledger_dir / "items" / "index.json"
|
|
157
|
+
legacy_memory_index = paths.taskledger_dir / "memories" / "index.json"
|
|
158
|
+
if legacy_item_index.exists():
|
|
159
|
+
raise LaunchError(
|
|
160
|
+
"Legacy item JSON storage is unsupported after this refactor: "
|
|
161
|
+
f"remove {legacy_item_index}."
|
|
162
|
+
)
|
|
163
|
+
if legacy_memory_index.exists():
|
|
164
|
+
raise LaunchError(
|
|
165
|
+
"Legacy memory JSON storage is unsupported after this refactor: "
|
|
166
|
+
f"remove {legacy_memory_index}."
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _should_write_root_config(
|
|
171
|
+
locator: object,
|
|
172
|
+
paths: ProjectPaths,
|
|
173
|
+
) -> bool:
|
|
174
|
+
if paths.config_path.exists():
|
|
175
|
+
return False
|
|
176
|
+
source = getattr(locator, "source", "")
|
|
177
|
+
return source not in {"legacy"}
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _taskledger_dir_setting(taskledger_dir: Path) -> str:
|
|
181
|
+
if not taskledger_dir.is_absolute():
|
|
182
|
+
return taskledger_dir.as_posix()
|
|
183
|
+
if taskledger_dir.exists():
|
|
184
|
+
resolved = taskledger_dir
|
|
185
|
+
else:
|
|
186
|
+
resolved = taskledger_dir.resolve()
|
|
187
|
+
return str(resolved)
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from datetime import datetime, timezone
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
import yaml
|
|
7
|
+
|
|
8
|
+
from taskledger.domain.models import TaskLock
|
|
9
|
+
from taskledger.errors import LaunchError
|
|
10
|
+
from taskledger.storage.atomic import atomic_create_text, atomic_write_text
|
|
11
|
+
from taskledger.storage.common import read_text
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def write_lock(path: Path, lock: TaskLock) -> None:
|
|
15
|
+
atomic_create_text(
|
|
16
|
+
path,
|
|
17
|
+
yaml.safe_dump(lock.to_dict(), sort_keys=False, allow_unicode=True),
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def update_lock(path: Path, lock: TaskLock) -> None:
|
|
22
|
+
"""Update an existing lock, or create if it doesn't exist."""
|
|
23
|
+
atomic_write_text(
|
|
24
|
+
path,
|
|
25
|
+
yaml.safe_dump(lock.to_dict(), sort_keys=False, allow_unicode=True),
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def read_lock(path: Path) -> TaskLock | None:
|
|
30
|
+
if not path.exists():
|
|
31
|
+
return None
|
|
32
|
+
try:
|
|
33
|
+
payload = yaml.safe_load(read_text(path))
|
|
34
|
+
except yaml.YAMLError as exc:
|
|
35
|
+
raise LaunchError(f"Invalid lock file {path}: {exc}") from exc
|
|
36
|
+
if not isinstance(payload, dict):
|
|
37
|
+
raise LaunchError(f"Invalid lock file {path}: expected mapping.")
|
|
38
|
+
return TaskLock.from_dict(payload)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def remove_lock(path: Path) -> None:
|
|
42
|
+
if not path.exists():
|
|
43
|
+
return
|
|
44
|
+
try:
|
|
45
|
+
path.unlink()
|
|
46
|
+
except OSError as exc:
|
|
47
|
+
raise LaunchError(f"Failed to remove lock {path}: {exc}") from exc
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def lock_is_expired(lock: TaskLock, *, now: datetime | None = None) -> bool:
|
|
51
|
+
if lock.expires_at is None:
|
|
52
|
+
return False
|
|
53
|
+
try:
|
|
54
|
+
expires_at = datetime.fromisoformat(lock.expires_at)
|
|
55
|
+
except ValueError as exc:
|
|
56
|
+
raise LaunchError(
|
|
57
|
+
f"Invalid lock expiration on {lock.task_id} "
|
|
58
|
+
f"({lock.lock_id}): {lock.expires_at}"
|
|
59
|
+
) from exc
|
|
60
|
+
reference = now or datetime.now(timezone.utc)
|
|
61
|
+
return expires_at < reference
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def lock_status(lock: TaskLock | None) -> dict[str, object]:
|
|
65
|
+
if lock is None:
|
|
66
|
+
return {
|
|
67
|
+
"active": False,
|
|
68
|
+
"expired": False,
|
|
69
|
+
"holder": None,
|
|
70
|
+
"stage": None,
|
|
71
|
+
"run_id": None,
|
|
72
|
+
"created_at": None,
|
|
73
|
+
"expires_at": None,
|
|
74
|
+
}
|
|
75
|
+
return {
|
|
76
|
+
"active": True,
|
|
77
|
+
"expired": lock_is_expired(lock),
|
|
78
|
+
"holder": lock.holder.to_dict(),
|
|
79
|
+
"stage": lock.stage,
|
|
80
|
+
"run_id": lock.run_id,
|
|
81
|
+
"created_at": lock.created_at,
|
|
82
|
+
"expires_at": lock.expires_at,
|
|
83
|
+
}
|