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.
Files changed (67) hide show
  1. taskledger/__init__.py +5 -0
  2. taskledger/__main__.py +6 -0
  3. taskledger/_version.py +24 -0
  4. taskledger/api/__init__.py +13 -0
  5. taskledger/api/handoff.py +247 -0
  6. taskledger/api/introductions.py +9 -0
  7. taskledger/api/locks.py +4 -0
  8. taskledger/api/plans.py +31 -0
  9. taskledger/api/project.py +185 -0
  10. taskledger/api/questions.py +19 -0
  11. taskledger/api/search.py +87 -0
  12. taskledger/api/task_runs.py +38 -0
  13. taskledger/api/tasks.py +61 -0
  14. taskledger/cli.py +600 -0
  15. taskledger/cli_actor.py +196 -0
  16. taskledger/cli_common.py +617 -0
  17. taskledger/cli_implement.py +409 -0
  18. taskledger/cli_migrate.py +328 -0
  19. taskledger/cli_misc.py +984 -0
  20. taskledger/cli_plan.py +478 -0
  21. taskledger/cli_question.py +350 -0
  22. taskledger/cli_task.py +257 -0
  23. taskledger/cli_validate.py +285 -0
  24. taskledger/command_inventory.py +125 -0
  25. taskledger/domain/__init__.py +2 -0
  26. taskledger/domain/models.py +1697 -0
  27. taskledger/domain/policies.py +542 -0
  28. taskledger/domain/states.py +320 -0
  29. taskledger/errors.py +165 -0
  30. taskledger/exchange.py +343 -0
  31. taskledger/ids.py +19 -0
  32. taskledger/py.typed +0 -0
  33. taskledger/search.py +349 -0
  34. taskledger/services/__init__.py +1 -0
  35. taskledger/services/actors.py +245 -0
  36. taskledger/services/dashboard.py +306 -0
  37. taskledger/services/doctor.py +435 -0
  38. taskledger/services/handoff.py +1029 -0
  39. taskledger/services/handoff_lifecycle.py +154 -0
  40. taskledger/services/navigation.py +930 -0
  41. taskledger/services/phase5_lock_transfer.py +96 -0
  42. taskledger/services/plan_lint.py +397 -0
  43. taskledger/services/serve_read_model.py +852 -0
  44. taskledger/services/tasks.py +4224 -0
  45. taskledger/services/validation.py +221 -0
  46. taskledger/services/web_dashboard.py +1742 -0
  47. taskledger/storage/__init__.py +39 -0
  48. taskledger/storage/atomic.py +57 -0
  49. taskledger/storage/common.py +90 -0
  50. taskledger/storage/events.py +98 -0
  51. taskledger/storage/frontmatter.py +57 -0
  52. taskledger/storage/indexes.py +42 -0
  53. taskledger/storage/init.py +187 -0
  54. taskledger/storage/locks.py +83 -0
  55. taskledger/storage/meta.py +103 -0
  56. taskledger/storage/migrations.py +207 -0
  57. taskledger/storage/paths.py +166 -0
  58. taskledger/storage/project_config.py +393 -0
  59. taskledger/storage/repos.py +256 -0
  60. taskledger/storage/task_store.py +836 -0
  61. taskledger/timeutils.py +7 -0
  62. taskledger-0.1.0.dist-info/METADATA +411 -0
  63. taskledger-0.1.0.dist-info/RECORD +67 -0
  64. taskledger-0.1.0.dist-info/WHEEL +5 -0
  65. taskledger-0.1.0.dist-info/entry_points.txt +2 -0
  66. taskledger-0.1.0.dist-info/licenses/LICENSE +201 -0
  67. 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
+ }