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.
- taskops/__init__.py +39 -0
- taskops/_clock.py +32 -0
- taskops/_errors.py +160 -0
- taskops/_ids.py +63 -0
- taskops/_types.py +106 -0
- taskops/_version.py +7 -0
- taskops/assets/GUIDE.md +220 -0
- taskops/contracts/__init__.py +96 -0
- taskops/contracts/_fields.py +113 -0
- taskops/contracts/actor.py +30 -0
- taskops/contracts/board.py +142 -0
- taskops/contracts/commit.py +36 -0
- taskops/contracts/day.py +150 -0
- taskops/contracts/dep.py +23 -0
- taskops/contracts/event.py +58 -0
- taskops/contracts/gitstate.py +45 -0
- taskops/contracts/index.py +37 -0
- taskops/contracts/lease.py +43 -0
- taskops/contracts/log.py +59 -0
- taskops/contracts/remote.py +48 -0
- taskops/contracts/results.py +84 -0
- taskops/contracts/task.py +94 -0
- taskops/contracts/tools.py +110 -0
- taskops/contracts/wire.py +60 -0
- taskops/engine/__init__.py +34 -0
- taskops/engine/_blocks.py +48 -0
- taskops/engine/_briefs.py +92 -0
- taskops/engine/_chunks.py +66 -0
- taskops/engine/_closed.py +65 -0
- taskops/engine/_entries.py +126 -0
- taskops/engine/_events.py +55 -0
- taskops/engine/_opened.py +51 -0
- taskops/engine/_process.py +80 -0
- taskops/engine/_prompts.py +98 -0
- taskops/engine/_stream.py +129 -0
- taskops/engine/activity.py +94 -0
- taskops/engine/bus.py +42 -0
- taskops/engine/commitline.py +110 -0
- taskops/engine/day.py +142 -0
- taskops/engine/diffstat.py +63 -0
- taskops/engine/gitio.py +108 -0
- taskops/engine/gitstate.py +96 -0
- taskops/engine/history.py +76 -0
- taskops/engine/identity.py +84 -0
- taskops/engine/log.py +68 -0
- taskops/engine/machine.py +160 -0
- taskops/engine/narrate.py +91 -0
- taskops/engine/project.py +57 -0
- taskops/engine/replay.py +142 -0
- taskops/engine/reports.py +67 -0
- taskops/engine/scheduler.py +135 -0
- taskops/engine/transcript.py +127 -0
- taskops/engine/wire.py +92 -0
- taskops/engine/worker.py +115 -0
- taskops/py.typed +0 -0
- taskops/render/__init__.py +40 -0
- taskops/render/_closed_days.py +64 -0
- taskops/render/_dossier.py +68 -0
- taskops/render/_opened.py +64 -0
- taskops/render/_sections.py +51 -0
- taskops/render/_tasklist.py +72 -0
- taskops/render/_text.py +74 -0
- taskops/render/_verbatim.py +65 -0
- taskops/render/ansi.py +92 -0
- taskops/render/board.py +55 -0
- taskops/render/day.py +102 -0
- taskops/render/dispatch.py +104 -0
- taskops/render/inbox.py +27 -0
- taskops/render/log.py +47 -0
- taskops/render/recover.py +64 -0
- taskops/render/report.py +51 -0
- taskops/render/reports.py +57 -0
- taskops/render/results.py +96 -0
- taskops/render/session.py +75 -0
- taskops/render/task.py +88 -0
- taskops/render/tasklist.py +65 -0
- taskops/storage/__init__.py +36 -0
- taskops/storage/_ddl.py +78 -0
- taskops/storage/_delivered.py +51 -0
- taskops/storage/_deps.py +69 -0
- taskops/storage/_events.py +127 -0
- taskops/storage/_leases.py +108 -0
- taskops/storage/_rows.py +90 -0
- taskops/storage/_tasks.py +124 -0
- taskops/storage/locate.py +76 -0
- taskops/storage/schema.py +66 -0
- taskops/storage/store.py +120 -0
- taskops/storage/sync.py +139 -0
- taskops/transports/__init__.py +6 -0
- taskops/transports/cli/__init__.py +0 -0
- taskops/transports/cli/commands/__init__.py +3 -0
- taskops/transports/cli/commands/_digest.py +64 -0
- taskops/transports/cli/commands/_serve_init.py +65 -0
- taskops/transports/cli/commands/_shared.py +50 -0
- taskops/transports/cli/commands/_tasks_args.py +112 -0
- taskops/transports/cli/commands/_window.py +45 -0
- taskops/transports/cli/commands/ask.py +27 -0
- taskops/transports/cli/commands/dispatch.py +43 -0
- taskops/transports/cli/commands/init.py +48 -0
- taskops/transports/cli/commands/log.py +22 -0
- taskops/transports/cli/commands/plan.py +43 -0
- taskops/transports/cli/commands/pushpull.py +53 -0
- taskops/transports/cli/commands/recover.py +30 -0
- taskops/transports/cli/commands/remote.py +56 -0
- taskops/transports/cli/commands/report.py +82 -0
- taskops/transports/cli/commands/run_.py +60 -0
- taskops/transports/cli/commands/serve.py +74 -0
- taskops/transports/cli/commands/sync.py +22 -0
- taskops/transports/cli/commands/tasks.py +79 -0
- taskops/transports/cli/commands/ui.py +72 -0
- taskops/transports/cli/commands/update.py +25 -0
- taskops/transports/cli/main.py +85 -0
- taskops/transports/hooks/__init__.py +15 -0
- taskops/transports/hooks/__main__.py +63 -0
- taskops/transports/hooks/_args.py +28 -0
- taskops/transports/hooks/claude.py +78 -0
- taskops/transports/hooks/commit.py +61 -0
- taskops/transports/hooks/events.py +107 -0
- taskops/transports/hooks/record.py +55 -0
- taskops/transports/http/__init__.py +21 -0
- taskops/transports/http/_handler.py +132 -0
- taskops/transports/http/_wire.py +79 -0
- taskops/transports/http/_wsframes.py +116 -0
- taskops/transports/http/agentapi.py +69 -0
- taskops/transports/http/api.py +122 -0
- taskops/transports/http/exchange.py +80 -0
- taskops/transports/http/live.py +160 -0
- taskops/transports/http/policy.py +110 -0
- taskops/transports/http/projects.py +116 -0
- taskops/transports/http/reports.py +75 -0
- taskops/transports/http/router.py +90 -0
- taskops/transports/http/server.py +50 -0
- taskops/transports/http/static.py +86 -0
- taskops/transports/http/ui/app.js +59 -0
- taskops/transports/http/ui/index.html +23 -0
- taskops/transports/http/ui/style.css +1 -0
- taskops/transports/http/websocket.py +60 -0
- taskops/transports/mcp/__init__.py +19 -0
- taskops/transports/mcp/__main__.py +7 -0
- taskops/transports/mcp/_descriptions.py +102 -0
- taskops/transports/mcp/_reads.py +82 -0
- taskops/transports/mcp/_writes.py +72 -0
- taskops/transports/mcp/answers.py +44 -0
- taskops/transports/mcp/arguments.py +104 -0
- taskops/transports/mcp/dispatch.py +47 -0
- taskops/transports/mcp/protocol.py +83 -0
- taskops/transports/mcp/schema.py +50 -0
- taskops/transports/mcp/server.py +49 -0
- taskops/transports/mcp/tools.py +66 -0
- taskops/usecases/__init__.py +56 -0
- taskops/usecases/_entry.py +84 -0
- taskops/usecases/_facts.py +49 -0
- taskops/usecases/_freeing.py +93 -0
- taskops/usecases/_gitignore.py +94 -0
- taskops/usecases/_mirroring.py +57 -0
- taskops/usecases/_narrating.py +77 -0
- taskops/usecases/_project.py +67 -0
- taskops/usecases/_range.py +102 -0
- taskops/usecases/_reasons.py +54 -0
- taskops/usecases/_remotefile.py +62 -0
- taskops/usecases/_reportsync.py +108 -0
- taskops/usecases/_routing.py +91 -0
- taskops/usecases/_wireclient.py +141 -0
- taskops/usecases/ask.py +41 -0
- taskops/usecases/claim.py +104 -0
- taskops/usecases/dispatch.py +160 -0
- taskops/usecases/dossier.py +155 -0
- taskops/usecases/edit.py +71 -0
- taskops/usecases/exchange.py +86 -0
- taskops/usecases/feed.py +138 -0
- taskops/usecases/guard.py +122 -0
- taskops/usecases/hooks.py +127 -0
- taskops/usecases/index.py +62 -0
- taskops/usecases/ingest.py +60 -0
- taskops/usecases/log.py +134 -0
- taskops/usecases/narration.py +91 -0
- taskops/usecases/plan.py +128 -0
- taskops/usecases/pushpull.py +119 -0
- taskops/usecases/recover.py +84 -0
- taskops/usecases/remote.py +78 -0
- taskops/usecases/report.py +87 -0
- taskops/usecases/reportfile.py +106 -0
- taskops/usecases/session.py +134 -0
- taskops/usecases/setup.py +92 -0
- taskops/usecases/sync.py +78 -0
- taskops/usecases/update.py +123 -0
- taskops/usecases/view.py +112 -0
- taskops_cli-0.2.0.dist-info/METADATA +291 -0
- taskops_cli-0.2.0.dist-info/RECORD +193 -0
- taskops_cli-0.2.0.dist-info/WHEEL +5 -0
- taskops_cli-0.2.0.dist-info/entry_points.txt +2 -0
- taskops_cli-0.2.0.dist-info/licenses/LICENSE +21 -0
- taskops_cli-0.2.0.dist-info/top_level.txt +1 -0
taskops/usecases/log.py
ADDED
|
@@ -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))
|
taskops/usecases/plan.py
ADDED
|
@@ -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))
|