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,127 @@
|
|
|
1
|
+
"""The events table — the append-only log every projection is derived from.
|
|
2
|
+
|
|
3
|
+
Writes are `INSERT OR IGNORE` on a content-hash id, which is what makes the whole
|
|
4
|
+
replication story work: the same event can arrive from a `git pull` and from the
|
|
5
|
+
relay, and the second one is a no-op instead of a duplicate.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import sqlite3
|
|
11
|
+
|
|
12
|
+
from ..contracts import Event
|
|
13
|
+
from ._rows import dumps, to_event
|
|
14
|
+
|
|
15
|
+
__all__ = ["EventTable"]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class EventTable:
|
|
19
|
+
def __init__(self, db: sqlite3.Connection) -> None:
|
|
20
|
+
self.db = db
|
|
21
|
+
|
|
22
|
+
def append(self, event: Event, *, exported: bool = False) -> bool:
|
|
23
|
+
"""True if this event is new here. False means we already had it.
|
|
24
|
+
|
|
25
|
+
`seq` is assigned by SQLite's own rowid sequence via a subquery rather
|
|
26
|
+
than AUTOINCREMENT on the primary key, because the primary key has to stay
|
|
27
|
+
the content hash — so local ordering and global identity are two columns
|
|
28
|
+
instead of one compromise.
|
|
29
|
+
"""
|
|
30
|
+
cursor = self.db.execute(
|
|
31
|
+
"INSERT OR IGNORE INTO events (id, seq, task, actor, kind, body, ts, "
|
|
32
|
+
"exported) VALUES (?, (SELECT COALESCE(MAX(seq), 0) + 1 FROM events), "
|
|
33
|
+
"?, ?, ?, ?, ?, ?)",
|
|
34
|
+
(event["id"], event["task"], event["actor"], event["kind"],
|
|
35
|
+
dumps(event["body"]), event["ts"], int(exported)))
|
|
36
|
+
return cursor.rowcount == 1
|
|
37
|
+
|
|
38
|
+
def of_task(self, task_id: str, *, kinds: tuple[str, ...] = ()) -> list[Event]:
|
|
39
|
+
"""One task's history, oldest first."""
|
|
40
|
+
sql = "SELECT * FROM events WHERE task=?"
|
|
41
|
+
params: tuple[object, ...] = (task_id,)
|
|
42
|
+
if kinds:
|
|
43
|
+
sql += f" AND kind IN ({', '.join('?' * len(kinds))})"
|
|
44
|
+
params += kinds
|
|
45
|
+
rows = self.db.execute(sql + " ORDER BY ts, seq", params).fetchall()
|
|
46
|
+
return [to_event(row) for row in rows]
|
|
47
|
+
|
|
48
|
+
def since(self, ts: float, *, limit: int = 500) -> list[Event]:
|
|
49
|
+
"""Everything after a moment — what a standup and the live feed both read."""
|
|
50
|
+
rows = self.db.execute("SELECT * FROM events WHERE ts > ? "
|
|
51
|
+
"ORDER BY ts, seq LIMIT ?", (ts, limit)).fetchall()
|
|
52
|
+
return [to_event(row) for row in rows]
|
|
53
|
+
|
|
54
|
+
def newest_since(self, ts: float, *, limit: int) -> list[Event]:
|
|
55
|
+
"""The LAST `limit` events in a window, oldest first — what a history view reads.
|
|
56
|
+
|
|
57
|
+
Not `since(...)[-limit:]`: `LIMIT` applies to the ascending scan, so that expression takes
|
|
58
|
+
the OLDEST rows and then shows them as the newest. On a thirty-day window over a busy
|
|
59
|
+
project it produced a timeline that was entirely correct and entirely the wrong end.
|
|
60
|
+
|
|
61
|
+
Ordered DESC to pick the tail and reversed here, so callers still get the log's own order.
|
|
62
|
+
"""
|
|
63
|
+
rows = self.db.execute("SELECT * FROM events WHERE ts > ? "
|
|
64
|
+
"ORDER BY ts DESC, seq DESC LIMIT ?", (ts, limit)).fetchall()
|
|
65
|
+
return [to_event(row) for row in reversed(rows)]
|
|
66
|
+
|
|
67
|
+
def after_seq(self, seq: int, *, limit: int = 200) -> list[Event]:
|
|
68
|
+
"""The studio's cursor read: strictly local order, no clock involved.
|
|
69
|
+
|
|
70
|
+
A separate process cannot see the in-process event bus, so the live board
|
|
71
|
+
polls this. Ordering by `ts` there would drop an event whose clock came
|
|
72
|
+
from a machine running slightly behind.
|
|
73
|
+
"""
|
|
74
|
+
return self.page_after(seq, limit=limit)[0]
|
|
75
|
+
|
|
76
|
+
def page_after(self, seq: int, *, limit: int) -> tuple[list[Event], int]:
|
|
77
|
+
"""The same read, plus the seq of the LAST row scanned — the replication cursor.
|
|
78
|
+
|
|
79
|
+
`Event` carries no `seq` on purpose: seq is this machine's local order, not the event's
|
|
80
|
+
identity, and putting it in the contract would invite a puller to store another
|
|
81
|
+
machine's numbering as if it were its own. So a page that ends mid-log has to hand its
|
|
82
|
+
cursor back beside the events rather than inside them.
|
|
83
|
+
|
|
84
|
+
Last SCANNED and not last returned, because a caller that filters the page (the HTTP
|
|
85
|
+
exchange drops local-only kinds) would otherwise re-scan the dropped tail forever.
|
|
86
|
+
"""
|
|
87
|
+
rows = self.db.execute(
|
|
88
|
+
"SELECT * FROM events WHERE seq > ? ORDER BY seq LIMIT ?", (seq, limit)
|
|
89
|
+
).fetchall()
|
|
90
|
+
return [to_event(row) for row in rows], int(rows[-1]["seq"]) if rows else seq
|
|
91
|
+
|
|
92
|
+
def max_seq(self) -> int:
|
|
93
|
+
row = self.db.execute("SELECT COALESCE(MAX(seq), 0) AS n FROM events").fetchone()
|
|
94
|
+
return int(row["n"])
|
|
95
|
+
|
|
96
|
+
def unexported(self, *, limit: int = 1000) -> list[Event]:
|
|
97
|
+
"""What the committed log has not seen yet, oldest first."""
|
|
98
|
+
rows = self.db.execute("SELECT * FROM events WHERE exported = 0 "
|
|
99
|
+
"ORDER BY seq LIMIT ?", (limit,)).fetchall()
|
|
100
|
+
return [to_event(row) for row in rows]
|
|
101
|
+
|
|
102
|
+
def mark_exported(self, ids: list[str]) -> None:
|
|
103
|
+
marks = ", ".join("?" * len(ids))
|
|
104
|
+
self.db.execute(f"UPDATE events SET exported = 1 WHERE id IN ({marks})",
|
|
105
|
+
tuple(ids))
|
|
106
|
+
|
|
107
|
+
def count_by_task(self, kind: str) -> dict[str, int]:
|
|
108
|
+
"""How many events of one kind each task has. ONE query for a whole board.
|
|
109
|
+
|
|
110
|
+
A board needs a commit count per card, and asking per task is what turns a
|
|
111
|
+
page that was fine at fifty tasks into one that stalls at five hundred.
|
|
112
|
+
"""
|
|
113
|
+
rows = self.db.execute("SELECT task, COUNT(*) AS n FROM events "
|
|
114
|
+
"WHERE kind=? GROUP BY task", (kind,)).fetchall()
|
|
115
|
+
return {str(row["task"]): int(row["n"]) for row in rows}
|
|
116
|
+
|
|
117
|
+
def latest_by_task(self, kind: str) -> dict[str, Event]:
|
|
118
|
+
"""The most recent event of one kind, per task — the fleet view's read.
|
|
119
|
+
|
|
120
|
+
`MAX(seq)` in a subquery rather than sorting in Python: this runs on every
|
|
121
|
+
board refresh, and `activity` is by far the largest kind in the table.
|
|
122
|
+
"""
|
|
123
|
+
rows = self.db.execute(
|
|
124
|
+
"SELECT * FROM events WHERE kind=? AND seq IN ("
|
|
125
|
+
" SELECT MAX(seq) FROM events WHERE kind=? GROUP BY task)",
|
|
126
|
+
(kind, kind)).fetchall()
|
|
127
|
+
return {str(row["task"]): to_event(row) for row in rows}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"""The leases table — where a hundred agents avoid each other.
|
|
2
|
+
|
|
3
|
+
The claim is ONE statement: `INSERT INTO leases` with `task` as the primary key,
|
|
4
|
+
preceded by a delete of whatever has expired. Two agents racing for one task are
|
|
5
|
+
two inserts on one key, and SQLite decides — no advisory lock, no lock file, no
|
|
6
|
+
retry loop. `Store` opens claims with BEGIN IMMEDIATE so the write lock is taken
|
|
7
|
+
before the expiry sweep reads, which is what stops the classic interleaving where
|
|
8
|
+
both sweeps delete the same dead lease and both then insert.
|
|
9
|
+
|
|
10
|
+
Every method takes `now` rather than reading a clock: expiry is the behaviour most
|
|
11
|
+
worth testing, and a test that has to sleep for fifteen minutes is a test nobody
|
|
12
|
+
runs.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import sqlite3
|
|
18
|
+
|
|
19
|
+
from ..contracts import Lease
|
|
20
|
+
from ._rows import to_lease
|
|
21
|
+
|
|
22
|
+
__all__ = ["LeaseTable"]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class LeaseTable:
|
|
26
|
+
def __init__(self, db: sqlite3.Connection) -> None:
|
|
27
|
+
self.db = db
|
|
28
|
+
|
|
29
|
+
def sweep(self, now: float) -> list[str]:
|
|
30
|
+
"""Drop expired leases; return the tasks that just came free.
|
|
31
|
+
|
|
32
|
+
Returned rather than silently dropped because a task whose agent died has
|
|
33
|
+
to be walked back out of `claimed` into `ready`, and only the caller knows
|
|
34
|
+
how to write that transition.
|
|
35
|
+
"""
|
|
36
|
+
rows = self.db.execute("SELECT task FROM leases WHERE expires <= ?", (now,)).fetchall()
|
|
37
|
+
self.db.execute("DELETE FROM leases WHERE expires <= ?", (now,))
|
|
38
|
+
return [str(row["task"]) for row in rows]
|
|
39
|
+
|
|
40
|
+
def acquire(self, lease: Lease) -> bool:
|
|
41
|
+
"""True if the claim landed, False if somebody else holds it.
|
|
42
|
+
|
|
43
|
+
`INSERT OR IGNORE` and then checking the row count is the atomic test —
|
|
44
|
+
asking first and inserting second is the race this exists to avoid.
|
|
45
|
+
"""
|
|
46
|
+
cursor = self.db.execute(
|
|
47
|
+
"INSERT OR IGNORE INTO leases (task, actor, session, branch, acquired, "
|
|
48
|
+
"expires) VALUES (?, ?, ?, ?, ?, ?)",
|
|
49
|
+
(lease["task"], lease["actor"], lease["session"], lease["branch"],
|
|
50
|
+
lease["acquired"], lease["expires"]))
|
|
51
|
+
return cursor.rowcount == 1
|
|
52
|
+
|
|
53
|
+
def get(self, task_id: str) -> Lease | None:
|
|
54
|
+
row = self.db.execute("SELECT * FROM leases WHERE task=?", (task_id,)).fetchone()
|
|
55
|
+
return to_lease(row) if row else None
|
|
56
|
+
|
|
57
|
+
def held_by(self, task_id: str, actor: str, now: float) -> bool:
|
|
58
|
+
"""Does this actor hold a LIVE lease on this task.
|
|
59
|
+
|
|
60
|
+
The precondition of every write an agent makes, so it checks expiry here
|
|
61
|
+
rather than trusting a sweep to have run: a lease that lapsed one second
|
|
62
|
+
ago is not a lease, whatever the table still says.
|
|
63
|
+
"""
|
|
64
|
+
row = self.db.execute(
|
|
65
|
+
"SELECT 1 FROM leases WHERE task=? AND actor=? AND expires > ?", (task_id, actor, now)
|
|
66
|
+
).fetchone()
|
|
67
|
+
return row is not None
|
|
68
|
+
|
|
69
|
+
def renew(self, *, task_id: str, actor: str, expires: float) -> bool:
|
|
70
|
+
"""Push the deadline out. Scoped to the holder, so a renewal cannot be
|
|
71
|
+
used to steal a task by naming somebody else's."""
|
|
72
|
+
cursor = self.db.execute(
|
|
73
|
+
"UPDATE leases SET expires=? WHERE task=? AND actor=?", (expires, task_id, actor)
|
|
74
|
+
)
|
|
75
|
+
return cursor.rowcount == 1
|
|
76
|
+
|
|
77
|
+
def set_branch(self, *, task_id: str, branch: str) -> None:
|
|
78
|
+
self.db.execute("UPDATE leases SET branch=? WHERE task=?", (branch, task_id))
|
|
79
|
+
|
|
80
|
+
def set_session(self, *, task_id: str, session: str) -> None:
|
|
81
|
+
"""Re-point a lease at the session now running it.
|
|
82
|
+
|
|
83
|
+
A resumed Claude Code session gets a NEW id, so without this the board shows a
|
|
84
|
+
live claim whose transcript path names a process that no longer exists.
|
|
85
|
+
"""
|
|
86
|
+
self.db.execute("UPDATE leases SET session=? WHERE task=?", (session, task_id))
|
|
87
|
+
|
|
88
|
+
def release(self, task_id: str) -> None:
|
|
89
|
+
self.db.execute("DELETE FROM leases WHERE task=?", (task_id,))
|
|
90
|
+
|
|
91
|
+
def live(self, now: float) -> list[Lease]:
|
|
92
|
+
rows = self.db.execute(
|
|
93
|
+
"SELECT * FROM leases WHERE expires > ? ORDER BY acquired", (now,)
|
|
94
|
+
).fetchall()
|
|
95
|
+
return [to_lease(row) for row in rows]
|
|
96
|
+
|
|
97
|
+
def of_actor(self, actor: str, now: float) -> list[Lease]:
|
|
98
|
+
rows = self.db.execute(
|
|
99
|
+
"SELECT * FROM leases WHERE actor=? AND expires > ? ORDER BY acquired", (actor, now)
|
|
100
|
+
).fetchall()
|
|
101
|
+
return [to_lease(row) for row in rows]
|
|
102
|
+
|
|
103
|
+
def of_session(self, session: str) -> list[Lease]:
|
|
104
|
+
"""What this Claude Code session is holding — the SessionStart read."""
|
|
105
|
+
rows = self.db.execute(
|
|
106
|
+
"SELECT * FROM leases WHERE session=? ORDER BY acquired", (session,)
|
|
107
|
+
).fetchall()
|
|
108
|
+
return [to_lease(row) for row in rows]
|
taskops/storage/_rows.py
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""Rows in, contracts out. The one place that knows a column is JSON.
|
|
2
|
+
|
|
3
|
+
`labels`, `files` and `body` are JSON text in SQLite and lists or dicts everywhere
|
|
4
|
+
else. Doing that conversion in each table object meant four places knowing which
|
|
5
|
+
columns are encoded, and the fourth one always forgets — so it happens here, and a
|
|
6
|
+
table's job shrinks to writing SQL.
|
|
7
|
+
|
|
8
|
+
A stored value that is not valid JSON DEGRADES to empty rather than raising: these
|
|
9
|
+
columns are also written by `sync` from a file another machine's taskops appended,
|
|
10
|
+
and one malformed line must not make the whole board unreadable. The event
|
|
11
|
+
`body` is the extreme case — a newer version writes shapes this one has not seen.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import json
|
|
17
|
+
import sqlite3
|
|
18
|
+
from typing import Any, cast
|
|
19
|
+
|
|
20
|
+
from ..contracts import Event, Lease, Task
|
|
21
|
+
|
|
22
|
+
__all__ = ["dumps", "as_list", "as_dict", "to_task", "to_lease", "to_event"]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def dumps(value: object) -> str:
|
|
26
|
+
"""Compact and key-sorted: the text is compared across machines during sync,
|
|
27
|
+
so two equal values must serialize identically."""
|
|
28
|
+
return json.dumps(value, sort_keys=True, separators=(",", ":"), default=str)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def as_list(raw: object) -> list[str]:
|
|
32
|
+
parsed = _loads(raw)
|
|
33
|
+
if not isinstance(parsed, list):
|
|
34
|
+
return []
|
|
35
|
+
return [str(item) for item in cast("list[object]", parsed)]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def as_dict(raw: object) -> dict[str, Any]:
|
|
39
|
+
parsed = _loads(raw)
|
|
40
|
+
return cast("dict[str, Any]", parsed) if isinstance(parsed, dict) else {}
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _loads(raw: object) -> object:
|
|
44
|
+
if not isinstance(raw, str) or not raw:
|
|
45
|
+
return None
|
|
46
|
+
try:
|
|
47
|
+
return json.loads(raw)
|
|
48
|
+
except json.JSONDecodeError:
|
|
49
|
+
return None
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def to_task(row: sqlite3.Row) -> Task:
|
|
53
|
+
return Task(
|
|
54
|
+
id=str(row["id"]),
|
|
55
|
+
title=str(row["title"]),
|
|
56
|
+
spec=str(row["spec"]),
|
|
57
|
+
status=row["status"],
|
|
58
|
+
priority=int(row["priority"]),
|
|
59
|
+
parent=str(row["parent"]) if row["parent"] else None,
|
|
60
|
+
labels=as_list(row["labels"]),
|
|
61
|
+
files=as_list(row["files"]),
|
|
62
|
+
created_by=str(row["created_by"]),
|
|
63
|
+
# `or ""` and not `str(...)`: a row that predates the column reads as NULL after the ALTER,
|
|
64
|
+
# and `str(None)` would put the string "None" in it — an assignee nobody can be.
|
|
65
|
+
assignee=str(row["assignee"] or ""),
|
|
66
|
+
created=float(row["created"]),
|
|
67
|
+
updated=float(row["updated"]),
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def to_lease(row: sqlite3.Row) -> Lease:
|
|
72
|
+
return Lease(
|
|
73
|
+
task=str(row["task"]),
|
|
74
|
+
actor=str(row["actor"]),
|
|
75
|
+
session=str(row["session"]),
|
|
76
|
+
branch=str(row["branch"]),
|
|
77
|
+
acquired=float(row["acquired"]),
|
|
78
|
+
expires=float(row["expires"]),
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def to_event(row: sqlite3.Row) -> Event:
|
|
83
|
+
return Event(
|
|
84
|
+
id=str(row["id"]),
|
|
85
|
+
task=str(row["task"]),
|
|
86
|
+
actor=str(row["actor"]),
|
|
87
|
+
kind=row["kind"],
|
|
88
|
+
body=as_dict(row["body"]),
|
|
89
|
+
ts=float(row["ts"]),
|
|
90
|
+
)
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
"""The tasks table."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sqlite3
|
|
6
|
+
|
|
7
|
+
from .._errors import BadRequest, NoSuchTask
|
|
8
|
+
from .._types import EDITABLE_FIELDS, Status
|
|
9
|
+
from ..contracts import Task
|
|
10
|
+
from ._rows import dumps, to_task
|
|
11
|
+
|
|
12
|
+
__all__ = ["TaskTable"]
|
|
13
|
+
|
|
14
|
+
_COLUMNS = ("id", "title", "spec", "status", "priority", "parent", "labels",
|
|
15
|
+
"files", "created_by", "assignee", "created", "updated")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _values(task: Task) -> tuple[object, ...]:
|
|
19
|
+
"""A task in `_COLUMNS` order. One function, so the two orders cannot drift."""
|
|
20
|
+
return (task["id"], task["title"], task["spec"], task["status"],
|
|
21
|
+
task["priority"], task["parent"], dumps(task["labels"]),
|
|
22
|
+
dumps(task["files"]), task["created_by"], task["assignee"],
|
|
23
|
+
task["created"], task["updated"])
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class TaskTable:
|
|
27
|
+
def __init__(self, db: sqlite3.Connection) -> None:
|
|
28
|
+
self.db = db
|
|
29
|
+
|
|
30
|
+
def insert(self, task: Task) -> None:
|
|
31
|
+
"""Fails LOUDLY on a duplicate id rather than replacing.
|
|
32
|
+
|
|
33
|
+
Task ids are random, so a collision is either a 1-in-16-million accident or
|
|
34
|
+
a caller re-sending a plan — and silently overwriting the second case would
|
|
35
|
+
destroy a task somebody is already working on.
|
|
36
|
+
"""
|
|
37
|
+
marks = ", ".join("?" * len(_COLUMNS))
|
|
38
|
+
self.db.execute(f"INSERT INTO tasks ({', '.join(_COLUMNS)}) "
|
|
39
|
+
f"VALUES ({marks})", _values(task))
|
|
40
|
+
|
|
41
|
+
def upsert(self, task: Task) -> None:
|
|
42
|
+
"""Last-writer-wins by `updated`, for the sync path only.
|
|
43
|
+
|
|
44
|
+
Two machines editing one task is rare and the log records both edits, so
|
|
45
|
+
the loser is recoverable. Refusing the import instead would make a
|
|
46
|
+
`git pull` fail on something git itself merged cleanly.
|
|
47
|
+
"""
|
|
48
|
+
if self.get(task["id"]) is None:
|
|
49
|
+
self.insert(task)
|
|
50
|
+
return
|
|
51
|
+
self.db.execute(
|
|
52
|
+
"UPDATE tasks SET title=?, spec=?, status=?, priority=?, parent=?, "
|
|
53
|
+
"labels=?, files=?, updated=? WHERE id=? AND updated<=?",
|
|
54
|
+
(task["title"], task["spec"], task["status"], task["priority"],
|
|
55
|
+
task["parent"], dumps(task["labels"]), dumps(task["files"]),
|
|
56
|
+
task["updated"], task["id"], task["updated"]))
|
|
57
|
+
|
|
58
|
+
def get(self, task_id: str) -> Task | None:
|
|
59
|
+
row = self.db.execute("SELECT * FROM tasks WHERE id=?", (task_id,)).fetchone()
|
|
60
|
+
return to_task(row) if row else None
|
|
61
|
+
|
|
62
|
+
def need(self, task_id: str) -> Task:
|
|
63
|
+
"""The read that refuses to return None — most callers cannot proceed."""
|
|
64
|
+
found = self.get(task_id)
|
|
65
|
+
if found is None:
|
|
66
|
+
raise NoSuchTask.named(task_id)
|
|
67
|
+
return found
|
|
68
|
+
|
|
69
|
+
def set_assignee(self, task_id: str, actor: str, *, when: float) -> None:
|
|
70
|
+
"""Point a card at somebody, or back at the pool with "".
|
|
71
|
+
|
|
72
|
+
Its own method rather than a general `update`: assignment changes who the SCHEDULER will
|
|
73
|
+
offer the card to, which is a different kind of write from editing a title, and the two want
|
|
74
|
+
different events in the log.
|
|
75
|
+
"""
|
|
76
|
+
self.db.execute("UPDATE tasks SET assignee=?, updated=? WHERE id=?",
|
|
77
|
+
(actor, when, task_id))
|
|
78
|
+
|
|
79
|
+
def set_field(self, task_id: str, field: str, value: object, *, when: float) -> None:
|
|
80
|
+
"""Rewrite ONE editable column — a title, a spec, a priority.
|
|
81
|
+
|
|
82
|
+
The column name is interpolated, so it is checked against `EDITABLE_FIELDS` first and
|
|
83
|
+
nowhere else: a whitelist at the only place that builds the SQL is the difference
|
|
84
|
+
between a typo and an injection, and every caller above reaches this one door.
|
|
85
|
+
"""
|
|
86
|
+
if field not in EDITABLE_FIELDS:
|
|
87
|
+
raise BadRequest(f"`{field}` is not editable — use one of "
|
|
88
|
+
f"{', '.join(EDITABLE_FIELDS)}")
|
|
89
|
+
self.db.execute(f"UPDATE tasks SET {field}=?, updated=? WHERE id=?",
|
|
90
|
+
(value, when, task_id))
|
|
91
|
+
|
|
92
|
+
def set_status(self, task_id: str, status: Status, *, when: float) -> None:
|
|
93
|
+
self.db.execute("UPDATE tasks SET status=?, updated=? WHERE id=?",
|
|
94
|
+
(status, when, task_id))
|
|
95
|
+
|
|
96
|
+
def all(self) -> list[Task]:
|
|
97
|
+
"""Every task, most urgent first. The board's one read."""
|
|
98
|
+
rows = self.db.execute("SELECT * FROM tasks "
|
|
99
|
+
"ORDER BY priority, created").fetchall()
|
|
100
|
+
return [to_task(row) for row in rows]
|
|
101
|
+
|
|
102
|
+
def with_status(self, statuses: tuple[str, ...]) -> list[Task]:
|
|
103
|
+
marks = ", ".join("?" * len(statuses))
|
|
104
|
+
rows = self.db.execute(f"SELECT * FROM tasks WHERE status IN ({marks}) "
|
|
105
|
+
f"ORDER BY priority, created", statuses).fetchall()
|
|
106
|
+
return [to_task(row) for row in rows]
|
|
107
|
+
|
|
108
|
+
def children(self, parent: str) -> list[Task]:
|
|
109
|
+
rows = self.db.execute("SELECT * FROM tasks WHERE parent=? "
|
|
110
|
+
"ORDER BY priority, created", (parent,)).fetchall()
|
|
111
|
+
return [to_task(row) for row in rows]
|
|
112
|
+
|
|
113
|
+
def search(self, text: str, *, limit: int = 20) -> list[Task]:
|
|
114
|
+
"""Substring over title and spec, case-insensitive.
|
|
115
|
+
|
|
116
|
+
Not FTS5: it is a compile-time option missing from some distro pythons, and
|
|
117
|
+
a task list is thousands of rows rather than millions. A LIKE scan that
|
|
118
|
+
always works beats an index absent on a teammate's machine.
|
|
119
|
+
"""
|
|
120
|
+
like = f"%{text.lower()}%"
|
|
121
|
+
rows = self.db.execute(
|
|
122
|
+
"SELECT * FROM tasks WHERE LOWER(title) LIKE ? OR LOWER(spec) LIKE ? "
|
|
123
|
+
"ORDER BY priority, created LIMIT ?", (like, like, limit)).fetchall()
|
|
124
|
+
return [to_task(row) for row in rows]
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""Finding the project from any path inside it.
|
|
2
|
+
|
|
3
|
+
Every surface takes a path from someone who was not thinking about where the
|
|
4
|
+
project root is — an agent passes its cwd, a git hook passes wherever git ran it,
|
|
5
|
+
the studio passes whatever was typed. Walking up for `.taskops/` is what makes all
|
|
6
|
+
three the same call, and it is the same convention as `.git` for the same reason.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import os
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
from .._errors import NotInitialized
|
|
15
|
+
|
|
16
|
+
__all__ = ["PROJECT_DIR", "DB_FILE", "LOG_FILE", "GUIDE_FILE", "REPORTS_DIR", "ENV_ROOT",
|
|
17
|
+
"resolve_root", "find_root"]
|
|
18
|
+
|
|
19
|
+
PROJECT_DIR = ".taskops"
|
|
20
|
+
|
|
21
|
+
DB_FILE = f"{PROJECT_DIR}/db.sqlite"
|
|
22
|
+
"""GITIGNORED. Derived state — every row in it can be rebuilt from the log."""
|
|
23
|
+
|
|
24
|
+
LOG_FILE = f"{PROJECT_DIR}/events.jsonl"
|
|
25
|
+
"""COMMITTED. The shared source of truth: append-only, one JSON object per line,
|
|
26
|
+
ids that are content hashes. That combination is what lets two developers merge
|
|
27
|
+
their agents' work with `git pull` and no server — a conflict needs two writers of
|
|
28
|
+
the same line, and appenders never produce one."""
|
|
29
|
+
|
|
30
|
+
GUIDE_FILE = f"{PROJECT_DIR}/GUIDE.md"
|
|
31
|
+
"""The agent-facing manual `taskops init` writes. Read by humans too, which is the
|
|
32
|
+
point: one document, so the two cannot be told different things."""
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
REPORTS_DIR = f"{PROJECT_DIR}/reports"
|
|
36
|
+
"""COMMITTED, unlike everything else generated here. A daily dossier is written ONCE and
|
|
37
|
+
then narrated by a human or a session — so it stops being derived state the moment somebody
|
|
38
|
+
adds a paragraph to it, and a regenerated copy could not carry that paragraph back."""
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
ENV_ROOT = "TASKOPS_ROOT"
|
|
42
|
+
"""Overrides the search entirely. THE mechanism that makes dispatched workers work.
|
|
43
|
+
|
|
44
|
+
A worker runs in a `git worktree`, which has no `.taskops/` of its own — so walking up would find
|
|
45
|
+
either nothing or, worse, some unrelated project further up the filesystem. With this set, N workers
|
|
46
|
+
in N worktrees all coordinate through ONE board, which is the whole point of dispatching them.
|
|
47
|
+
|
|
48
|
+
An override and not a fallback: if it is set and wrong, that is an error worth seeing rather than a
|
|
49
|
+
silent walk up to a different project.
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def find_root(start: Path | str) -> Path | None:
|
|
54
|
+
"""The nearest ancestor holding `.taskops/`, or None. `$TASKOPS_ROOT` wins outright."""
|
|
55
|
+
forced = os.environ.get(ENV_ROOT, "").strip()
|
|
56
|
+
if forced:
|
|
57
|
+
candidate = Path(forced).expanduser().resolve()
|
|
58
|
+
return candidate if (candidate / PROJECT_DIR).is_dir() else None
|
|
59
|
+
here = Path(start).expanduser().resolve()
|
|
60
|
+
for candidate in (here, *here.parents):
|
|
61
|
+
if (candidate / PROJECT_DIR).is_dir():
|
|
62
|
+
return candidate
|
|
63
|
+
return None
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def resolve_root(start: Path | str) -> Path:
|
|
67
|
+
"""Same, but a missing project is an error naming the fix.
|
|
68
|
+
|
|
69
|
+
Raising rather than creating: `taskops init` also installs git hooks and
|
|
70
|
+
writes the guide, and a store that appeared silently under whatever directory
|
|
71
|
+
an agent happened to be in would be a project nobody knows exists.
|
|
72
|
+
"""
|
|
73
|
+
root = find_root(start)
|
|
74
|
+
if root is None:
|
|
75
|
+
raise NotInitialized.at(start)
|
|
76
|
+
return root
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""Bringing a database up to date. The tables themselves are in `_ddl`.
|
|
2
|
+
|
|
3
|
+
One database file per repository at `<repo>/.taskops/db.sqlite`, and it is a CACHE
|
|
4
|
+
— `storage.sync` can rebuild every row from the committed event log, which is why
|
|
5
|
+
it is gitignored and why a schema change needs no data-migration story.
|
|
6
|
+
|
|
7
|
+
**WAL, unlike megabrain.** That engine documents its rollback-journal choice as a
|
|
8
|
+
property of its workload rather than a preference: writes there are rare, so
|
|
9
|
+
readers already never wait. Here the workload is the opposite — a hundred agents
|
|
10
|
+
renewing leases and appending events continuously — and the studio reads this
|
|
11
|
+
database on every board refresh while they do. Under a rollback journal that
|
|
12
|
+
refresh stalls on each commit; WAL is what lets a reader run beside a writer.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import sqlite3
|
|
18
|
+
|
|
19
|
+
from ._ddl import DDL
|
|
20
|
+
|
|
21
|
+
__all__ = ["apply"]
|
|
22
|
+
|
|
23
|
+
_PRAGMAS = ("PRAGMA journal_mode=WAL", "PRAGMA synchronous=NORMAL")
|
|
24
|
+
"""`synchronous=NORMAL` and not FULL: under WAL that risks losing the last commits
|
|
25
|
+
to an OS crash, never corruption — and the events those commits carry are also in
|
|
26
|
+
the log or re-derivable from git. Paying a full fsync per lease renewal to protect
|
|
27
|
+
data that is recoverable anyway is the wrong trade at a hundred agents.
|
|
28
|
+
|
|
29
|
+
`foreign_keys` is deliberately NOT set. There are no FK constraints in the schema:
|
|
30
|
+
`sync` imports events referring to tasks a `git pull` has not delivered yet, and a
|
|
31
|
+
constraint would reject exactly the events that make the project converge.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
_LATE_COLUMNS: tuple[tuple[str, str], ...] = (
|
|
35
|
+
("tasks", "assignee TEXT NOT NULL DEFAULT ''"),
|
|
36
|
+
)
|
|
37
|
+
"""Columns added after a table shipped, applied with ALTER on every open so a database written by an
|
|
38
|
+
older taskops keeps working with no migration step anyone has to remember.
|
|
39
|
+
|
|
40
|
+
`assignee` is the mechanism's first user, and it worked exactly as intended: a database created
|
|
41
|
+
before dispatch existed gains the column on its next open, and every row defaults to the open pool —
|
|
42
|
+
which is what those rows meant."""
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def apply(db: sqlite3.Connection) -> None:
|
|
46
|
+
"""Create everything missing and backfill late columns. Idempotent."""
|
|
47
|
+
for pragma in _PRAGMAS:
|
|
48
|
+
db.execute(pragma)
|
|
49
|
+
db.executescript(DDL)
|
|
50
|
+
for table, column in _LATE_COLUMNS:
|
|
51
|
+
_add_column(db, table, column)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _add_column(db: sqlite3.Connection, table: str, column: str) -> None:
|
|
55
|
+
"""Add a late column, tolerating ONLY the already-present case.
|
|
56
|
+
|
|
57
|
+
A bare `except OperationalError: pass` also swallows "database is locked",
|
|
58
|
+
"disk I/O error" and "no such table" — every real reason a migration fails. The
|
|
59
|
+
database then opens against a schema nobody migrated and fails later, somewhere
|
|
60
|
+
else, with no trace of the migration that quietly gave up.
|
|
61
|
+
"""
|
|
62
|
+
try:
|
|
63
|
+
db.execute(f"ALTER TABLE {table} ADD COLUMN {column}")
|
|
64
|
+
except sqlite3.OperationalError as err:
|
|
65
|
+
if "duplicate column name" not in str(err).lower():
|
|
66
|
+
raise
|