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
taskledger/exchange.py
ADDED
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import shutil
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from taskledger.domain.models import (
|
|
8
|
+
ActiveTaskState,
|
|
9
|
+
CodeChangeRecord,
|
|
10
|
+
DependencyRequirement,
|
|
11
|
+
FileLink,
|
|
12
|
+
IntroductionRecord,
|
|
13
|
+
LinkCollection,
|
|
14
|
+
PlanRecord,
|
|
15
|
+
QuestionRecord,
|
|
16
|
+
RequirementCollection,
|
|
17
|
+
TaskEvent,
|
|
18
|
+
TaskHandoffRecord,
|
|
19
|
+
TaskLock,
|
|
20
|
+
TaskRecord,
|
|
21
|
+
TaskRunRecord,
|
|
22
|
+
TaskTodo,
|
|
23
|
+
TodoCollection,
|
|
24
|
+
)
|
|
25
|
+
from taskledger.errors import LaunchError
|
|
26
|
+
from taskledger.storage.events import append_event, load_events
|
|
27
|
+
from taskledger.storage.indexes import rebuild_v2_indexes
|
|
28
|
+
from taskledger.storage.locks import write_lock
|
|
29
|
+
from taskledger.storage.task_store import (
|
|
30
|
+
V2Paths,
|
|
31
|
+
ensure_v2_layout,
|
|
32
|
+
load_active_locks,
|
|
33
|
+
load_active_task_state,
|
|
34
|
+
overwrite_plan,
|
|
35
|
+
plan_markdown_path,
|
|
36
|
+
resolve_v2_paths,
|
|
37
|
+
save_active_task_state,
|
|
38
|
+
save_change,
|
|
39
|
+
save_handoff,
|
|
40
|
+
save_introduction,
|
|
41
|
+
save_links,
|
|
42
|
+
save_plan,
|
|
43
|
+
save_question,
|
|
44
|
+
save_requirements,
|
|
45
|
+
save_run,
|
|
46
|
+
save_task,
|
|
47
|
+
save_todos,
|
|
48
|
+
task_lock_path,
|
|
49
|
+
)
|
|
50
|
+
from taskledger.storage.task_store import list_changes as list_v2_changes
|
|
51
|
+
from taskledger.storage.task_store import list_handoffs as list_v2_handoffs
|
|
52
|
+
from taskledger.storage.task_store import list_introductions as list_v2_introductions
|
|
53
|
+
from taskledger.storage.task_store import list_plans as list_v2_plans
|
|
54
|
+
from taskledger.storage.task_store import list_questions as list_v2_questions
|
|
55
|
+
from taskledger.storage.task_store import list_runs as list_v2_runs
|
|
56
|
+
from taskledger.storage.task_store import list_tasks as list_v2_tasks
|
|
57
|
+
from taskledger.storage.task_store import load_links as load_v2_links
|
|
58
|
+
from taskledger.storage.task_store import load_requirements as load_v2_requirements
|
|
59
|
+
from taskledger.storage.task_store import load_todos as load_v2_todos
|
|
60
|
+
from taskledger.timeutils import utc_now_iso
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def export_project_payload(
|
|
64
|
+
workspace_root: Path,
|
|
65
|
+
*,
|
|
66
|
+
include_bodies: bool = False,
|
|
67
|
+
include_run_artifacts: bool = False,
|
|
68
|
+
) -> dict[str, object]:
|
|
69
|
+
v2_payload = _export_v2_payload(workspace_root)
|
|
70
|
+
return {
|
|
71
|
+
"kind": "taskledger_export",
|
|
72
|
+
"version": 2,
|
|
73
|
+
"schema_version": 2,
|
|
74
|
+
"generated_at": utc_now_iso(),
|
|
75
|
+
"project_dir": str(resolve_v2_paths(workspace_root).project_dir),
|
|
76
|
+
"options": {
|
|
77
|
+
"include_bodies": include_bodies,
|
|
78
|
+
"include_run_artifacts": include_run_artifacts,
|
|
79
|
+
},
|
|
80
|
+
"counts": {
|
|
81
|
+
key: len(_dict_list(value))
|
|
82
|
+
for key, value in v2_payload.items()
|
|
83
|
+
if isinstance(value, list)
|
|
84
|
+
},
|
|
85
|
+
"v2": v2_payload,
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def parse_project_import_payload(text: str, *, format_name: str) -> dict[str, object]:
|
|
90
|
+
if format_name != "json":
|
|
91
|
+
raise LaunchError(f"Unsupported project import format: {format_name}")
|
|
92
|
+
try:
|
|
93
|
+
payload = json.loads(text)
|
|
94
|
+
except json.JSONDecodeError as exc:
|
|
95
|
+
raise LaunchError(f"Invalid project import JSON: {exc}") from exc
|
|
96
|
+
if not isinstance(payload, dict):
|
|
97
|
+
raise LaunchError("Project import JSON must be an object.")
|
|
98
|
+
result: dict[str, object] = payload
|
|
99
|
+
if payload.get("success") is True and isinstance(payload.get("data"), dict):
|
|
100
|
+
candidate = payload["data"]
|
|
101
|
+
if candidate.get("kind") in {"taskledger_export", "project_export"}:
|
|
102
|
+
result = candidate
|
|
103
|
+
if payload.get("ok") is True and isinstance(payload.get("result"), dict):
|
|
104
|
+
candidate = payload["result"]
|
|
105
|
+
if candidate.get("kind") in {"taskledger_export", "project_export"}:
|
|
106
|
+
result = candidate
|
|
107
|
+
if result.get("kind") not in {None, "taskledger_export", "project_export"}:
|
|
108
|
+
raise LaunchError("Unsupported project import payload kind.")
|
|
109
|
+
return result
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def import_project_payload(
|
|
113
|
+
workspace_root: Path,
|
|
114
|
+
*,
|
|
115
|
+
payload: dict[str, object],
|
|
116
|
+
replace: bool,
|
|
117
|
+
) -> dict[str, object]:
|
|
118
|
+
paths = ensure_v2_layout(workspace_root)
|
|
119
|
+
if replace:
|
|
120
|
+
_clear_v2_state(paths)
|
|
121
|
+
_import_v2_payload(workspace_root, payload)
|
|
122
|
+
counts = rebuild_v2_indexes(paths)
|
|
123
|
+
return {
|
|
124
|
+
"kind": "taskledger_import",
|
|
125
|
+
"replace": replace,
|
|
126
|
+
"counts": counts,
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def write_project_snapshot(
|
|
131
|
+
workspace_root: Path,
|
|
132
|
+
*,
|
|
133
|
+
output_dir: Path,
|
|
134
|
+
include_bodies: bool,
|
|
135
|
+
include_run_artifacts: bool,
|
|
136
|
+
) -> dict[str, object]:
|
|
137
|
+
payload = export_project_payload(
|
|
138
|
+
workspace_root,
|
|
139
|
+
include_bodies=include_bodies,
|
|
140
|
+
include_run_artifacts=include_run_artifacts,
|
|
141
|
+
)
|
|
142
|
+
timestamp = utc_now_iso().replace(":", "-").replace("+00:00", "Z")
|
|
143
|
+
snapshot_dir = output_dir / f"taskledger-snapshot-{timestamp}"
|
|
144
|
+
snapshot_dir.mkdir(parents=True, exist_ok=False)
|
|
145
|
+
export_path = snapshot_dir / "taskledger-export.json"
|
|
146
|
+
export_path.write_text(
|
|
147
|
+
json.dumps(payload, indent=2, sort_keys=True) + "\n",
|
|
148
|
+
encoding="utf-8",
|
|
149
|
+
)
|
|
150
|
+
return {
|
|
151
|
+
"kind": "taskledger_snapshot",
|
|
152
|
+
"snapshot_dir": str(snapshot_dir),
|
|
153
|
+
"export_path": str(export_path),
|
|
154
|
+
"include_bodies": include_bodies,
|
|
155
|
+
"include_run_artifacts": include_run_artifacts,
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _dict_list(value: object) -> list[dict[str, object]]:
|
|
160
|
+
if not isinstance(value, list):
|
|
161
|
+
return []
|
|
162
|
+
return [item for item in value if isinstance(item, dict)]
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _export_v2_payload(workspace_root: Path) -> dict[str, object]:
|
|
166
|
+
tasks = list_v2_tasks(workspace_root)
|
|
167
|
+
introductions = list_v2_introductions(workspace_root)
|
|
168
|
+
return {
|
|
169
|
+
"tasks": [item.to_dict() for item in tasks],
|
|
170
|
+
"active_task": (
|
|
171
|
+
active_state.to_dict()
|
|
172
|
+
if (active_state := load_active_task_state(workspace_root)) is not None
|
|
173
|
+
else None
|
|
174
|
+
),
|
|
175
|
+
"introductions": [item.to_dict() for item in introductions],
|
|
176
|
+
"plans": [
|
|
177
|
+
plan.to_dict()
|
|
178
|
+
for task in tasks
|
|
179
|
+
for plan in list_v2_plans(workspace_root, task.id)
|
|
180
|
+
],
|
|
181
|
+
"questions": [
|
|
182
|
+
question.to_dict()
|
|
183
|
+
for task in tasks
|
|
184
|
+
for question in list_v2_questions(workspace_root, task.id)
|
|
185
|
+
],
|
|
186
|
+
"runs": [
|
|
187
|
+
run.to_dict()
|
|
188
|
+
for task in tasks
|
|
189
|
+
for run in list_v2_runs(workspace_root, task.id)
|
|
190
|
+
],
|
|
191
|
+
"changes": [
|
|
192
|
+
change.to_dict()
|
|
193
|
+
for task in tasks
|
|
194
|
+
for change in list_v2_changes(workspace_root, task.id)
|
|
195
|
+
],
|
|
196
|
+
"handoffs": [
|
|
197
|
+
handoff.to_dict()
|
|
198
|
+
for task in tasks
|
|
199
|
+
for handoff in list_v2_handoffs(workspace_root, task.id)
|
|
200
|
+
],
|
|
201
|
+
"todos": [
|
|
202
|
+
todo.to_dict()
|
|
203
|
+
for task in tasks
|
|
204
|
+
for todo in load_v2_todos(workspace_root, task.id).todos
|
|
205
|
+
],
|
|
206
|
+
"links": [
|
|
207
|
+
link.to_dict()
|
|
208
|
+
for task in tasks
|
|
209
|
+
for link in load_v2_links(workspace_root, task.id).links
|
|
210
|
+
],
|
|
211
|
+
"requirements": [
|
|
212
|
+
req.to_dict()
|
|
213
|
+
for task in tasks
|
|
214
|
+
for req in load_v2_requirements(workspace_root, task.id).requirements
|
|
215
|
+
],
|
|
216
|
+
"locks": [item.to_dict() for item in load_active_locks(workspace_root)],
|
|
217
|
+
"events": [
|
|
218
|
+
item.to_dict()
|
|
219
|
+
for item in load_events(resolve_v2_paths(workspace_root).events_dir)
|
|
220
|
+
],
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def _import_standalone_collections(
|
|
225
|
+
raw_v2: dict[str, object], workspace_root: Path
|
|
226
|
+
) -> None:
|
|
227
|
+
"""Import per-record collections from newer exports."""
|
|
228
|
+
todos_by_task: dict[str, list] = {}
|
|
229
|
+
for item in _dict_list(raw_v2.get("todos")):
|
|
230
|
+
tid = str(item.get("task_id") or "")
|
|
231
|
+
todos_by_task.setdefault(tid, []).append(item)
|
|
232
|
+
for tid, items in todos_by_task.items():
|
|
233
|
+
save_todos(
|
|
234
|
+
workspace_root,
|
|
235
|
+
TodoCollection(
|
|
236
|
+
task_id=tid,
|
|
237
|
+
todos=tuple(TaskTodo.from_dict(i) for i in items),
|
|
238
|
+
),
|
|
239
|
+
)
|
|
240
|
+
links_by_task: dict[str, list] = {}
|
|
241
|
+
for item in _dict_list(raw_v2.get("links")):
|
|
242
|
+
tid = str(item.get("task_id") or "")
|
|
243
|
+
links_by_task.setdefault(tid, []).append(item)
|
|
244
|
+
for tid, items in links_by_task.items():
|
|
245
|
+
save_links(
|
|
246
|
+
workspace_root,
|
|
247
|
+
LinkCollection(
|
|
248
|
+
task_id=tid,
|
|
249
|
+
links=tuple(FileLink.from_dict(i) for i in items),
|
|
250
|
+
),
|
|
251
|
+
)
|
|
252
|
+
reqs_by_task: dict[str, list] = {}
|
|
253
|
+
for item in _dict_list(raw_v2.get("requirements")):
|
|
254
|
+
tid = str(item.get("task_id") or item.get("parent_task_id") or "")
|
|
255
|
+
reqs_by_task.setdefault(tid, []).append(item)
|
|
256
|
+
for tid, items in reqs_by_task.items():
|
|
257
|
+
save_requirements(
|
|
258
|
+
workspace_root,
|
|
259
|
+
RequirementCollection(
|
|
260
|
+
task_id=tid,
|
|
261
|
+
requirements=tuple(DependencyRequirement.from_dict(i) for i in items),
|
|
262
|
+
),
|
|
263
|
+
)
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def _import_v2_payload(workspace_root: Path, payload: dict[str, object]) -> None:
|
|
267
|
+
raw_v2 = payload.get("v2")
|
|
268
|
+
if not isinstance(raw_v2, dict):
|
|
269
|
+
raise LaunchError("Import payload is missing v2 task state.")
|
|
270
|
+
paths = resolve_v2_paths(workspace_root)
|
|
271
|
+
for item in _dict_list(raw_v2.get("tasks")):
|
|
272
|
+
task = TaskRecord.from_dict(item)
|
|
273
|
+
save_task(workspace_root, task)
|
|
274
|
+
# Import per-record collections from embedded task data
|
|
275
|
+
if task.todos:
|
|
276
|
+
save_todos(
|
|
277
|
+
workspace_root,
|
|
278
|
+
TodoCollection(task_id=task.id, todos=task.todos),
|
|
279
|
+
)
|
|
280
|
+
if task.file_links:
|
|
281
|
+
save_links(
|
|
282
|
+
workspace_root,
|
|
283
|
+
LinkCollection(task_id=task.id, links=task.file_links),
|
|
284
|
+
)
|
|
285
|
+
if task.requirements:
|
|
286
|
+
save_requirements(
|
|
287
|
+
workspace_root,
|
|
288
|
+
RequirementCollection(
|
|
289
|
+
task_id=task.id,
|
|
290
|
+
requirements=tuple(
|
|
291
|
+
DependencyRequirement(task_id=r) for r in task.requirements
|
|
292
|
+
),
|
|
293
|
+
),
|
|
294
|
+
)
|
|
295
|
+
# Import standalone per-record collections (from newer exports)
|
|
296
|
+
_import_standalone_collections(raw_v2, workspace_root)
|
|
297
|
+
active_task = raw_v2.get("active_task")
|
|
298
|
+
if active_task is not None:
|
|
299
|
+
state = ActiveTaskState.from_dict(active_task)
|
|
300
|
+
if not any(task.id == state.task_id for task in list_v2_tasks(workspace_root)):
|
|
301
|
+
raise LaunchError(
|
|
302
|
+
f"Import active task points to missing task: {state.task_id}"
|
|
303
|
+
)
|
|
304
|
+
save_active_task_state(workspace_root, state)
|
|
305
|
+
for item in _dict_list(raw_v2.get("introductions")):
|
|
306
|
+
save_introduction(workspace_root, IntroductionRecord.from_dict(item))
|
|
307
|
+
for item in _dict_list(raw_v2.get("plans")):
|
|
308
|
+
plan = PlanRecord.from_dict(item)
|
|
309
|
+
if plan_markdown_path(paths, plan.task_id, plan.plan_version).exists():
|
|
310
|
+
overwrite_plan(workspace_root, plan)
|
|
311
|
+
else:
|
|
312
|
+
save_plan(workspace_root, plan)
|
|
313
|
+
for item in _dict_list(raw_v2.get("questions")):
|
|
314
|
+
save_question(workspace_root, QuestionRecord.from_dict(item))
|
|
315
|
+
for item in _dict_list(raw_v2.get("runs")):
|
|
316
|
+
save_run(workspace_root, TaskRunRecord.from_dict(item))
|
|
317
|
+
for item in _dict_list(raw_v2.get("changes")):
|
|
318
|
+
save_change(workspace_root, CodeChangeRecord.from_dict(item))
|
|
319
|
+
for item in _dict_list(raw_v2.get("handoffs")):
|
|
320
|
+
save_handoff(workspace_root, TaskHandoffRecord.from_dict(item))
|
|
321
|
+
for item in _dict_list(raw_v2.get("locks")):
|
|
322
|
+
lock = TaskLock.from_dict(item)
|
|
323
|
+
write_lock(task_lock_path(paths, lock.task_id), lock)
|
|
324
|
+
if paths.events_dir.exists():
|
|
325
|
+
for path in paths.events_dir.glob("*.ndjson"):
|
|
326
|
+
path.unlink()
|
|
327
|
+
for item in _dict_list(raw_v2.get("events")):
|
|
328
|
+
append_event(paths.events_dir, TaskEvent.from_dict(item))
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def _clear_v2_state(paths: V2Paths) -> None:
|
|
332
|
+
for directory in paths.tasks_dir.glob("task-*"):
|
|
333
|
+
if directory.is_dir():
|
|
334
|
+
shutil.rmtree(directory)
|
|
335
|
+
for directory in (
|
|
336
|
+
paths.introductions_dir,
|
|
337
|
+
paths.events_dir,
|
|
338
|
+
):
|
|
339
|
+
if directory.exists():
|
|
340
|
+
shutil.rmtree(directory)
|
|
341
|
+
directory.mkdir(parents=True, exist_ok=True)
|
|
342
|
+
if paths.active_task_path.exists():
|
|
343
|
+
paths.active_task_path.unlink()
|
taskledger/ids.py
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def next_project_id(prefix: str, existing_ids: list[str]) -> str:
|
|
7
|
+
pattern = re.compile(rf"^{re.escape(prefix)}-(\d+)$")
|
|
8
|
+
max_value = 0
|
|
9
|
+
for item in existing_ids:
|
|
10
|
+
match = pattern.match(item)
|
|
11
|
+
if match is None:
|
|
12
|
+
continue
|
|
13
|
+
max_value = max(max_value, int(match.group(1)))
|
|
14
|
+
return f"{prefix}-{max_value + 1:04d}"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def slugify_project_ref(value: str, *, empty: str = "item") -> str:
|
|
18
|
+
normalized = re.sub(r"[^a-z0-9]+", "-", value.lower()).strip("-")
|
|
19
|
+
return normalized or empty
|
taskledger/py.typed
ADDED
|
File without changes
|