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 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
@@ -0,0 +1,8 @@
1
+ """``python -m tackboard`` — run the ``tack`` command-line interface."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from .cli import main
6
+
7
+ if __name__ == "__main__":
8
+ raise SystemExit(main())
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
+ ]