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,328 @@
1
+ """CLI commands for taskledger migrate: status, plan, apply."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ from typing import Annotated
7
+
8
+ import typer
9
+
10
+ from taskledger.cli_common import (
11
+ CLIState,
12
+ emit_error,
13
+ emit_payload,
14
+ launch_error_exit_code,
15
+ )
16
+ from taskledger.domain.states import TASKLEDGER_STORAGE_LAYOUT_VERSION
17
+ from taskledger.errors import LaunchError
18
+ from taskledger.storage.migrations import (
19
+ apply_layout_migrations,
20
+ required_layout_migrations,
21
+ scan_records_for_migration,
22
+ )
23
+
24
+ migrate_app = typer.Typer(
25
+ add_completion=False,
26
+ help="Inspect and apply storage migrations.",
27
+ )
28
+
29
+
30
+ @migrate_app.command("status")
31
+ def migrate_status_command(ctx: typer.Context) -> None:
32
+ state = ctx.obj
33
+ assert isinstance(state, CLIState)
34
+ try:
35
+ from taskledger.storage.meta import read_storage_meta
36
+
37
+ meta = read_storage_meta(state.cwd)
38
+ except LaunchError as exc:
39
+ emit_error(ctx, exc)
40
+ raise typer.Exit(code=launch_error_exit_code(exc)) from exc
41
+
42
+ if meta is None:
43
+ payload = {
44
+ "ok": True,
45
+ "status": "no_storage_meta",
46
+ "message": (
47
+ "No storage.yaml found."
48
+ " Run 'taskledger init' or 'taskledger migrate apply'."
49
+ ),
50
+ "current_layout_version": None,
51
+ "target_layout_version": TASKLEDGER_STORAGE_LAYOUT_VERSION,
52
+ "pending_migrations": [],
53
+ "records_needing_migration": 0,
54
+ }
55
+ human = (
56
+ "MIGRATE STATUS\n"
57
+ " No storage.yaml found.\n"
58
+ " Run 'taskledger init' or 'taskledger migrate apply'."
59
+ )
60
+ emit_payload(ctx, payload, human=human)
61
+ return
62
+
63
+ try:
64
+ pending = required_layout_migrations(
65
+ meta.storage_layout_version, TASKLEDGER_STORAGE_LAYOUT_VERSION
66
+ )
67
+ except LaunchError:
68
+ pending = []
69
+
70
+ try:
71
+ records = scan_records_for_migration(state.cwd)
72
+ except LaunchError:
73
+ records = []
74
+
75
+ needs_migration = len(pending) > 0 or len(records) > 0
76
+ payload = {
77
+ "ok": True,
78
+ "status": "migration_needed" if needs_migration else "up_to_date",
79
+ "current_layout_version": meta.storage_layout_version,
80
+ "target_layout_version": TASKLEDGER_STORAGE_LAYOUT_VERSION,
81
+ "pending_migrations": [
82
+ {"from": m.from_version, "to": m.to_version, "name": m.name}
83
+ for m in pending
84
+ ],
85
+ "records_needing_migration": len(records),
86
+ }
87
+ if needs_migration:
88
+ human_lines = [
89
+ "MIGRATE STATUS",
90
+ f" Layout version: {meta.storage_layout_version}"
91
+ f" -> {TASKLEDGER_STORAGE_LAYOUT_VERSION}",
92
+ f" Pending layout migrations: {len(pending)}",
93
+ f" Records needing migration: {len(records)}",
94
+ ]
95
+ else:
96
+ human_lines = [
97
+ "MIGRATE STATUS",
98
+ f" Layout version: {meta.storage_layout_version} (up to date)",
99
+ " No migrations needed.",
100
+ ]
101
+ emit_payload(ctx, payload, human="\n".join(human_lines))
102
+
103
+
104
+ @migrate_app.command("plan")
105
+ def migrate_plan_command(ctx: typer.Context) -> None:
106
+ state = ctx.obj
107
+ assert isinstance(state, CLIState)
108
+ try:
109
+ from taskledger.storage.meta import read_storage_meta
110
+
111
+ meta = read_storage_meta(state.cwd)
112
+ except LaunchError as exc:
113
+ emit_error(ctx, exc)
114
+ raise typer.Exit(code=launch_error_exit_code(exc)) from exc
115
+
116
+ if meta is None:
117
+ payload = {
118
+ "ok": True,
119
+ "status": "no_storage_meta",
120
+ "message": "No storage.yaml found. Nothing to plan.",
121
+ "migrations": [],
122
+ "records": [],
123
+ }
124
+ emit_payload(ctx, payload, human="MIGRATE PLAN\n No storage.yaml found.")
125
+ return
126
+
127
+ try:
128
+ pending = required_layout_migrations(
129
+ meta.storage_layout_version, TASKLEDGER_STORAGE_LAYOUT_VERSION
130
+ )
131
+ except LaunchError as exc:
132
+ emit_error(ctx, exc)
133
+ raise typer.Exit(code=launch_error_exit_code(exc)) from exc
134
+
135
+ try:
136
+ records = scan_records_for_migration(state.cwd)
137
+ except LaunchError:
138
+ records = []
139
+
140
+ payload = {
141
+ "ok": True,
142
+ "current_layout_version": meta.storage_layout_version,
143
+ "target_layout_version": TASKLEDGER_STORAGE_LAYOUT_VERSION,
144
+ "migrations": [
145
+ {"from": m.from_version, "to": m.to_version, "name": m.name}
146
+ for m in pending
147
+ ],
148
+ "records": [
149
+ {
150
+ "path": str(r.path),
151
+ "object_type": r.object_type,
152
+ "current_version": r.current_version,
153
+ "target_version": r.target_version,
154
+ }
155
+ for r in records
156
+ ],
157
+ }
158
+ human_lines = [
159
+ "MIGRATE PLAN",
160
+ f" Layout: {meta.storage_layout_version}"
161
+ f" -> {TASKLEDGER_STORAGE_LAYOUT_VERSION}",
162
+ f" Layout migrations: {len(pending)}",
163
+ f" Records to migrate: {len(records)}",
164
+ ]
165
+ for m in pending:
166
+ human_lines.append(
167
+ f" - {m.name} (layout {m.from_version} -> {m.to_version})"
168
+ )
169
+ for r in records:
170
+ human_lines.append(
171
+ f" - {r.path.name} ({r.object_type}:"
172
+ f" {r.current_version} -> {r.target_version})"
173
+ )
174
+ emit_payload(ctx, payload, human="\n".join(human_lines))
175
+
176
+
177
+ @migrate_app.command("apply")
178
+ def migrate_apply_command(
179
+ ctx: typer.Context,
180
+ backup: Annotated[
181
+ bool,
182
+ typer.Option("--backup", help="Create a snapshot before applying migrations."),
183
+ ] = False,
184
+ dry_run: Annotated[
185
+ bool,
186
+ typer.Option("--dry-run", help="Show what would be done without writing."),
187
+ ] = False,
188
+ ) -> None:
189
+ state = ctx.obj
190
+ assert isinstance(state, CLIState)
191
+ try:
192
+ from taskledger.storage.meta import read_storage_meta
193
+
194
+ meta = read_storage_meta(state.cwd)
195
+ except LaunchError as exc:
196
+ emit_error(ctx, exc)
197
+ raise typer.Exit(code=launch_error_exit_code(exc)) from exc
198
+
199
+ if meta is None:
200
+ payload = {
201
+ "ok": False,
202
+ "status": "no_storage_meta",
203
+ "message": "No storage.yaml found. Run 'taskledger init' first.",
204
+ }
205
+ emit_error(ctx, LaunchError(str(payload["message"])))
206
+ raise typer.Exit(code=2)
207
+
208
+ if meta.storage_layout_version > TASKLEDGER_STORAGE_LAYOUT_VERSION:
209
+ too_new = LaunchError(
210
+ f"Storage layout {meta.storage_layout_version} is newer than "
211
+ f"supported {TASKLEDGER_STORAGE_LAYOUT_VERSION}."
212
+ " Upgrade taskledger."
213
+ )
214
+ emit_error(ctx, too_new)
215
+ raise typer.Exit(code=launch_error_exit_code(too_new)) from too_new
216
+
217
+ if meta.storage_layout_version == TASKLEDGER_STORAGE_LAYOUT_VERSION:
218
+ payload = {
219
+ "ok": True,
220
+ "status": "up_to_date",
221
+ "message": "No migrations needed.",
222
+ "applied_migrations": [],
223
+ }
224
+ emit_payload(ctx, payload, human="MIGRATE APPLY\n Already up to date.")
225
+ return
226
+
227
+ # Create backup if requested
228
+ snapshot_dir: str | None = None
229
+ if backup and not dry_run:
230
+ from taskledger.api.project import project_snapshot
231
+
232
+ snapshot_path = Path.cwd() / ".taskledger" / "snapshots"
233
+ result = project_snapshot(
234
+ state.cwd,
235
+ output_dir=snapshot_path,
236
+ )
237
+ snapshot_dir = str(result.get("snapshot_dir", "")) or None
238
+
239
+ # Apply migrations
240
+ try:
241
+ applied = apply_layout_migrations(
242
+ state.cwd,
243
+ meta.storage_layout_version,
244
+ dry_run=dry_run,
245
+ )
246
+ except LaunchError as exc:
247
+ emit_error(ctx, exc)
248
+ raise typer.Exit(code=launch_error_exit_code(exc)) from exc
249
+
250
+ # Write audit record
251
+ if not dry_run and applied:
252
+ _write_migration_audit(state.cwd, meta.storage_layout_version, applied)
253
+
254
+ # Rebuild indexes
255
+ if not dry_run and applied:
256
+ try:
257
+ from taskledger.storage.indexes import rebuild_v2_indexes
258
+ from taskledger.storage.task_store import resolve_v2_paths
259
+
260
+ v2_paths = resolve_v2_paths(state.cwd)
261
+ rebuild_v2_indexes(v2_paths)
262
+ except LaunchError:
263
+ pass # Index rebuild is best-effort after migration
264
+
265
+ payload = {
266
+ "ok": True,
267
+ "status": "dry_run" if dry_run else "applied",
268
+ "from_layout_version": meta.storage_layout_version,
269
+ "to_layout_version": TASKLEDGER_STORAGE_LAYOUT_VERSION,
270
+ "applied_migrations": applied,
271
+ "snapshot_dir": snapshot_dir,
272
+ }
273
+ human_lines = [
274
+ "MIGRATE APPLY" + (" (dry run)" if dry_run else ""),
275
+ f" Layout: {meta.storage_layout_version}"
276
+ f" -> {TASKLEDGER_STORAGE_LAYOUT_VERSION}",
277
+ f" Applied migrations: {len(applied)}",
278
+ ]
279
+ for name in applied:
280
+ human_lines.append(f" - {name}")
281
+ if snapshot_dir:
282
+ human_lines.append(f" Backup: {snapshot_dir}")
283
+ emit_payload(ctx, payload, human="\n".join(human_lines))
284
+
285
+
286
+ def _write_migration_audit(
287
+ workspace_root: Path,
288
+ from_version: int,
289
+ applied_names: list[str],
290
+ ) -> None:
291
+ """Write a migration audit record to .taskledger/migrations/."""
292
+ from taskledger.storage.paths import resolve_taskledger_root
293
+ from taskledger.timeutils import utc_now_iso
294
+
295
+ project_dir = resolve_taskledger_root(workspace_root)
296
+ migrations_dir = project_dir / "migrations"
297
+ migrations_dir.mkdir(parents=True, exist_ok=True)
298
+
299
+ timestamp = utc_now_iso().replace(":", "").replace("-", "").replace("+", "")
300
+ audit_path = migrations_dir / (
301
+ f"{timestamp[:15]}-layout-{from_version}"
302
+ f"-to-{TASKLEDGER_STORAGE_LAYOUT_VERSION}.md"
303
+ )
304
+
305
+ try:
306
+ from taskledger._version import __version__ as tl_version
307
+ except ImportError:
308
+ tl_version = "unknown"
309
+
310
+ front_matter = (
311
+ "---\n"
312
+ f"schema_version: 1\n"
313
+ f"object_type: migration_audit\n"
314
+ f"from_layout_version: {from_version}\n"
315
+ f"to_layout_version: {TASKLEDGER_STORAGE_LAYOUT_VERSION}\n"
316
+ f"taskledger_version: {tl_version}\n"
317
+ f'created_at: "{utc_now_iso()}"\n'
318
+ "---\n\n"
319
+ )
320
+ body = (
321
+ f"# Migration layout {from_version} -> {TASKLEDGER_STORAGE_LAYOUT_VERSION}\n\n"
322
+ )
323
+ body += "## Applied migrations\n\n"
324
+ for name in applied_names:
325
+ body += f"- {name}\n"
326
+ body += "\n"
327
+
328
+ audit_path.write_text(front_matter + body, encoding="utf-8")