tackboard 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- tackboard/__init__.py +13 -0
- tackboard/__main__.py +8 -0
- tackboard/claims.py +264 -0
- tackboard/cli.py +1240 -0
- tackboard/config.py +121 -0
- tackboard/db.py +110 -0
- tackboard/dependencies.py +414 -0
- tackboard/errors.py +138 -0
- tackboard/history.py +130 -0
- tackboard/models.py +218 -0
- tackboard/schema.py +606 -0
- tackboard/service.py +1295 -0
- tackboard/static/README +19 -0
- tackboard/static/app.css +190 -0
- tackboard/static/graph.js +283 -0
- tackboard/static/refresh.js +31 -0
- tackboard/templates/base.html +31 -0
- tackboard/templates/board.html +13 -0
- tackboard/templates/card.html +11 -0
- tackboard/templates/column.html +10 -0
- tackboard/templates/dep_line.html +1 -0
- tackboard/templates/error.html +5 -0
- tackboard/templates/filters.html +33 -0
- tackboard/templates/graph.html +29 -0
- tackboard/templates/history.html +1 -0
- tackboard/templates/note.html +1 -0
- tackboard/templates/show_all.html +1 -0
- tackboard/templates/task.html +34 -0
- tackboard/views.py +140 -0
- tackboard/web.py +902 -0
- tackboard-0.1.0.dist-info/METADATA +497 -0
- tackboard-0.1.0.dist-info/RECORD +35 -0
- tackboard-0.1.0.dist-info/WHEEL +4 -0
- tackboard-0.1.0.dist-info/entry_points.txt +3 -0
- tackboard-0.1.0.dist-info/licenses/LICENSE +21 -0
tackboard/__init__.py
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""tackboard — a project-scoped task board for coordinating multiple AI agents.
|
|
2
|
+
|
|
3
|
+
One SQLite database per project, a stable CLI as the primary interface, and a
|
|
4
|
+
read-only web view for the human operator. The public surface is the ``tack``
|
|
5
|
+
command; see ``README.md`` for the CLI contract and ``DESIGN.md`` for the
|
|
6
|
+
architecture.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
__version__ = "0.1.0"
|
|
12
|
+
|
|
13
|
+
__all__ = ["__version__"]
|
tackboard/__main__.py
ADDED
tackboard/claims.py
ADDED
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
"""Claims and multi-agent concurrency (spec section 8).
|
|
2
|
+
|
|
3
|
+
The claim/status policy lives in exactly one place — ``service.apply_transition``
|
|
4
|
+
— so every entry point applies the same table. This module is the public claim
|
|
5
|
+
surface plus the atomic ``next --claim`` selector.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import re
|
|
11
|
+
import sqlite3
|
|
12
|
+
from datetime import datetime
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
from .config import validate_agent_id
|
|
16
|
+
from .db import read_transaction, write_transaction
|
|
17
|
+
from .errors import UsageError, ValidationError
|
|
18
|
+
from .models import PRIORITY_RANK, utcnow_iso
|
|
19
|
+
from .service import (
|
|
20
|
+
_check_priority,
|
|
21
|
+
_label_key,
|
|
22
|
+
_project_rows,
|
|
23
|
+
apply_transition,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
STAGES: tuple[str, ...] = ("work", "review")
|
|
27
|
+
|
|
28
|
+
_DURATION_RE = re.compile(r"\s*(\d+)\s*([smhd])\s*\Z")
|
|
29
|
+
_DURATION_UNITS = {"s": 1, "m": 60, "h": 3600, "d": 86400}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def parse_duration(text: str) -> int:
|
|
33
|
+
"""Parse a duration such as ``2h``, ``30m``, ``45s`` or ``1d`` into seconds."""
|
|
34
|
+
match = _DURATION_RE.match(text)
|
|
35
|
+
if match is None:
|
|
36
|
+
raise ValidationError(
|
|
37
|
+
f"Invalid duration {text!r}; use a number with a unit suffix "
|
|
38
|
+
"(s, m, h, d), for example 2h."
|
|
39
|
+
)
|
|
40
|
+
amount, unit = match.groups()
|
|
41
|
+
return int(amount) * _DURATION_UNITS[unit]
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _age_seconds(claimed_at: str) -> int:
|
|
45
|
+
moment = datetime.strptime(claimed_at, "%Y-%m-%dT%H:%M:%SZ")
|
|
46
|
+
now = datetime.strptime(utcnow_iso(), "%Y-%m-%dT%H:%M:%SZ")
|
|
47
|
+
return max(0, int((now - moment).total_seconds()))
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def active_claim(conn: sqlite3.Connection, task_id: str) -> dict[str, Any] | None:
|
|
51
|
+
"""Return the active claim on a task, or ``None``."""
|
|
52
|
+
row = conn.execute("SELECT * FROM claims WHERE task_id = ?", (task_id,)).fetchone()
|
|
53
|
+
if row is None:
|
|
54
|
+
return None
|
|
55
|
+
return {
|
|
56
|
+
"task_id": row["task_id"],
|
|
57
|
+
"agent_id": row["agent_id"],
|
|
58
|
+
"claimed_at": row["claimed_at"],
|
|
59
|
+
"age_seconds": _age_seconds(row["claimed_at"]),
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def claim(
|
|
64
|
+
conn: sqlite3.Connection,
|
|
65
|
+
task_id: str,
|
|
66
|
+
*,
|
|
67
|
+
actor: str,
|
|
68
|
+
force: bool = False,
|
|
69
|
+
reason: str | None = None,
|
|
70
|
+
if_version: int | None = None,
|
|
71
|
+
) -> dict[str, Any]:
|
|
72
|
+
"""Claim a task (spec 8.2); the status change and claim commit together."""
|
|
73
|
+
return apply_transition(
|
|
74
|
+
conn,
|
|
75
|
+
task_id,
|
|
76
|
+
actor=actor,
|
|
77
|
+
mode="claim",
|
|
78
|
+
if_version=if_version,
|
|
79
|
+
force=force,
|
|
80
|
+
reason=reason,
|
|
81
|
+
event_type="task.claimed",
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def release(
|
|
86
|
+
conn: sqlite3.Connection,
|
|
87
|
+
task_id: str,
|
|
88
|
+
*,
|
|
89
|
+
actor: str,
|
|
90
|
+
force: bool = False,
|
|
91
|
+
reason: str | None = None,
|
|
92
|
+
if_version: int | None = None,
|
|
93
|
+
) -> dict[str, Any]:
|
|
94
|
+
"""Release a claim (spec 8.2); ``in_progress`` returns to ``ready``."""
|
|
95
|
+
return apply_transition(
|
|
96
|
+
conn,
|
|
97
|
+
task_id,
|
|
98
|
+
actor=actor,
|
|
99
|
+
mode="release",
|
|
100
|
+
if_version=if_version,
|
|
101
|
+
force=force,
|
|
102
|
+
reason=reason,
|
|
103
|
+
event_type="task.released",
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def list_claims(
|
|
108
|
+
conn: sqlite3.Connection,
|
|
109
|
+
*,
|
|
110
|
+
agent: str | None = None,
|
|
111
|
+
older_than: str | None = None,
|
|
112
|
+
) -> dict[str, Any]:
|
|
113
|
+
"""Inspect active claims, optionally by agent or minimum age (spec 8.1)."""
|
|
114
|
+
if agent is not None:
|
|
115
|
+
validate_agent_id(agent)
|
|
116
|
+
threshold = parse_duration(older_than) if older_than is not None else None
|
|
117
|
+
|
|
118
|
+
with read_transaction(conn):
|
|
119
|
+
sql = """
|
|
120
|
+
SELECT c.task_id AS task_id, c.agent_id AS agent_id,
|
|
121
|
+
c.claimed_at AS claimed_at
|
|
122
|
+
FROM claims c JOIN tasks t ON t.id = c.task_id
|
|
123
|
+
"""
|
|
124
|
+
params: list[Any] = []
|
|
125
|
+
if agent is not None:
|
|
126
|
+
sql += " WHERE c.agent_id = ?"
|
|
127
|
+
params.append(agent)
|
|
128
|
+
sql += " ORDER BY c.claimed_at, c.task_id"
|
|
129
|
+
rows = conn.execute(sql, params).fetchall()
|
|
130
|
+
tasks = {
|
|
131
|
+
task["id"]: task
|
|
132
|
+
for task in _project_rows(
|
|
133
|
+
conn,
|
|
134
|
+
conn.execute(
|
|
135
|
+
"""
|
|
136
|
+
SELECT t.* FROM tasks t
|
|
137
|
+
JOIN claims c ON c.task_id = t.id
|
|
138
|
+
"""
|
|
139
|
+
).fetchall(),
|
|
140
|
+
full=False,
|
|
141
|
+
)
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
items: list[dict[str, Any]] = []
|
|
145
|
+
for row in rows:
|
|
146
|
+
age = _age_seconds(row["claimed_at"])
|
|
147
|
+
if threshold is not None and age < threshold:
|
|
148
|
+
continue
|
|
149
|
+
items.append(
|
|
150
|
+
{
|
|
151
|
+
"task_id": row["task_id"],
|
|
152
|
+
"agent_id": row["agent_id"],
|
|
153
|
+
"claimed_at": row["claimed_at"],
|
|
154
|
+
"age_seconds": age,
|
|
155
|
+
"task": tasks.get(str(row["task_id"])),
|
|
156
|
+
}
|
|
157
|
+
)
|
|
158
|
+
return {"schema_version": 1, "items": items}
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _candidates(
|
|
162
|
+
conn: sqlite3.Connection,
|
|
163
|
+
*,
|
|
164
|
+
actor: str,
|
|
165
|
+
stage: str,
|
|
166
|
+
label: str | None,
|
|
167
|
+
min_priority: str | None,
|
|
168
|
+
all_assignees: bool,
|
|
169
|
+
full: bool,
|
|
170
|
+
) -> list[dict[str, Any]]:
|
|
171
|
+
sql = "SELECT t.* FROM tasks t"
|
|
172
|
+
params: list[Any] = []
|
|
173
|
+
if label is not None:
|
|
174
|
+
sql += " JOIN task_labels l ON l.task_id = t.id AND l.label_key = ?"
|
|
175
|
+
params.append(_label_key(label))
|
|
176
|
+
sql += " WHERE t.status = ?"
|
|
177
|
+
params.append("ready" if stage == "work" else "review")
|
|
178
|
+
rows = conn.execute(sql, params).fetchall()
|
|
179
|
+
|
|
180
|
+
tasks = _project_rows(conn, rows, full=full)
|
|
181
|
+
flag = "eligible" if stage == "work" else "reviewable"
|
|
182
|
+
tasks = [task for task in tasks if task[flag]]
|
|
183
|
+
if not all_assignees:
|
|
184
|
+
tasks = [task for task in tasks if task["assignee"] in (None, actor)]
|
|
185
|
+
if min_priority is not None:
|
|
186
|
+
floor = PRIORITY_RANK[min_priority]
|
|
187
|
+
tasks = [
|
|
188
|
+
task for task in tasks if PRIORITY_RANK[str(task["priority"])] >= floor
|
|
189
|
+
]
|
|
190
|
+
tasks.sort(key=_next_sort_key)
|
|
191
|
+
return tasks
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _next_sort_key(task: dict[str, Any]) -> tuple[int, str, int]:
|
|
195
|
+
sequence = int(str(task["id"]).split("-", 1)[1])
|
|
196
|
+
return (-PRIORITY_RANK[str(task["priority"])], str(task["created_at"]), sequence)
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def next_task(
|
|
200
|
+
conn: sqlite3.Connection,
|
|
201
|
+
*,
|
|
202
|
+
actor: str,
|
|
203
|
+
stage: str = "work",
|
|
204
|
+
claim: bool = False,
|
|
205
|
+
force: bool = False,
|
|
206
|
+
reason: str | None = None,
|
|
207
|
+
label: str | None = None,
|
|
208
|
+
min_priority: str | None = None,
|
|
209
|
+
all_assignees: bool = False,
|
|
210
|
+
full: bool = False,
|
|
211
|
+
) -> dict[str, Any]:
|
|
212
|
+
"""Select the next task for ``actor``; ``claim=True`` selects and claims in
|
|
213
|
+
one ``BEGIN IMMEDIATE`` transaction (spec 10.4)."""
|
|
214
|
+
actor = validate_agent_id(actor)
|
|
215
|
+
if stage not in STAGES:
|
|
216
|
+
raise ValidationError(
|
|
217
|
+
f"Unknown stage {stage!r}; expected one of {', '.join(STAGES)}."
|
|
218
|
+
)
|
|
219
|
+
if force and not claim:
|
|
220
|
+
raise UsageError("--force is only meaningful together with --claim.")
|
|
221
|
+
if min_priority is not None:
|
|
222
|
+
_check_priority(min_priority)
|
|
223
|
+
|
|
224
|
+
arguments: dict[str, Any] = {
|
|
225
|
+
"actor": actor,
|
|
226
|
+
"stage": stage,
|
|
227
|
+
"label": label,
|
|
228
|
+
"min_priority": min_priority,
|
|
229
|
+
"all_assignees": all_assignees,
|
|
230
|
+
"full": full,
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
if claim:
|
|
234
|
+
with write_transaction(conn):
|
|
235
|
+
candidates = _candidates(conn, **arguments)
|
|
236
|
+
if not candidates:
|
|
237
|
+
return {"task": None, "tasks": []}
|
|
238
|
+
result = apply_transition(
|
|
239
|
+
conn,
|
|
240
|
+
str(candidates[0]["id"]),
|
|
241
|
+
actor=actor,
|
|
242
|
+
mode="claim",
|
|
243
|
+
force=force,
|
|
244
|
+
reason=reason,
|
|
245
|
+
event_type="task.claimed",
|
|
246
|
+
)
|
|
247
|
+
claimed_id = str(result["task"]["id"])
|
|
248
|
+
candidates = [task for task in candidates if task["id"] != claimed_id]
|
|
249
|
+
return {"task": result["task"], "tasks": candidates}
|
|
250
|
+
|
|
251
|
+
with read_transaction(conn):
|
|
252
|
+
candidates = _candidates(conn, **arguments)
|
|
253
|
+
return {"task": candidates[0] if candidates else None, "tasks": candidates}
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
__all__ = [
|
|
257
|
+
"STAGES",
|
|
258
|
+
"active_claim",
|
|
259
|
+
"claim",
|
|
260
|
+
"list_claims",
|
|
261
|
+
"next_task",
|
|
262
|
+
"parse_duration",
|
|
263
|
+
"release",
|
|
264
|
+
]
|