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
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""Bringing the local board level with a write that happened on the server.
|
|
2
|
+
|
|
3
|
+
The pull that follows a remote write carries the EVENTS, and events are enough for most of
|
|
4
|
+
what a board shows — but not for a claim. `engine.replay` deliberately does not materialise
|
|
5
|
+
leases: importing one would mean claiming a task on behalf of an agent running on another
|
|
6
|
+
machine, which is the opposite of what this whole feature is for. So a pull alone leaves the
|
|
7
|
+
winner's own board saying `ready`, and the commit guard reads that board — it refuses a commit
|
|
8
|
+
from an actor with no live lease HERE. The agent would have won the race and then been told it
|
|
9
|
+
had never claimed anything.
|
|
10
|
+
|
|
11
|
+
What is safe is narrower than what replay refuses, and this module is exactly that narrow: the
|
|
12
|
+
only lease written here is the one the server just GRANTED TO THIS CALLER, handed back in the
|
|
13
|
+
answer. It is not inferred from somebody else's event; it is the receipt for a claim this
|
|
14
|
+
machine made a moment ago. Everything else about other agents' claims still stays out.
|
|
15
|
+
|
|
16
|
+
The result is also applied verbatim rather than recomputed: the timestamps are the server's, so
|
|
17
|
+
a later status event from that same server is still newer than what is written here and replays
|
|
18
|
+
normally. Stamping `now()` instead would silently make the local row look newer than the server
|
|
19
|
+
it came from, and every subsequent pull would decline to update it.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
|
|
26
|
+
from ..contracts import Claim, Task
|
|
27
|
+
from ._project import project
|
|
28
|
+
|
|
29
|
+
__all__ = ["mirror_claim", "mirror_update"]
|
|
30
|
+
|
|
31
|
+
_LEASE_ENDS = ("ready", "done", "cancelled")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def mirror_claim(start: Path | str, claim: Claim) -> None:
|
|
35
|
+
"""Record the lease the server granted, and put the card in `claimed` here too."""
|
|
36
|
+
lease = claim["lease"]
|
|
37
|
+
with project(start) as store:
|
|
38
|
+
if store.tasks.get(lease["task"]) is None:
|
|
39
|
+
return
|
|
40
|
+
store.leases.release(lease["task"])
|
|
41
|
+
store.leases.acquire(lease)
|
|
42
|
+
store.tasks.set_status(lease["task"], "claimed", when=lease["acquired"])
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def mirror_update(start: Path | str, task: Task) -> None:
|
|
46
|
+
"""Take the task exactly as the server returned it, and drop the lease if it ended.
|
|
47
|
+
|
|
48
|
+
The status is copied rather than replayed because this caller has the authoritative row
|
|
49
|
+
in its hands; the lease has to be dropped here because releasing one is local state and
|
|
50
|
+
`replay` has no business writing it, so nothing else would ever clear it.
|
|
51
|
+
"""
|
|
52
|
+
with project(start) as store:
|
|
53
|
+
if store.tasks.get(task["id"]) is None:
|
|
54
|
+
return
|
|
55
|
+
store.tasks.set_status(task["id"], task["status"], when=task["updated"])
|
|
56
|
+
if task["status"] in _LEASE_ENDS:
|
|
57
|
+
store.leases.release(task["id"])
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""The narration reaching the FILE while it is still being written.
|
|
2
|
+
|
|
3
|
+
The first version wrote the prose once, at the end. On a day that is one reading that is
|
|
4
|
+
thirty seconds of `_pendiente_`; on `report all`, which narrates in several passes, it is a
|
|
5
|
+
quarter of an hour during which the file on disk is indistinguishable from one nobody ever
|
|
6
|
+
narrated — and if the process dies at minute fourteen, that is exactly what it becomes.
|
|
7
|
+
|
|
8
|
+
So the text is flushed as it arrives: every `FLUSH_CHARS` of prose, and at every pass
|
|
9
|
+
boundary. A flush is a whole-file rewrite through `render.narrated`, which is the same call
|
|
10
|
+
the final write makes, so a partial file is never a different SHAPE of file — only a shorter
|
|
11
|
+
one. Nothing here decides anything; it is the writing half of `dossier.digest`.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
from ..engine import OnPass, OnText
|
|
19
|
+
from ..render import narrated
|
|
20
|
+
|
|
21
|
+
__all__ = ["Progressive", "FLUSH_CHARS"]
|
|
22
|
+
|
|
23
|
+
FLUSH_CHARS = 400
|
|
24
|
+
"""Prose written since the last flush before the file is rewritten. Roughly a paragraph.
|
|
25
|
+
|
|
26
|
+
A report is tens of kilobytes and a narration arrives over minutes, so this is a handful of
|
|
27
|
+
rewrites per pass — cheap enough to ignore, frequent enough that a person watching the file
|
|
28
|
+
sees it grow rather than jump.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class Progressive:
|
|
33
|
+
"""Wraps a caller's callbacks so the same deltas also land on disk as they arrive."""
|
|
34
|
+
|
|
35
|
+
def __init__(self, path: Path, report: str, *,
|
|
36
|
+
on_pass: OnPass = None, on_text: OnText = None) -> None:
|
|
37
|
+
self._path = path
|
|
38
|
+
self._report = report
|
|
39
|
+
self._on_pass = on_pass
|
|
40
|
+
self._on_text = on_text
|
|
41
|
+
self._written: list[str] = []
|
|
42
|
+
self._chars = 0
|
|
43
|
+
self._flushed = 0
|
|
44
|
+
|
|
45
|
+
def text(self, delta: str) -> None:
|
|
46
|
+
"""A fragment: keep it, pass it on, and flush once enough has piled up."""
|
|
47
|
+
self._written.append(delta)
|
|
48
|
+
self._chars += len(delta)
|
|
49
|
+
if self._on_text:
|
|
50
|
+
self._on_text(delta)
|
|
51
|
+
if self._chars - self._flushed >= FLUSH_CHARS:
|
|
52
|
+
self.flush()
|
|
53
|
+
|
|
54
|
+
def passed(self, n: int, total: int) -> None:
|
|
55
|
+
"""A pass is starting, so the previous one is complete — the natural moment to flush."""
|
|
56
|
+
self.flush()
|
|
57
|
+
if self._on_pass:
|
|
58
|
+
self._on_pass(n, total)
|
|
59
|
+
|
|
60
|
+
def finish(self, prose: str) -> Path:
|
|
61
|
+
"""The FINAL write, from the text `narrate` returned rather than from the fragments.
|
|
62
|
+
|
|
63
|
+
Not the same string as the last flush, and that is why this exists: a multi-pass
|
|
64
|
+
narration returns the STITCHED reading, not the concatenation of the slices somebody
|
|
65
|
+
watched go by. The partial file is a live view; this is the document.
|
|
66
|
+
"""
|
|
67
|
+
self._path.write_text(narrated(self._report, prose), encoding="utf-8")
|
|
68
|
+
return self._path
|
|
69
|
+
|
|
70
|
+
def flush(self) -> None:
|
|
71
|
+
"""The report as it stands, narrated with the prose so far. A no-op before the first
|
|
72
|
+
delta, because a file whose narration section is empty reads as a taskops bug."""
|
|
73
|
+
prose = "".join(self._written)
|
|
74
|
+
if not prose.strip():
|
|
75
|
+
return
|
|
76
|
+
self._path.write_text(narrated(self._report, prose), encoding="utf-8")
|
|
77
|
+
self._flushed = self._chars
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""Opening the project, and the heartbeat every call pays for.
|
|
2
|
+
|
|
3
|
+
Two things every use case needs and none of them should re-derive:
|
|
4
|
+
|
|
5
|
+
**The root.** Callers pass whatever path they happen to be in — an agent its cwd, a
|
|
6
|
+
git hook wherever git ran it — so resolution walks up for `.taskops/` exactly once,
|
|
7
|
+
here.
|
|
8
|
+
|
|
9
|
+
**The heartbeat.** Any taskops call an agent makes is proof it is alive, so every
|
|
10
|
+
call renews that agent's leases. This is what makes the TTL bound a CRASH rather
|
|
11
|
+
than a slow task: an agent working for an hour keeps its claim without doing anything
|
|
12
|
+
special, and one whose process died stops renewing within a single call.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from collections.abc import Generator
|
|
18
|
+
from contextlib import contextmanager
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
|
|
21
|
+
from .._clock import LEASE_TTL, now
|
|
22
|
+
from ..contracts import Actor
|
|
23
|
+
from ..engine import identity, sweep_dead
|
|
24
|
+
from ..storage import Store, resolve_root
|
|
25
|
+
|
|
26
|
+
__all__ = ["project", "caller", "heartbeat", "locate"]
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def locate(start: Path | str) -> Path:
|
|
30
|
+
"""Which project a path belongs to. Raises `NotInitialized` naming the fix.
|
|
31
|
+
|
|
32
|
+
Exported because the studio needs the root BEFORE it opens a port — it serves one repository,
|
|
33
|
+
and binding to a directory with no project would be a server that answers every request with
|
|
34
|
+
the same error. A transport may not call `storage.resolve_root` itself (an invariant test says
|
|
35
|
+
so, and it caught exactly this), so the resolution is a use case like any other.
|
|
36
|
+
"""
|
|
37
|
+
return resolve_root(start)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@contextmanager
|
|
41
|
+
def project(start: Path | str) -> Generator[Store]:
|
|
42
|
+
"""An open Store on the project containing `start`. Commits on a clean exit."""
|
|
43
|
+
with Store(resolve_root(start)) as store:
|
|
44
|
+
yield store
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def caller(store: Store, asked: str = "") -> Actor:
|
|
48
|
+
"""Who is calling. Resolved from the argument, the environment, then git."""
|
|
49
|
+
return identity.resolve(store.root, asked)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def heartbeat(store: Store, actor: str, *, at: float | None = None) -> None:
|
|
53
|
+
"""Push this actor's LIVE lease deadlines out, and expire every dead one.
|
|
54
|
+
|
|
55
|
+
Both halves belong together: the sweep is what makes another agent's crash
|
|
56
|
+
visible, and running it on every call means no daemon has to exist to notice one.
|
|
57
|
+
|
|
58
|
+
An already-expired lease is NOT revived, and that is the important case. By the
|
|
59
|
+
time it lapsed another agent may have claimed the task, so quietly handing it
|
|
60
|
+
back would produce two holders and no error. The late agent instead finds out at
|
|
61
|
+
its next write, where the lease guard names the fix — which is the one path that
|
|
62
|
+
cannot end with two agents editing the same files believing they own them.
|
|
63
|
+
"""
|
|
64
|
+
when = now() if at is None else at
|
|
65
|
+
for lease in store.leases.of_actor(actor, when):
|
|
66
|
+
store.leases.renew(task_id=lease["task"], actor=actor, expires=when + LEASE_TTL)
|
|
67
|
+
sweep_dead(store, at=when)
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""Which days a report covers — the selector a human types, resolved to two dates.
|
|
2
|
+
|
|
3
|
+
Four ways of saying the same thing (one day, the last N, an explicit span, everything) and
|
|
4
|
+
ONE place that turns any of them into `from_date, to_date`. That is deliberate: the dossier
|
|
5
|
+
writer needs the same answer as the reader, and a second resolution is how a file named
|
|
6
|
+
`2026-07-22..2026-07-28.md` ends up holding a different week.
|
|
7
|
+
|
|
8
|
+
Strict, like `parse_window` and `parse_date`. A selector nobody can read is REFUSED and never
|
|
9
|
+
widened to something plausible: a report quietly covering the wrong month looks exactly like
|
|
10
|
+
a correct one, and it is the shape somebody pastes into a review.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from typing import NamedTuple
|
|
16
|
+
|
|
17
|
+
from .._clock import now
|
|
18
|
+
from .._errors import BadRequest
|
|
19
|
+
from ..engine import date_of, first_date, shift
|
|
20
|
+
from ..storage import Store
|
|
21
|
+
|
|
22
|
+
__all__ = ["is_day", "Selector", "resolve", "parse_date"]
|
|
23
|
+
|
|
24
|
+
_UNITS = {"d": 1, "w": 7}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class Selector(NamedTuple):
|
|
28
|
+
"""What the caller asked for, before a store is open. Defaults to today."""
|
|
29
|
+
|
|
30
|
+
date: str = ""
|
|
31
|
+
"""The START of the window, or the whole of it when nothing else is set: `today`,
|
|
32
|
+
`yesterday`, or `YYYY-MM-DD` — whatever `parse_date` accepts."""
|
|
33
|
+
|
|
34
|
+
to: str = ""
|
|
35
|
+
"""The last day, INCLUSIVE. Empty means today for a range, and `date` for a single day."""
|
|
36
|
+
|
|
37
|
+
last: str = ""
|
|
38
|
+
"""`7d`, `2w`, `1m` — a span ending at `to`."""
|
|
39
|
+
|
|
40
|
+
whole: bool = False
|
|
41
|
+
"""`report all`: from the log's first event to today."""
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def resolve(store: Store, sel: Selector) -> tuple[str, str, str]:
|
|
45
|
+
"""`Selector` -> the two dates a `period_report` runs between, and what to call them.
|
|
46
|
+
|
|
47
|
+
Order matters: `whole` wins, then `last`, then an explicit pair, then the single day.
|
|
48
|
+
They are not combinable, and letting one silently override another inside a report would
|
|
49
|
+
be worse than the argument parser refusing the combination up front.
|
|
50
|
+
|
|
51
|
+
`all` is labelled `all` and not by its dates on purpose: it is a standing name for "the
|
|
52
|
+
whole project", so `report all --digest` keeps updating ONE file instead of leaving a
|
|
53
|
+
trail of near-identical ones whose end date happens to differ.
|
|
54
|
+
"""
|
|
55
|
+
if sel.whole:
|
|
56
|
+
return first_date(store), parse_date(sel.to), "all"
|
|
57
|
+
if sel.last:
|
|
58
|
+
end = parse_date(sel.to)
|
|
59
|
+
return _back(end, sel.last), end, ""
|
|
60
|
+
if sel.to:
|
|
61
|
+
return parse_date(sel.date), parse_date(sel.to), ""
|
|
62
|
+
return parse_date(sel.date), parse_date(sel.date), ""
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _back(end: str, last: str) -> str:
|
|
66
|
+
"""`2026-07-28`, `7d` -> `2026-07-22`. INCLUSIVE of both ends: `7d` is seven days of
|
|
67
|
+
work, not seven days ago plus today, which would be eight."""
|
|
68
|
+
raw = last.strip().lower()
|
|
69
|
+
number, unit = raw[:-1], raw[-1:]
|
|
70
|
+
if not number.isdigit() or int(number) <= 0 or unit not in (*_UNITS, "m"):
|
|
71
|
+
raise BadRequest(f"`{last}` is not a span — use a positive number then d, w or m, "
|
|
72
|
+
f"e.g. 7d, 2w or 1m")
|
|
73
|
+
if unit == "m":
|
|
74
|
+
return shift(shift(end, months=-int(number)), days=1)
|
|
75
|
+
return shift(end, days=1 - int(number) * _UNITS[unit])
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def parse_date(text: str) -> str:
|
|
79
|
+
"""`yesterday` -> `2026-07-27`. Strict, for the same reason `parse_window` is.
|
|
80
|
+
|
|
81
|
+
`yesterday` is 24 hours back read as a LOCAL date rather than "today minus one" in
|
|
82
|
+
arithmetic on a date, which would have to know how long a month is. The calendar is
|
|
83
|
+
libc's problem, and libc is the only one that knows about the two days a year that are
|
|
84
|
+
not 24 hours long.
|
|
85
|
+
"""
|
|
86
|
+
raw = text.strip().lower()
|
|
87
|
+
if raw in ("", "today"):
|
|
88
|
+
return date_of(now())
|
|
89
|
+
if raw == "yesterday":
|
|
90
|
+
return date_of(now() - 86400.0)
|
|
91
|
+
if len(raw) == 10 and raw[4] == "-" and raw[7] == "-" and raw.replace("-", "").isdigit():
|
|
92
|
+
return raw
|
|
93
|
+
raise BadRequest(f"`{text}` is not a day — use `today`, `yesterday`, or a date "
|
|
94
|
+
f"like 2026-07-28")
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def is_day(label: str) -> bool:
|
|
98
|
+
"""Whether a report label names one calendar day. The ONE place this shape is read:
|
|
99
|
+
the index and the report reader both branch on it, and two copies is how one of them
|
|
100
|
+
keeps accepting a shape the other started refusing."""
|
|
101
|
+
return (len(label) == 10 and label[4] == "-" and label[7] == "-"
|
|
102
|
+
and label.replace("-", "").isdigit())
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""Why there is nothing to claim. Prose with branches in it, kept out of the claim path.
|
|
2
|
+
|
|
3
|
+
Split from `claim` because the two answer different questions: that module decides what an agent GETS,
|
|
4
|
+
this one explains what it did not get. Every branch here exists because a specific unhelpful answer
|
|
5
|
+
sent an agent to ask a human when it could have acted:
|
|
6
|
+
|
|
7
|
+
- "nothing ready" → it asks a human. "waiting on tk-abc" → it goes and does tk-abc.
|
|
8
|
+
- "cannot claim" → it retries the same call. "assigned to agent:ana/w2" → it asks for its own.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from ..engine import counts
|
|
14
|
+
from ..storage import Store
|
|
15
|
+
|
|
16
|
+
__all__ = ["why_nothing", "why_not_this"]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def why_nothing(store: Store, labels: tuple[str, ...], wanted: str, asker: str = "") -> str:
|
|
20
|
+
"""Why there is nothing to do. "Nothing ready" alone sends the agent to ask a
|
|
21
|
+
human; told WHICH — all blocked, all taken, or genuinely finished — it can act."""
|
|
22
|
+
if wanted:
|
|
23
|
+
return why_not_this(store, wanted, asker)
|
|
24
|
+
numbers = counts(store)
|
|
25
|
+
if numbers["open"] == 0:
|
|
26
|
+
return "nothing open — the project is finished, or nothing has been planned"
|
|
27
|
+
if labels:
|
|
28
|
+
return (f"no ready task carries {', '.join(labels)}. Drop the label filter, "
|
|
29
|
+
f"or plan the work that is missing")
|
|
30
|
+
if numbers["ready"] == 0 and numbers["blocked"]:
|
|
31
|
+
return (f"{numbers['blocked']} task(s) are waiting on a dependency and "
|
|
32
|
+
f"{numbers['working']} are in flight — unblocking one of those is "
|
|
33
|
+
f"the useful move")
|
|
34
|
+
return (f"every ready task was claimed by another agent in the last moment "
|
|
35
|
+
f"({numbers['working']} in flight) — ask again")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def why_not_this(store: Store, wanted: str, asker: str = "") -> str:
|
|
39
|
+
"""Why THIS task cannot be claimed. Each status has a different useful answer.
|
|
40
|
+
|
|
41
|
+
A blocked task names its blockers, because "not ready" alone sends the agent to a human when
|
|
42
|
+
the actionable move is usually to go work on the blocker instead.
|
|
43
|
+
"""
|
|
44
|
+
task = store.tasks.need(wanted)
|
|
45
|
+
if task["status"] == "backlog":
|
|
46
|
+
blockers = ", ".join(store.deps.open_blockers_of(wanted)) or "unresolved deps"
|
|
47
|
+
return (f"{wanted} is waiting on {blockers} — finish those first, or claim one "
|
|
48
|
+
f"of them with taskops_next")
|
|
49
|
+
if task["status"] in ("done", "cancelled"):
|
|
50
|
+
return f"{wanted} is already {task['status']} — nothing to do there"
|
|
51
|
+
if task["assignee"] and task["assignee"] != asker:
|
|
52
|
+
return (f"{wanted} is assigned to {task['assignee']} — ask taskops_next with no task "
|
|
53
|
+
f"and it will give you something that is yours")
|
|
54
|
+
return f"{wanted} is {task['status']}, held by someone else — read it with taskops_ask"
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""Reading and writing `.taskops/remote.json` — the file mechanics, nothing else.
|
|
2
|
+
|
|
3
|
+
Split from `remote` by what each half is: that module decides WHAT a remote is (one per
|
|
4
|
+
project, a URL and a secret, two cursors that never compare), this one knows how to put it
|
|
5
|
+
on disk without leaking it. The file is written through `os.open` with mode 0600 rather than
|
|
6
|
+
`write_text` followed by a `chmod`, because the two-step version publishes the token to every
|
|
7
|
+
user on the machine for the width of one syscall.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
import os
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Any, cast
|
|
16
|
+
|
|
17
|
+
from ..contracts import Remote
|
|
18
|
+
from ..storage import PROJECT_DIR, resolve_root
|
|
19
|
+
|
|
20
|
+
__all__ = ["REMOTE_FILE", "remote_path", "load", "write"]
|
|
21
|
+
|
|
22
|
+
REMOTE_FILE = f"{PROJECT_DIR}/remote.json"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def remote_path(root: Path) -> Path:
|
|
26
|
+
return root / REMOTE_FILE
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def load(start: Path | str) -> Remote | None:
|
|
30
|
+
"""The configured remote, or None. Never raises on a corrupt file.
|
|
31
|
+
|
|
32
|
+
A hand-edited `remote.json` with a trailing comma should read as "no remote configured",
|
|
33
|
+
which `remote add` can then fix — refusing to run and refusing to be repaired is a state
|
|
34
|
+
with no way out of it.
|
|
35
|
+
"""
|
|
36
|
+
path = remote_path(resolve_root(start))
|
|
37
|
+
if not path.is_file():
|
|
38
|
+
return None
|
|
39
|
+
try:
|
|
40
|
+
parsed: Any = json.loads(path.read_text(encoding="utf-8"))
|
|
41
|
+
except (json.JSONDecodeError, OSError):
|
|
42
|
+
return None
|
|
43
|
+
if not isinstance(parsed, dict):
|
|
44
|
+
return None
|
|
45
|
+
fields = cast("dict[str, Any]", parsed)
|
|
46
|
+
if not fields.get("url"):
|
|
47
|
+
return None
|
|
48
|
+
return Remote(url=str(fields["url"]), token=str(fields.get("token", "")),
|
|
49
|
+
pushed=int(fields.get("pushed", 0) or 0),
|
|
50
|
+
cursor=int(fields.get("cursor", 0) or 0))
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def write(root: Path, remote: Remote) -> Remote:
|
|
54
|
+
path = remote_path(root)
|
|
55
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
56
|
+
handle = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
|
57
|
+
with os.fdopen(handle, "w", encoding="utf-8") as out:
|
|
58
|
+
json.dump(remote, out, indent=2, sort_keys=True)
|
|
59
|
+
out.write("\n")
|
|
60
|
+
# A file that already existed keeps its old mode through O_CREAT, so say it again.
|
|
61
|
+
os.chmod(path, 0o600)
|
|
62
|
+
return remote
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"""Reports across the wire — the half of sync that can destroy something irrecoverable.
|
|
2
|
+
|
|
3
|
+
Events are safe to move in bulk: they are facts with content-hash ids, so the union of two
|
|
4
|
+
logs is the correct log. A report is not. Its dossier regenerates from the log any time, but
|
|
5
|
+
the NARRATION under it was written once — by a model somebody paid for, or by a person — and
|
|
6
|
+
nothing in the system can reconstruct it. So the rule here is the opposite of the one for
|
|
7
|
+
events: never overwrite, ever, unless a human said `--force` after reading what they would
|
|
8
|
+
lose.
|
|
9
|
+
|
|
10
|
+
The comparison is `stamped_seq`: line one of every generated report carries the log's
|
|
11
|
+
`max_seq` at the moment it was rendered, so a larger stamp means "this copy saw more of the
|
|
12
|
+
history". It is a HEURISTIC and worth saying so — two machines number their own sqlite rows,
|
|
13
|
+
so the stamps are strictly comparable only once both have synced. The pathological case (two
|
|
14
|
+
independent narrations of the same day, neither machine having seen the other) falls to the
|
|
15
|
+
server's equal-seq-different-content 409, which is the honest answer: nobody can decide that
|
|
16
|
+
one for you.
|
|
17
|
+
|
|
18
|
+
This module never regenerates a dossier. Owning the store is what earns the right to render,
|
|
19
|
+
and a client that "helpfully" re-rendered what it downloaded would be uploading a report the
|
|
20
|
+
server has never seen the events for.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
from typing import Any
|
|
27
|
+
|
|
28
|
+
from ..engine import stamped_seq
|
|
29
|
+
from ..storage import REPORTS_DIR
|
|
30
|
+
from ._wireclient import Wire
|
|
31
|
+
from .dossier import report_path
|
|
32
|
+
|
|
33
|
+
__all__ = ["ReportSwap", "exchange"]
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class ReportSwap:
|
|
37
|
+
"""Which reports moved, and which ones refused to."""
|
|
38
|
+
|
|
39
|
+
def __init__(self) -> None:
|
|
40
|
+
self.uploaded: list[str] = []
|
|
41
|
+
self.downloaded: list[str] = []
|
|
42
|
+
self.conflicts: list[str] = []
|
|
43
|
+
"""One sentence per stand-off, naming BOTH seqs and the two ways out. A count would
|
|
44
|
+
be useless: the answer is per report, and it is `pull` for one and `--force` for the
|
|
45
|
+
next."""
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def exchange(wire: Wire, root: Path, *, upload: bool, force: bool = False) -> ReportSwap:
|
|
49
|
+
"""Reconcile every report both sides know about. `upload` is False on a plain `pull`."""
|
|
50
|
+
swap = ReportSwap()
|
|
51
|
+
for label in _labels(wire, root):
|
|
52
|
+
_reconcile(wire, root, label, swap, upload=upload, force=force)
|
|
53
|
+
return swap
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _reconcile(wire: Wire, root: Path, label: str, swap: ReportSwap, *,
|
|
57
|
+
upload: bool, force: bool) -> None:
|
|
58
|
+
path = report_path(root, label)
|
|
59
|
+
ours = path.read_text(encoding="utf-8") if path.is_file() else ""
|
|
60
|
+
theirs = wire.get_report(label)
|
|
61
|
+
if not _served(theirs):
|
|
62
|
+
if upload and ours:
|
|
63
|
+
_send(wire, label, ours, swap, force=force)
|
|
64
|
+
return
|
|
65
|
+
content = str(theirs.get("content", ""))
|
|
66
|
+
if not ours or int(theirs.get("max_seq", 0) or 0) > stamped_seq(ours):
|
|
67
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
68
|
+
path.write_text(content, encoding="utf-8")
|
|
69
|
+
swap.downloaded.append(label)
|
|
70
|
+
elif upload and content != ours:
|
|
71
|
+
_send(wire, label, ours, swap, force=force)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _send(wire: Wire, label: str, text: str, swap: ReportSwap, *, force: bool) -> None:
|
|
75
|
+
answer = wire.put_report(label, text, force=force)
|
|
76
|
+
if answer.get("status") == 409:
|
|
77
|
+
swap.conflicts.append(_clash(label, answer, stamped_seq(text)))
|
|
78
|
+
else:
|
|
79
|
+
swap.uploaded.append(label)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _clash(label: str, answer: dict[str, Any], mine: int) -> str:
|
|
83
|
+
"""The 409 in a sentence. Note the INVERSION: the body's `ours` is the SERVER's copy and
|
|
84
|
+
its `theirs` is what we just offered, because the server wrote it from where it stands."""
|
|
85
|
+
server = int(answer.get("ours", 0) or 0)
|
|
86
|
+
ours = int(answer.get("theirs", mine) or mine)
|
|
87
|
+
return (f"{label}: the server's copy is stamped at seq {server}, yours at {ours} — "
|
|
88
|
+
f"nothing was overwritten. Run `taskops pull` to take the server's, or "
|
|
89
|
+
f"`taskops push --force` to replace it (any narration there is lost).")
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _served(answer: dict[str, Any]) -> bool:
|
|
93
|
+
return answer.get("status") is None and "content" in answer
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _labels(wire: Wire, root: Path) -> list[str]:
|
|
97
|
+
"""Every label either side has, sorted so the output is stable.
|
|
98
|
+
|
|
99
|
+
The server's list is best-effort (`Wire.list_reports` explains why): on a server without
|
|
100
|
+
that route this degrades to the labels on this disk, which still reconciles everything
|
|
101
|
+
this machine has ever written — only a report that exists SOLELY on the server stays out
|
|
102
|
+
of reach, and it arrives the moment anyone here generates that window.
|
|
103
|
+
"""
|
|
104
|
+
directory = root / REPORTS_DIR
|
|
105
|
+
local: set[str] = set()
|
|
106
|
+
if directory.is_dir():
|
|
107
|
+
local = {path.stem for path in directory.glob("*.md")}
|
|
108
|
+
return sorted(local | set(wire.list_reports()))
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""Where a WRITE happens: here, or in the server that other machines also write to.
|
|
2
|
+
|
|
3
|
+
Push and pull make two boards converge; they do not make a claim safe. Between two syncs,
|
|
4
|
+
two agents on two machines can both find the same card `ready` and both take it — each
|
|
5
|
+
sqlite grants its own lease because neither knows about the other. The engine already solves
|
|
6
|
+
this *within* one database (two INSERTs on one primary key, one winner, pinned by a
|
|
7
|
+
fifty-thread test); the fix is therefore not a new algorithm but a new PLACE: when a project
|
|
8
|
+
has a remote, `next` and `update` are executed in the server's sqlite, where the race is the
|
|
9
|
+
same race the engine already wins.
|
|
10
|
+
|
|
11
|
+
**The decision lives here, in the use cases, and not in a transport.** The CLI, the MCP tools
|
|
12
|
+
and the local HTTP board all call `next_task` and `update`; putting the routing in any one of
|
|
13
|
+
them would mean an agent that claims safely through MCP and unsafely through `taskops claim`.
|
|
14
|
+
|
|
15
|
+
**The server must never route to itself.** It runs these very use cases, so a `remote.json`
|
|
16
|
+
that happened to sit in the store it serves would make it POST to itself, forever. That is
|
|
17
|
+
why every server endpoint passes `local=True`: a flag the caller must opt into is checkable,
|
|
18
|
+
and `tests/e2e/test_agentwire.py` plants exactly that file and asserts the server answers.
|
|
19
|
+
|
|
20
|
+
**Offline never falls back to a local claim.** A remote-configured project whose server is
|
|
21
|
+
unreachable raises, naming the URL. Quietly claiming locally instead would be the collision
|
|
22
|
+
this module exists to prevent, delivered by the very code meant to prevent it.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
from typing import Any, Callable, cast
|
|
29
|
+
|
|
30
|
+
from .._errors import Unreachable
|
|
31
|
+
from ..contracts import NextResult, Remote, UpdateResult
|
|
32
|
+
from ..engine import identity
|
|
33
|
+
from ._mirroring import mirror_claim, mirror_update
|
|
34
|
+
from ._project import locate
|
|
35
|
+
from ._wireclient import Wire
|
|
36
|
+
from .remote import read_remote
|
|
37
|
+
|
|
38
|
+
__all__ = ["routed", "whoami", "claim_remotely", "update_remotely"]
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def routed(start: Path | str, local: bool) -> Remote | None:
|
|
42
|
+
"""The remote this write belongs to, or None when it belongs to this disk."""
|
|
43
|
+
return None if local else read_remote(start)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def whoami(start: Path | str, actor: str) -> str:
|
|
47
|
+
"""Resolve the actor HERE, before it crosses the wire.
|
|
48
|
+
|
|
49
|
+
The server cannot do it: it has neither this machine's `$TASKOPS_ACTOR` nor its git
|
|
50
|
+
config, so an empty actor would arrive and resolve to the server's own identity — every
|
|
51
|
+
remote agent filed as one `dev:` on the box.
|
|
52
|
+
"""
|
|
53
|
+
return identity.resolve(locate(start), actor)["id"]
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def claim_remotely(start: Path | str, remote: Remote, body: dict[str, Any]) -> NextResult:
|
|
57
|
+
answer = cast("NextResult", _relay(start, remote, lambda wire: wire.claim(body)))
|
|
58
|
+
if answer["claim"] is not None:
|
|
59
|
+
mirror_claim(start, answer["claim"])
|
|
60
|
+
return answer
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def update_remotely(start: Path | str, remote: Remote, body: dict[str, Any]) -> UpdateResult:
|
|
64
|
+
answer = cast("UpdateResult", _relay(start, remote, lambda wire: wire.change(body)))
|
|
65
|
+
mirror_update(start, answer["task"])
|
|
66
|
+
return answer
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _relay(start: Path | str, remote: Remote,
|
|
70
|
+
send: Callable[[Wire], dict[str, Any]]) -> dict[str, Any]:
|
|
71
|
+
"""Do it there, then pull. The pull is NOT best effort — see below.
|
|
72
|
+
|
|
73
|
+
Without it the agent holds a lease the server knows about and its own board does not, and
|
|
74
|
+
the board is what the commit guard, `taskops brief` and every render read. So a failed
|
|
75
|
+
pull fails the whole call: a half-success that reports a claim the local tooling will then
|
|
76
|
+
deny is worse than an error naming the network.
|
|
77
|
+
|
|
78
|
+
The pull carries everybody's events; `_mirroring` then writes the one thing an event cannot
|
|
79
|
+
carry — the lease this caller was just granted.
|
|
80
|
+
"""
|
|
81
|
+
from .pushpull import pull
|
|
82
|
+
|
|
83
|
+
try:
|
|
84
|
+
answer = send(Wire(remote["url"], remote["token"]))
|
|
85
|
+
except Unreachable as gone:
|
|
86
|
+
raise Unreachable(
|
|
87
|
+
f"this project's writes go to {remote['url']}, which did not answer "
|
|
88
|
+
f"({gone}) — taskops will NOT claim locally instead, because a local claim "
|
|
89
|
+
f"could collide with another machine's; retry, or check the network") from gone
|
|
90
|
+
pull(start)
|
|
91
|
+
return answer
|