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,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()