taskops-cli 0.2.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 (193) hide show
  1. taskops/__init__.py +39 -0
  2. taskops/_clock.py +32 -0
  3. taskops/_errors.py +160 -0
  4. taskops/_ids.py +63 -0
  5. taskops/_types.py +106 -0
  6. taskops/_version.py +7 -0
  7. taskops/assets/GUIDE.md +220 -0
  8. taskops/contracts/__init__.py +96 -0
  9. taskops/contracts/_fields.py +113 -0
  10. taskops/contracts/actor.py +30 -0
  11. taskops/contracts/board.py +142 -0
  12. taskops/contracts/commit.py +36 -0
  13. taskops/contracts/day.py +150 -0
  14. taskops/contracts/dep.py +23 -0
  15. taskops/contracts/event.py +58 -0
  16. taskops/contracts/gitstate.py +45 -0
  17. taskops/contracts/index.py +37 -0
  18. taskops/contracts/lease.py +43 -0
  19. taskops/contracts/log.py +59 -0
  20. taskops/contracts/remote.py +48 -0
  21. taskops/contracts/results.py +84 -0
  22. taskops/contracts/task.py +94 -0
  23. taskops/contracts/tools.py +110 -0
  24. taskops/contracts/wire.py +60 -0
  25. taskops/engine/__init__.py +34 -0
  26. taskops/engine/_blocks.py +48 -0
  27. taskops/engine/_briefs.py +92 -0
  28. taskops/engine/_chunks.py +66 -0
  29. taskops/engine/_closed.py +65 -0
  30. taskops/engine/_entries.py +126 -0
  31. taskops/engine/_events.py +55 -0
  32. taskops/engine/_opened.py +51 -0
  33. taskops/engine/_process.py +80 -0
  34. taskops/engine/_prompts.py +98 -0
  35. taskops/engine/_stream.py +129 -0
  36. taskops/engine/activity.py +94 -0
  37. taskops/engine/bus.py +42 -0
  38. taskops/engine/commitline.py +110 -0
  39. taskops/engine/day.py +142 -0
  40. taskops/engine/diffstat.py +63 -0
  41. taskops/engine/gitio.py +108 -0
  42. taskops/engine/gitstate.py +96 -0
  43. taskops/engine/history.py +76 -0
  44. taskops/engine/identity.py +84 -0
  45. taskops/engine/log.py +68 -0
  46. taskops/engine/machine.py +160 -0
  47. taskops/engine/narrate.py +91 -0
  48. taskops/engine/project.py +57 -0
  49. taskops/engine/replay.py +142 -0
  50. taskops/engine/reports.py +67 -0
  51. taskops/engine/scheduler.py +135 -0
  52. taskops/engine/transcript.py +127 -0
  53. taskops/engine/wire.py +92 -0
  54. taskops/engine/worker.py +115 -0
  55. taskops/py.typed +0 -0
  56. taskops/render/__init__.py +40 -0
  57. taskops/render/_closed_days.py +64 -0
  58. taskops/render/_dossier.py +68 -0
  59. taskops/render/_opened.py +64 -0
  60. taskops/render/_sections.py +51 -0
  61. taskops/render/_tasklist.py +72 -0
  62. taskops/render/_text.py +74 -0
  63. taskops/render/_verbatim.py +65 -0
  64. taskops/render/ansi.py +92 -0
  65. taskops/render/board.py +55 -0
  66. taskops/render/day.py +102 -0
  67. taskops/render/dispatch.py +104 -0
  68. taskops/render/inbox.py +27 -0
  69. taskops/render/log.py +47 -0
  70. taskops/render/recover.py +64 -0
  71. taskops/render/report.py +51 -0
  72. taskops/render/reports.py +57 -0
  73. taskops/render/results.py +96 -0
  74. taskops/render/session.py +75 -0
  75. taskops/render/task.py +88 -0
  76. taskops/render/tasklist.py +65 -0
  77. taskops/storage/__init__.py +36 -0
  78. taskops/storage/_ddl.py +78 -0
  79. taskops/storage/_delivered.py +51 -0
  80. taskops/storage/_deps.py +69 -0
  81. taskops/storage/_events.py +127 -0
  82. taskops/storage/_leases.py +108 -0
  83. taskops/storage/_rows.py +90 -0
  84. taskops/storage/_tasks.py +124 -0
  85. taskops/storage/locate.py +76 -0
  86. taskops/storage/schema.py +66 -0
  87. taskops/storage/store.py +120 -0
  88. taskops/storage/sync.py +139 -0
  89. taskops/transports/__init__.py +6 -0
  90. taskops/transports/cli/__init__.py +0 -0
  91. taskops/transports/cli/commands/__init__.py +3 -0
  92. taskops/transports/cli/commands/_digest.py +64 -0
  93. taskops/transports/cli/commands/_serve_init.py +65 -0
  94. taskops/transports/cli/commands/_shared.py +50 -0
  95. taskops/transports/cli/commands/_tasks_args.py +112 -0
  96. taskops/transports/cli/commands/_window.py +45 -0
  97. taskops/transports/cli/commands/ask.py +27 -0
  98. taskops/transports/cli/commands/dispatch.py +43 -0
  99. taskops/transports/cli/commands/init.py +48 -0
  100. taskops/transports/cli/commands/log.py +22 -0
  101. taskops/transports/cli/commands/plan.py +43 -0
  102. taskops/transports/cli/commands/pushpull.py +53 -0
  103. taskops/transports/cli/commands/recover.py +30 -0
  104. taskops/transports/cli/commands/remote.py +56 -0
  105. taskops/transports/cli/commands/report.py +82 -0
  106. taskops/transports/cli/commands/run_.py +60 -0
  107. taskops/transports/cli/commands/serve.py +74 -0
  108. taskops/transports/cli/commands/sync.py +22 -0
  109. taskops/transports/cli/commands/tasks.py +79 -0
  110. taskops/transports/cli/commands/ui.py +72 -0
  111. taskops/transports/cli/commands/update.py +25 -0
  112. taskops/transports/cli/main.py +85 -0
  113. taskops/transports/hooks/__init__.py +15 -0
  114. taskops/transports/hooks/__main__.py +63 -0
  115. taskops/transports/hooks/_args.py +28 -0
  116. taskops/transports/hooks/claude.py +78 -0
  117. taskops/transports/hooks/commit.py +61 -0
  118. taskops/transports/hooks/events.py +107 -0
  119. taskops/transports/hooks/record.py +55 -0
  120. taskops/transports/http/__init__.py +21 -0
  121. taskops/transports/http/_handler.py +132 -0
  122. taskops/transports/http/_wire.py +79 -0
  123. taskops/transports/http/_wsframes.py +116 -0
  124. taskops/transports/http/agentapi.py +69 -0
  125. taskops/transports/http/api.py +122 -0
  126. taskops/transports/http/exchange.py +80 -0
  127. taskops/transports/http/live.py +160 -0
  128. taskops/transports/http/policy.py +110 -0
  129. taskops/transports/http/projects.py +116 -0
  130. taskops/transports/http/reports.py +75 -0
  131. taskops/transports/http/router.py +90 -0
  132. taskops/transports/http/server.py +50 -0
  133. taskops/transports/http/static.py +86 -0
  134. taskops/transports/http/ui/app.js +59 -0
  135. taskops/transports/http/ui/index.html +23 -0
  136. taskops/transports/http/ui/style.css +1 -0
  137. taskops/transports/http/websocket.py +60 -0
  138. taskops/transports/mcp/__init__.py +19 -0
  139. taskops/transports/mcp/__main__.py +7 -0
  140. taskops/transports/mcp/_descriptions.py +102 -0
  141. taskops/transports/mcp/_reads.py +82 -0
  142. taskops/transports/mcp/_writes.py +72 -0
  143. taskops/transports/mcp/answers.py +44 -0
  144. taskops/transports/mcp/arguments.py +104 -0
  145. taskops/transports/mcp/dispatch.py +47 -0
  146. taskops/transports/mcp/protocol.py +83 -0
  147. taskops/transports/mcp/schema.py +50 -0
  148. taskops/transports/mcp/server.py +49 -0
  149. taskops/transports/mcp/tools.py +66 -0
  150. taskops/usecases/__init__.py +56 -0
  151. taskops/usecases/_entry.py +84 -0
  152. taskops/usecases/_facts.py +49 -0
  153. taskops/usecases/_freeing.py +93 -0
  154. taskops/usecases/_gitignore.py +94 -0
  155. taskops/usecases/_mirroring.py +57 -0
  156. taskops/usecases/_narrating.py +77 -0
  157. taskops/usecases/_project.py +67 -0
  158. taskops/usecases/_range.py +102 -0
  159. taskops/usecases/_reasons.py +54 -0
  160. taskops/usecases/_remotefile.py +62 -0
  161. taskops/usecases/_reportsync.py +108 -0
  162. taskops/usecases/_routing.py +91 -0
  163. taskops/usecases/_wireclient.py +141 -0
  164. taskops/usecases/ask.py +41 -0
  165. taskops/usecases/claim.py +104 -0
  166. taskops/usecases/dispatch.py +160 -0
  167. taskops/usecases/dossier.py +155 -0
  168. taskops/usecases/edit.py +71 -0
  169. taskops/usecases/exchange.py +86 -0
  170. taskops/usecases/feed.py +138 -0
  171. taskops/usecases/guard.py +122 -0
  172. taskops/usecases/hooks.py +127 -0
  173. taskops/usecases/index.py +62 -0
  174. taskops/usecases/ingest.py +60 -0
  175. taskops/usecases/log.py +134 -0
  176. taskops/usecases/narration.py +91 -0
  177. taskops/usecases/plan.py +128 -0
  178. taskops/usecases/pushpull.py +119 -0
  179. taskops/usecases/recover.py +84 -0
  180. taskops/usecases/remote.py +78 -0
  181. taskops/usecases/report.py +87 -0
  182. taskops/usecases/reportfile.py +106 -0
  183. taskops/usecases/session.py +134 -0
  184. taskops/usecases/setup.py +92 -0
  185. taskops/usecases/sync.py +78 -0
  186. taskops/usecases/update.py +123 -0
  187. taskops/usecases/view.py +112 -0
  188. taskops_cli-0.2.0.dist-info/METADATA +291 -0
  189. taskops_cli-0.2.0.dist-info/RECORD +193 -0
  190. taskops_cli-0.2.0.dist-info/WHEEL +5 -0
  191. taskops_cli-0.2.0.dist-info/entry_points.txt +2 -0
  192. taskops_cli-0.2.0.dist-info/licenses/LICENSE +21 -0
  193. taskops_cli-0.2.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,134 @@
1
+ """`taskops log <task>` — what the agent actually said while working on a card.
2
+
3
+ The card already knows enough to find it without any new bookkeeping: `dispatch` puts every worker in
4
+ `<repo>/.taskops/trees/<id>/`, and Claude Code names a transcript directory after the working directory
5
+ it ran in. So the lookup is a path computation, and it works on transcripts written before taskops knew
6
+ it would ever read them — the axion-v3 workers' conversations were recoverable after the fact.
7
+
8
+ Two directories are searched, in this order:
9
+
10
+ 1. **the card's worktree** — one directory per card, so everything in it belongs to this card
11
+ 2. **the repository itself** — shared by every session anybody ran there, so it needs a filter
12
+
13
+ For (2) there are two ways to prove an entry belongs, and the path computation alone is not enough. A
14
+ dispatched agent makes a branch, so `gitBranch` identifies it. A person who claimed a card in their own
15
+ terminal usually stays on `main`, and every one of their entries would be discarded — so the card also
16
+ remembers WHICH sessions worked it, and a transcript named by a recorded session id is read whole.
17
+
18
+ Nothing is copied into the event log. A transcript is 200 KB for 40 turns, `events.jsonl` is committed,
19
+ and the value of that file is that a human can read its diff. The consequence is stated plainly in the
20
+ contract: a transcript is LOCAL, and a teammate who pulls gets the card and the commits but not this.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ from pathlib import Path
26
+
27
+ from ..contracts import LogEntry, SessionLog
28
+ from ..engine import branch_for
29
+ from ..engine._entries import flatten
30
+ from ..engine.transcript import directories_for, home, read_entries
31
+ from ..engine.worker import worktree_for
32
+ from ..storage import Store
33
+ from ._project import project
34
+
35
+ __all__ = ["session_log", "MAX_ENTRIES"]
36
+
37
+ MAX_ENTRIES = 400
38
+ """Entries kept, newest last. A long session runs to thousands, and a viewer wants the shape of the
39
+ work plus how it ended — so when it truncates, it keeps the END and says so."""
40
+
41
+
42
+ def session_log(start: Path | str, task_id: str, *, limit: int = MAX_ENTRIES) -> SessionLog:
43
+ """The conversation for one card, oldest first."""
44
+ with project(start) as store:
45
+ task = store.tasks.need(task_id)
46
+ branch = branch_for(task)
47
+ tree = worktree_for(store.root, task)
48
+ places = directories_for(store.root, tree)
49
+ if not places:
50
+ return _empty(task_id, _nowhere(store, tree))
51
+ entries = _gather(places, tree, branch, _recorded(store, task_id))
52
+ if not entries:
53
+ return _empty(task_id, _silent(store, task_id, places))
54
+ return _bounded(task_id, entries, ", ".join(str(p) for p in places), limit)
55
+
56
+
57
+ def _recorded(store: Store, task_id: str) -> tuple[str, ...]:
58
+ """The session ids known to have worked this card — from its live lease and from its own history.
59
+
60
+ The lease is the current holder; the events are every past one, which matters because a card that
61
+ was released and re-claimed has more than one conversation and both are worth reading.
62
+ """
63
+ held = store.leases.get(task_id)
64
+ found = [held["session"]] if held else []
65
+ found += [str(event["body"].get("session", ""))
66
+ for event in store.events.of_task(task_id)]
67
+ return tuple(dict.fromkeys(name for name in found if name))
68
+
69
+
70
+ def _gather(places: list[Path], tree: Path, branch: str,
71
+ sessions: tuple[str, ...]) -> list[LogEntry]:
72
+ """Read every place, flatten every line, then order by time.
73
+
74
+ Sorted at the END rather than per file: two sessions on one card can overlap, and a viewer showing
75
+ them concatenated would imply an order that did not happen.
76
+ """
77
+ out: list[LogEntry] = []
78
+ for place in places:
79
+ # The worktree's directory holds only this card's sessions, so it needs no filter. The
80
+ # repository's is shared, so an entry has to prove it belongs by naming the card's branch.
81
+ wanted = "" if place.name.endswith(tree.name) else branch
82
+ for raw in read_entries(place, branch=wanted, sessions=sessions):
83
+ out += flatten(raw)
84
+ return sorted(out, key=lambda entry: entry["ts"])
85
+
86
+
87
+ def _bounded(task_id: str, entries: list[LogEntry], source: str,
88
+ limit: int) -> SessionLog:
89
+ """Keep the LAST `limit` entries, and say when that happened.
90
+
91
+ The end, not the beginning: a person opening a card's log is usually asking how it went, and the
92
+ answer is in the last few turns. Silence about the truncation would be a lie about the ending.
93
+ """
94
+ kept = entries[-limit:] if len(entries) > limit else entries
95
+ return SessionLog(task=task_id, sessions=_sessions(kept), entries=kept,
96
+ source=source, truncated=len(entries) > limit)
97
+
98
+
99
+ def _sessions(entries: list[LogEntry]) -> list[str]:
100
+ """The session ids present, in the order they first appear. More than one is normal — a card can be
101
+ released and picked up again, and each attempt is its own session."""
102
+ return list(dict.fromkeys(entry["session"] for entry in entries if entry["session"]))
103
+
104
+
105
+ def _empty(task_id: str, why: str) -> SessionLog:
106
+ return SessionLog(task=task_id, sessions=[], entries=[], source=why, truncated=False)
107
+
108
+
109
+ def _silent(store: Store, task_id: str, places: list[Path]) -> str:
110
+ """The directory exists and holds nothing for this card. Say which of the two reasons it is.
111
+
112
+ An empty pane that only prints a path reads as a broken viewer — it was reported as one. The
113
+ difference that matters to the reader: nobody ever worked this card in a Claude Code session (there
114
+ is nothing to show, and that is fine), versus somebody did but the entries cannot be attributed,
115
+ which happens for interactive work done before the card started recording its sessions.
116
+ """
117
+ looked = ", ".join(str(place) for place in places)
118
+ if _recorded(store, task_id):
119
+ return (f"a session is recorded for this card but none of its entries were found in {looked}. "
120
+ f"The transcript may have been written under a different Claude Code home.")
121
+ return ("no Claude Code session is recorded against this card, so there is no conversation to "
122
+ f"show. Sessions are recorded from the claim onwards; searched {looked}.")
123
+
124
+
125
+ def _nowhere(store: Store, tree: Path) -> str:
126
+ """Why there is nothing, specifically enough to act on.
127
+
128
+ A viewer that shows an empty pane cannot distinguish "this agent said nothing" from "taskops looked
129
+ in the wrong place", and the second has a fix — which is usually `$CLAUDE_CONFIG_DIR`, because a
130
+ second Claude Code home is exactly how the first version of this found nothing at all.
131
+ """
132
+ return (f"no transcript directory for this card. Looked under {home() / 'projects'} for "
133
+ f"{tree} and {store.root}. If Claude Code stores its config elsewhere, "
134
+ f"$CLAUDE_CONFIG_DIR is what points here.")
@@ -0,0 +1,91 @@
1
+ """Narrating a report IN THE BACKGROUND, with the prose on the wire as it is written.
2
+
3
+ The user's report was "aprieto Generate y no hace nada". Nothing was broken: the POST was a
4
+ multi-minute call to a model behind a mute spinner, the file said `_pendiente_` for all of it,
5
+ and a browser that gave up on the request took the only feedback there was with it.
6
+
7
+ So the request stops carrying the work. `start` spawns the digest and returns immediately; what
8
+ happens next arrives on the socket the board already holds, as `WireMessage`s — a pass
9
+ announcement, the prose in fragments, and one terminal frame either way. The engine already
10
+ streamed all of this (`engine.narrate`'s `on_pass`/`on_text`, which the terminal has used since
11
+ `report --digest` learned to show itself); the only thing missing was somewhere to send it.
12
+
13
+ **A delta is never an event.** It goes on `WIRE`, not on `BUS`, and never near the database —
14
+ `events.jsonl` is committed and its worth is that a human can read its diff.
15
+
16
+ One at a time per report. Two models rewriting the same file is guaranteed corruption, so a
17
+ second request is refused rather than queued: the first is already streaming to the person who
18
+ asked, and a queued duplicate would only overwrite it later.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import threading
24
+ from pathlib import Path
25
+
26
+ from .._errors import AlreadyNarrating
27
+ from ..contracts import WireMessage
28
+ from ..engine import WIRE
29
+ from ..storage import resolve_root
30
+ from ._range import Selector
31
+ from .dossier import digest
32
+
33
+ __all__ = ["start", "running"]
34
+
35
+ _lock = threading.Lock()
36
+ _running: set[str] = set()
37
+ """Report labels being narrated right now, in THIS process. Process-wide state, like the
38
+ channel it publishes to, and for the same reason: the digest runs inside the studio's own
39
+ server, so the only narrations this can collide with are the ones it started itself."""
40
+
41
+
42
+ def start(root: Path | str, label: str, *, force: bool = False, model: str = "") -> str:
43
+ """Begin narrating `label` and return at once. Raises `AlreadyNarrating` on a duplicate.
44
+
45
+ The caller gets no prose from this — that is the point. It arrives on the wire.
46
+ """
47
+ # Resolved HERE, on the caller's thread, and carried into every frame the run publishes.
48
+ # The channel is process-global: without the origin stamped on each message, a server
49
+ # holding several boards would show this project's prose on all of them. Resolving before
50
+ # the thread starts also means a bad root is an exception the caller sees, not a traceback
51
+ # in the server's stderr.
52
+ origin = str(resolve_root(root))
53
+ with _lock:
54
+ if label in _running:
55
+ raise AlreadyNarrating(f"{label} is being narrated right now — watch it arrive, "
56
+ f"or wait for that one to finish before regenerating")
57
+ _running.add(label)
58
+ worker = threading.Thread(target=_run, args=(root, origin, label, force, model),
59
+ name=f"narrate-{label}", daemon=True)
60
+ # Daemon: a narration is a paid-for convenience, never a reason a server refuses to exit.
61
+ # The dossier is already on disk, and the next run picks up from the file.
62
+ worker.start()
63
+ return label
64
+
65
+
66
+ def running() -> frozenset[str]:
67
+ """Which reports are being narrated. Read by tests and by anything wanting to say so."""
68
+ with _lock:
69
+ return frozenset(_running)
70
+
71
+
72
+ def _run(root: Path | str, origin: str, label: str, force: bool, model: str) -> None:
73
+ """The digest, with every callback turned into a frame. Never raises: it IS the top of a
74
+ thread, so an escaping exception would print a traceback into the server's stderr and tell
75
+ the person watching nothing at all."""
76
+ try:
77
+ digest(root, Selector(date=label), model=model, force=force,
78
+ on_pass=lambda n, total: _say(origin, "narration.pass", label, f"{n}/{total}"),
79
+ on_text=lambda text: _say(origin, "narration.delta", label, text))
80
+ _say(origin, "narration.done", label, "")
81
+ except Exception as err: # noqa: BLE001 — the reader is the error's audience
82
+ # Verbatim. `claude` missing or logged out is a thing the person can fix in a minute,
83
+ # and only if the sentence reaches the screen instead of becoming "failed".
84
+ _say(origin, "narration.failed", label, str(err) or err.__class__.__name__)
85
+ finally:
86
+ with _lock:
87
+ _running.discard(label)
88
+
89
+
90
+ def _say(origin: str, kind: str, label: str, text: str) -> None:
91
+ WIRE.publish(WireMessage(kind=kind, label=label, text=text, root=origin))
@@ -0,0 +1,128 @@
1
+ """Decomposition: a whole plan — tasks, tree and DAG — created in one call.
2
+
3
+ One call and not N, because the graph is the point. An agent that creates five tasks
4
+ with five calls has to invent its own scheme for referring to the ones it just made, and
5
+ the dependencies land in a second pass a context limit can cut short. Here `after`
6
+ accepts the INDEX of an earlier entry in the same batch, so the plan arrives whole or not
7
+ at all.
8
+
9
+ taskops does not decompose anything itself. The session calling this already read the
10
+ code and thought about the work; asking a second model to re-derive that from a title
11
+ would be worse and slower. What this owns is that the result is persistent, ordered, and
12
+ legible to every other agent — including ones on other machines.
13
+
14
+ Field reading lives in `_entry`: this module builds a graph, that one decides what a
15
+ model meant by a field it spelled its own way.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ from pathlib import Path
21
+ from typing import Any
22
+
23
+ from .._clock import now
24
+ from .._errors import BadRequest
25
+ from .._ids import new_task_id
26
+ from ..contracts import Dep, PlanResult, Task
27
+ from ..engine import record, unblock
28
+ from ..storage import Store
29
+ from . import _entry as field
30
+ from ._project import caller, heartbeat, project
31
+
32
+ __all__ = ["plan"]
33
+
34
+
35
+ def plan(start: Path | str, entries: list[dict[str, Any]], *,
36
+ actor: str = "") -> PlanResult:
37
+ """Create every task in `entries`, wire the dependencies, return what unblocked."""
38
+ if not entries:
39
+ raise BadRequest("`tasks` is empty — a plan with no tasks is not a plan")
40
+ with project(start) as store:
41
+ who = caller(store, actor)["id"]
42
+ heartbeat(store, who)
43
+ created = [_create(store, entry, who) for entry in entries]
44
+ deps = _wire(store, entries, created)
45
+ return PlanResult(created=created, deps=deps, unblocked=unblock(store))
46
+
47
+
48
+ def _create(store: Store, entry: dict[str, Any], who: str) -> Task:
49
+ """One entry -> a stored task. Starts in `backlog`; `unblock` promotes it.
50
+
51
+ Deliberately never created `ready`: whether a task is pickable is a property of the
52
+ dependency graph, and letting a creator assert it directly is how the column starts
53
+ disagreeing with the edges.
54
+ """
55
+ title = field.title_of(entry)
56
+ if not title:
57
+ raise BadRequest("every task needs a `title`")
58
+ when = now()
59
+ task = Task(id=new_task_id(), title=title,
60
+ spec=str(entry.get("spec", "")).strip(), status="backlog",
61
+ priority=field.priority_of(entry),
62
+ parent=field.optional(entry, "parent"),
63
+ labels=field.strings(entry, "labels"),
64
+ files=field.strings(entry, "files"),
65
+ created_by=who, assignee=field.optional(entry, "assignee") or "",
66
+ created=when, updated=when)
67
+ store.tasks.insert(task)
68
+ # The WHOLE task in the body, not just its title. The log is the source of truth another
69
+ # machine rebuilds from, so an event that omits the spec is an event that cannot reconstruct
70
+ # the task — which is exactly what happened: a teammate's `git pull` imported the events and
71
+ # left them with an empty board. `engine.replay` is the reader.
72
+ record(store, task=task["id"], actor=who, kind="created", body=_snapshot(task), ts=when)
73
+ return task
74
+
75
+
76
+ def _snapshot(task: Task) -> dict[str, Any]:
77
+ """Everything needed to recreate this task elsewhere. `id` and `created_by` are already on the
78
+ event, so repeating them would be two places to keep in step."""
79
+ return {"title": task["title"], "spec": task["spec"], "priority": task["priority"],
80
+ "parent": task["parent"], "labels": task["labels"], "files": task["files"],
81
+ "assignee": task["assignee"]}
82
+
83
+
84
+ def _wire(store: Store, entries: list[dict[str, Any]],
85
+ created: list[Task]) -> list[Dep]:
86
+ """Resolve every `after` into an edge: an index means this batch, a string an id."""
87
+ out: list[Dep] = []
88
+ # strict=True asserts the invariant rather than trusting it: `created` is built one
89
+ # per entry, so a length mismatch means `_create` skipped one — and the symptom
90
+ # would be a dependency wired to the WRONG task.
91
+ for entry, task in zip(entries, created, strict=True):
92
+ for reference in field.references(entry):
93
+ out.append(_edge(store, _resolve(reference, created), task["id"], task["created_by"]))
94
+ for waiting in field.blocked_ids(entry):
95
+ # The INVERSE direction: this new card blocks an existing one. Same edge, other way
96
+ # round, which is what makes the stuck-agent flow a single call.
97
+ store.tasks.need(waiting)
98
+ out.append(_edge(store, task["id"], waiting, task["created_by"]))
99
+ return out
100
+
101
+
102
+ def _edge(store: Store, blocker: str, waiting: str, who: str) -> Dep:
103
+ """One dependency, stored AND recorded.
104
+
105
+ Recorded because a dependency that exists only in this machine's database is one a teammate's
106
+ scheduler will walk somebody straight into after a `git pull`.
107
+ """
108
+ store.deps.add(blocker, waiting)
109
+ record(store, task=waiting, actor=who, kind="blocked", body={"on": blocker})
110
+ return Dep(task=blocker, blocks=waiting)
111
+
112
+
113
+ def _resolve(reference: object, created: list[Task]) -> str:
114
+ """An `after` entry -> a task id.
115
+
116
+ An out-of-range index is an ERROR, never a skipped edge: a plan whose dependencies
117
+ silently did not apply looks finished and schedules wrongly, which is the most
118
+ expensive way for this to fail. `bool` is rejected before `int` because `True == 1`
119
+ would otherwise resolve to the first task in the batch.
120
+ """
121
+ if isinstance(reference, bool):
122
+ raise BadRequest(f"`after` got {reference!r} — expected an index or a task id")
123
+ if isinstance(reference, int):
124
+ if not 0 <= reference < len(created):
125
+ raise BadRequest(f"`after` index {reference} is outside this batch of "
126
+ f"{len(created)} — indexes are 0-based")
127
+ return created[reference]["id"]
128
+ return str(reference)
@@ -0,0 +1,119 @@
1
+ """`taskops push` and `taskops pull` — the same convergence as git, over HTTP.
2
+
3
+ The order is `usecases/sync.py`'s order and for the same reasons: events in, REPLAY, unblock,
4
+ and only then anything else. Replay is the step whose absence was a real bug — a teammate
5
+ imported every event and looked at an empty board, because events are the source of truth and
6
+ something has to turn them into rows. `pull` that only relayed would reproduce that bug over a
7
+ faster wire, so `tests/e2e/test_remote.py` asserts the imported card is on the BOARD, not that
8
+ its row is in the table.
9
+
10
+ **Push implies pull.** git does not do this and here it should: the round trip is one more
11
+ request against a server you just proved you can reach, and the alternative is two developers
12
+ whose boards have diverged since this morning, each convinced they are current.
13
+
14
+ **Marked exported only after a 200.** A push cut halfway through re-sends on the next run and
15
+ the server accepts each event exactly once — ids are content hashes — so the failure mode is
16
+ a duplicate request, not a lost event. The other order loses work to a dropped connection.
17
+
18
+ `push` keeps its OWN cursor (`pushed`, a local seq in remote.json) and never touches the
19
+ `exported` flag — that one belongs to the git-log export. The first version drained
20
+ `exported` instead, and on any project that had ever run `taskops sync` everything was
21
+ already marked: a 370-event board pushed as `0 event(s) out`, silently. Two sinks, two
22
+ cursors, and a project may use both — the jsonl for teammates on git, the server for the
23
+ live board.
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ from pathlib import Path
29
+ from typing import Any, cast
30
+
31
+ from .._types import LOCAL_ONLY_KINDS
32
+ from ..contracts import Event
33
+ from ..engine import relay, replay, unblock
34
+ from ._project import locate, project
35
+ from ._reportsync import ReportSwap, exchange
36
+ from ._wireclient import PAGE, Wire
37
+ from .remote import require_remote, save_cursor, save_pushed
38
+
39
+ __all__ = ["push", "pull", "Exchange"]
40
+
41
+
42
+ class Exchange:
43
+ """What crossed the wire, in both directions, plus what the import actually changed."""
44
+
45
+ def __init__(self, *, events_in: int = 0, accepted: int = 0, applied: int = 0,
46
+ unblocked: list[str] | None = None) -> None:
47
+ self.events_in = events_in
48
+ self.accepted = accepted
49
+ """Events the SERVER had never seen. Zero on a second push of the same work, which is
50
+ the idempotency showing through rather than a failure."""
51
+
52
+ self.applied = applied
53
+ self.unblocked = unblocked or []
54
+ self.reports = ReportSwap()
55
+
56
+
57
+ def pull(start: Path | str) -> Exchange:
58
+ """Take the server's events, materialise them, and take any newer reports."""
59
+ remote = require_remote(start)
60
+ wire = Wire(remote["url"], remote["token"])
61
+ done = _receive(start, wire, remote["cursor"])
62
+ done.reports = exchange(wire, locate(start), upload=False)
63
+ return done
64
+
65
+
66
+ def push(start: Path | str, *, force: bool = False) -> Exchange:
67
+ """Send what is local, then pull, then reconcile reports in both directions."""
68
+ remote = require_remote(start)
69
+ wire = Wire(remote["url"], remote["token"])
70
+ accepted = _send(start, wire, remote["pushed"])
71
+ done = _receive(start, wire, remote["cursor"])
72
+ done.accepted = accepted
73
+ done.reports = exchange(wire, locate(start), upload=True, force=force)
74
+ return done
75
+
76
+
77
+ def _send(start: Path | str, wire: Wire, pushed: int) -> int:
78
+ """Page the LOCAL log past the push cursor. Returns how many were new on the server.
79
+
80
+ The cursor advances only after the server's 200, so a push cut halfway re-sends and the
81
+ content-hashed ids make the repeat a no-op. Local-only kinds are filtered out but their
82
+ seqs still advance the cursor — skipping that would re-scan every activity heartbeat this
83
+ machine ever wrote, which is most of the log.
84
+ """
85
+ accepted = 0
86
+ while True:
87
+ with project(start) as store:
88
+ batch, tail = store.events.page_after(pushed, limit=PAGE)
89
+ if not batch:
90
+ return accepted
91
+ shared = [e for e in batch if e["kind"] not in LOCAL_ONLY_KINDS]
92
+ if shared:
93
+ accepted += int(wire.post_events(cast("list[Any]", shared)).get("accepted", 0))
94
+ save_pushed(start, tail)
95
+ pushed = tail
96
+
97
+
98
+ def _receive(start: Path | str, wire: Wire, cursor: int) -> Exchange:
99
+ """Page the server's log from the cursor, then REPLAY and unblock. Saves the cursor.
100
+
101
+ The cursor is the server's `seq` and is never compared with a local one. A server that
102
+ forgot it — a store rebuilt from scratch — answers from 0, and re-importing the whole log
103
+ is a no-op rather than a repair job: `relay` accepts each content-hashed id once.
104
+ """
105
+ fresh: list[Event] = []
106
+ with project(start) as store:
107
+ while True:
108
+ page = wire.get_events(cursor, PAGE)
109
+ arrived = [cast("Event", row) for row in page.get("events", [])
110
+ if isinstance(row, dict)]
111
+ fresh.extend(event for event in arrived if relay(store, event))
112
+ cursor = max(cursor, int(page.get("max_seq", 0) or 0))
113
+ if not page.get("more") or not arrived:
114
+ break
115
+ # Replay is not optional. Without it the events landed and the board stays empty.
116
+ done = Exchange(events_in=len(fresh), applied=replay.apply(store, fresh),
117
+ unblocked=unblock(store))
118
+ save_cursor(start, cursor)
119
+ return done
@@ -0,0 +1,84 @@
1
+ """`taskops recover` — unstick a fleet whose workers died.
2
+
3
+ Written the day it was needed. Six cards in a real project sat `claimed` by six agents that had been
4
+ killed mid-run (an API balance ran out), and getting them back took six hand-written `update
5
+ --status released` calls plus a manual hunt through six worktrees for work that had never been
6
+ committed. That is the wrong shape for something that will happen every time a fleet dies.
7
+
8
+ **It does not wait for the lease.** A TTL is the right mechanism for a crash nobody noticed, but a
9
+ person looking at a board full of SILENT rows has already noticed — making them wait fifteen minutes
10
+ for a timer to agree with them is a tool arguing with its user.
11
+
12
+ **It never deletes anything.** A dead worker's worktree keeps whatever it wrote, and the recovery
13
+ records that in the card's thread instead of tidying it away. Uncommitted work in a directory nobody
14
+ mentions is work the next agent redoes from scratch, which was exactly what nearly happened.
15
+
16
+ **Two kinds of stuck, and the second is easy to miss.** A card can be held by a lease whose worker
17
+ went quiet, OR merely ASSIGNED to a worker that was never started — which is what a `dispatch` whose
18
+ sub-agents nobody spawned leaves behind. The second has no lease at all, so a recovery that only
19
+ swept leases left eight of them assigned to workers that did not exist: invisible to every other
20
+ agent, and unclaimable forever. Found by running it.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ from pathlib import Path
26
+
27
+ from .._clock import HEARTBEAT_GRACE, now
28
+ from ..engine import fleet
29
+ from ..storage import Store
30
+ from ._freeing import Stuck, release_lease, unassign
31
+ from ._project import caller, heartbeat, project
32
+
33
+ __all__ = ["recover", "Recovered", "Stuck"]
34
+
35
+
36
+ class Recovered:
37
+ def __init__(self, *, released: list[Stuck], alive: list[str]) -> None:
38
+ self.released = released
39
+ self.alive = alive
40
+ """Workers still reporting. Named, not silently skipped: a recovery that quietly left two
41
+ cards claimed is a recovery somebody has to check by hand anyway."""
42
+
43
+
44
+ def recover(start: Path | str, *, actor: str = "", grace: float = HEARTBEAT_GRACE,
45
+ force: bool = False) -> Recovered:
46
+ """Release every card whose worker has gone quiet. Returns what was freed and what was not.
47
+
48
+ `grace` is the silence a worker is allowed before it counts as gone — the same number the fleet
49
+ view uses to print SILENT, so what you see is what this acts on. `force` releases even the ones
50
+ still reporting, which is for the case where a fleet is alive and wrong.
51
+ """
52
+ with project(start) as store:
53
+ who = caller(store, actor)["id"]
54
+ heartbeat(store, who)
55
+ when = now()
56
+ live = {m["task"]: m for m in fleet(store, at=when)["members"]}
57
+ released: list[Stuck] = []
58
+ alive: list[str] = []
59
+ for lease in store.leases.live(when):
60
+ member = live.get(lease["task"])
61
+ quiet = when - (member["last_seen"] if member else lease["acquired"])
62
+ if not force and quiet < grace:
63
+ alive.append(f"{lease['actor']} on {lease['task']}")
64
+ continue
65
+ released.append(release_lease(store, lease, who, quiet))
66
+ released += _orphans(store, who)
67
+ return Recovered(released=released, alive=alive)
68
+
69
+
70
+ def _orphans(store: Store, who: str) -> list[Stuck]:
71
+ """Cards ASSIGNED to a worker that holds no lease — a dispatch nobody spawned.
72
+
73
+ Always recovered, with no grace period, because there is nothing to wait for: an assignment with
74
+ no lease means the worker never started, and the card is meanwhile hidden from every other agent
75
+ by the very filter that makes assignment useful. Left alone it is unclaimable forever.
76
+ """
77
+ out: list[Stuck] = []
78
+ for task in store.tasks.all():
79
+ if not task["assignee"] or store.leases.get(task["id"]) is not None:
80
+ continue
81
+ if task["status"] in ("done", "cancelled"):
82
+ continue
83
+ out.append(unassign(store, task["id"], task["assignee"], who))
84
+ return out
@@ -0,0 +1,78 @@
1
+ """The one remote a project syncs with: a URL, a secret, and two cursors.
2
+
3
+ ONE remote per project. A second `add` is refused by naming the first: two remotes means two
4
+ cursors over two logs and a report that has to answer "who saw more" three ways, and none of
5
+ that is designed. Federation is a different feature with a different shape.
6
+
7
+ The token lives inside the block `taskops init` gitignores — that block lists what under
8
+ `.taskops/` is NOT committed and its one hole is `events.jsonl`, so `remote.json` is ignored
9
+ by construction. `tests/e2e/test_remote.py` pins it: a token reaching a commit is
10
+ unrecoverable in the only sense that matters, since rotating it is work somebody has to
11
+ notice they need to do. The file mechanics (0600, atomic-enough writes) live in `_remotefile`.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from pathlib import Path
17
+ from typing import cast
18
+
19
+ from .._errors import BadRequest, NotInitialized
20
+ from ..contracts import Remote
21
+ from ..storage import resolve_root
22
+ from ._remotefile import REMOTE_FILE, load, remote_path, write
23
+
24
+ __all__ = ["REMOTE_FILE", "add_remote", "read_remote", "require_remote",
25
+ "remove_remote", "save_cursor", "save_pushed", "remote_path"]
26
+
27
+ read_remote = load
28
+
29
+
30
+ def add_remote(start: Path | str, url: str, token: str) -> Remote:
31
+ """Register the one remote. Refuses a second by naming the one already there."""
32
+ root = resolve_root(start)
33
+ address = url.strip().rstrip("/")
34
+ if not address.startswith(("http://", "https://")):
35
+ raise BadRequest(f"`{url}` is not a server address — pass the base URL, "
36
+ f"like https://taskops.example.com")
37
+ if not token.strip():
38
+ raise BadRequest("a remote needs a token — the server rejects every call without one")
39
+ already = read_remote(start)
40
+ if already is not None:
41
+ raise BadRequest(f"this project already syncs with {already['url']} — one remote per "
42
+ f"project; run `taskops remote remove` first")
43
+ return write(root, Remote(url=address, token=token.strip(), pushed=0, cursor=0))
44
+
45
+
46
+ def require_remote(start: Path | str) -> Remote:
47
+ """The remote, or the one line that says how to get one."""
48
+ found = read_remote(start)
49
+ if found is None:
50
+ raise NotInitialized("no remote configured — run `taskops remote add <url> "
51
+ "--token <token>`, or keep syncing through git with `taskops sync`")
52
+ return found
53
+
54
+
55
+ def remove_remote(start: Path | str) -> str:
56
+ """Forget the remote AND the token. Returns the url that was dropped."""
57
+ gone = require_remote(start)
58
+ remote_path(resolve_root(start)).unlink(missing_ok=True)
59
+ return gone["url"]
60
+
61
+
62
+ def save_cursor(start: Path | str, cursor: int) -> None:
63
+ """Record how far this machine has read the SERVER's log. Never moves backwards."""
64
+ _advance(start, "cursor", cursor)
65
+
66
+
67
+ def save_pushed(start: Path | str, pushed: int) -> None:
68
+ """Record how far this machine's OWN log has been sent up. Its own number — the two
69
+ cursors count different logs and may never be compared (see the contract)."""
70
+ _advance(start, "pushed", pushed)
71
+
72
+
73
+ def _advance(start: Path | str, field: str, value: int) -> None:
74
+ current = require_remote(start)
75
+ if value > int(current[field]): # type: ignore[literal-required]
76
+ stored = dict(current)
77
+ stored[field] = int(value)
78
+ write(resolve_root(start), cast("Remote", stored))