taskproof 0.3.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.
- taskproof/__init__.py +23 -0
- taskproof/__main__.py +8 -0
- taskproof/adapters/__init__.py +101 -0
- taskproof/adapters/base.py +228 -0
- taskproof/adapters/claude.py +46 -0
- taskproof/adapters/codex.py +140 -0
- taskproof/adapters/custom.py +61 -0
- taskproof/adapters/gemini.py +45 -0
- taskproof/adapters/opencode.py +46 -0
- taskproof/api/__init__.py +1 -0
- taskproof/api/server.py +822 -0
- taskproof/cli.py +1400 -0
- taskproof/concurrency.py +329 -0
- taskproof/dispatch.py +2289 -0
- taskproof/errors.py +55 -0
- taskproof/ledger.py +185 -0
- taskproof/models.py +155 -0
- taskproof/registry.py +1585 -0
- taskproof/schemas/result.json +58 -0
- taskproof/sql/schema.sql +70 -0
- taskproof/storage.py +372 -0
- taskproof/verify.py +615 -0
- taskproof/web/assets/MatrixView-BM8nu9s2.css +446 -0
- taskproof/web/assets/MatrixView-BM8nu9s2.css.gz +0 -0
- taskproof/web/assets/MatrixView-BMsawnxh.js +475 -0
- taskproof/web/assets/MatrixView-BMsawnxh.js.gz +0 -0
- taskproof/web/assets/ProjectsView-BcXcx5Gw.js +302 -0
- taskproof/web/assets/ProjectsView-BcXcx5Gw.js.gz +0 -0
- taskproof/web/assets/ProjectsView-CqNFL-fi.css +23 -0
- taskproof/web/assets/SettingsView-D_-waf0s.js +585 -0
- taskproof/web/assets/SettingsView-D_-waf0s.js.gz +0 -0
- taskproof/web/assets/SettingsView-aqTS3ubB.css +19 -0
- taskproof/web/assets/TaskDrawer-6JoGTH0s.js +597 -0
- taskproof/web/assets/TaskDrawer-6JoGTH0s.js.gz +0 -0
- taskproof/web/assets/TaskDrawer-N299VKzd.css +465 -0
- taskproof/web/assets/TaskDrawer-N299VKzd.css.gz +0 -0
- taskproof/web/assets/TasksView-BWhSHCHi.js +114 -0
- taskproof/web/assets/TasksView-BWhSHCHi.js.gz +0 -0
- taskproof/web/assets/TasksView-Bn08731V.css +30 -0
- taskproof/web/assets/icon-Dw-ohsdc.png +0 -0
- taskproof/web/assets/index-B-PLdDbR.js +75421 -0
- taskproof/web/assets/index-B-PLdDbR.js.gz +0 -0
- taskproof/web/assets/index-NKj7kbYl.css +1234 -0
- taskproof/web/assets/index-NKj7kbYl.css.gz +0 -0
- taskproof/web/index.html +23 -0
- taskproof-0.3.0.dist-info/METADATA +470 -0
- taskproof-0.3.0.dist-info/RECORD +51 -0
- taskproof-0.3.0.dist-info/WHEEL +5 -0
- taskproof-0.3.0.dist-info/entry_points.txt +2 -0
- taskproof-0.3.0.dist-info/licenses/LICENSE +201 -0
- taskproof-0.3.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "http://json-schema.org/draft-07/schema#",
|
|
3
|
+
"title": "taskproof Codex task result",
|
|
4
|
+
"description": "Passed to `codex exec --output-schema <this file> -o <result file>` to constrain the agent's final answer to a machine-readable shape. IMPORTANT: DeepSeek structured output runs in strict mode — every key under an object's `properties` MUST also be listed in that object's `required` array (a missing key fails with HTTP 400 'Required properties must match all properties in the object'). Every level of this schema therefore lists all of its fields as required and has no optional fields; when a value does not apply, use an empty string or an empty array.",
|
|
5
|
+
"type": "object",
|
|
6
|
+
"additionalProperties": false,
|
|
7
|
+
"required": ["status", "summary", "files_changed", "verify", "blockers", "needs_approval", "contract_dispute"],
|
|
8
|
+
"properties": {
|
|
9
|
+
"status": {
|
|
10
|
+
"type": "string",
|
|
11
|
+
"enum": ["done", "partial", "blocked", "rejected"],
|
|
12
|
+
"description": "done=completed as specified; partial=partially done (explain in summary); blocked=stopped by an external factor; rejected=the task boundary itself is wrong (do not change code in that case)"
|
|
13
|
+
},
|
|
14
|
+
"summary": {
|
|
15
|
+
"type": "string",
|
|
16
|
+
"description": "One to three sentences on what was done and the outcome. Do not replay the process log."
|
|
17
|
+
},
|
|
18
|
+
"files_changed": {
|
|
19
|
+
"type": "array",
|
|
20
|
+
"description": "Files actually added/changed/deleted; empty array when no file changed",
|
|
21
|
+
"items": {
|
|
22
|
+
"type": "object",
|
|
23
|
+
"additionalProperties": false,
|
|
24
|
+
"required": ["path", "change", "why"],
|
|
25
|
+
"properties": {
|
|
26
|
+
"path": { "type": "string", "description": "Path relative to the repository root" },
|
|
27
|
+
"change": { "type": "string", "enum": ["add", "modify", "delete"] },
|
|
28
|
+
"why": { "type": "string", "description": "Why this file was touched (one sentence)" }
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
},
|
|
32
|
+
"verify": {
|
|
33
|
+
"type": "object",
|
|
34
|
+
"description": "The verification commands actually run and their results; if none were run, leave command as an empty string, set exit_code to -1, and explain why under blockers",
|
|
35
|
+
"additionalProperties": false,
|
|
36
|
+
"required": ["command", "exit_code", "output_excerpt"],
|
|
37
|
+
"properties": {
|
|
38
|
+
"command": { "type": "string" },
|
|
39
|
+
"exit_code": { "type": "integer" },
|
|
40
|
+
"output_excerpt": { "type": "string", "description": "Key output summary (error lines or the tail); do not paste the whole log" }
|
|
41
|
+
}
|
|
42
|
+
},
|
|
43
|
+
"blockers": {
|
|
44
|
+
"type": "array",
|
|
45
|
+
"description": "Anything that prevented completion: missing dependency, missing permission, insufficient information, needed human confirmation, ...; empty array when there is none",
|
|
46
|
+
"items": { "type": "string" }
|
|
47
|
+
},
|
|
48
|
+
"needs_approval": {
|
|
49
|
+
"type": "array",
|
|
50
|
+
"description": "Actions that need the user's go-ahead (changing config, touching git, installing dependencies, connecting to a database, touching production/internal systems, ...). List them but do not perform them; empty array when there is none",
|
|
51
|
+
"items": { "type": "string" }
|
|
52
|
+
},
|
|
53
|
+
"contract_dispute": {
|
|
54
|
+
"type": "string",
|
|
55
|
+
"description": "If the task boundary is wrong (the repository or the files that should change are not the ones named in the contract), write that here and do not change any code; empty string when there is no dispute"
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
taskproof/sql/schema.sql
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
-- taskproof schema, v1.
|
|
2
|
+
--
|
|
3
|
+
-- Configuration lives in a YAML registry (human editable).
|
|
4
|
+
-- Only RUNTIME STATE lives here.
|
|
5
|
+
--
|
|
6
|
+
-- The audit stream (events.jsonl) is written alongside these tables but is
|
|
7
|
+
-- never read for logic — queries go to this database.
|
|
8
|
+
|
|
9
|
+
PRAGMA journal_mode = WAL;
|
|
10
|
+
PRAGMA foreign_keys = ON;
|
|
11
|
+
|
|
12
|
+
CREATE TABLE IF NOT EXISTS schema_version (
|
|
13
|
+
version INTEGER PRIMARY KEY,
|
|
14
|
+
applied_at TEXT NOT NULL
|
|
15
|
+
);
|
|
16
|
+
|
|
17
|
+
CREATE TABLE IF NOT EXISTS tasks (
|
|
18
|
+
id TEXT PRIMARY KEY,
|
|
19
|
+
project TEXT NOT NULL,
|
|
20
|
+
group_name TEXT NOT NULL DEFAULT 'default',
|
|
21
|
+
brief TEXT NOT NULL,
|
|
22
|
+
status TEXT NOT NULL,
|
|
23
|
+
adapter TEXT NOT NULL DEFAULT 'codex',
|
|
24
|
+
model TEXT,
|
|
25
|
+
reasoning TEXT,
|
|
26
|
+
attempt INTEGER NOT NULL DEFAULT 1,
|
|
27
|
+
exit_code INTEGER,
|
|
28
|
+
pid INTEGER,
|
|
29
|
+
pgid INTEGER,
|
|
30
|
+
queue_seq INTEGER,
|
|
31
|
+
workdir TEXT,
|
|
32
|
+
result_path TEXT,
|
|
33
|
+
verify_cmd TEXT,
|
|
34
|
+
verify_exit INTEGER,
|
|
35
|
+
files_changed INTEGER,
|
|
36
|
+
created_at TEXT NOT NULL,
|
|
37
|
+
started_at TEXT,
|
|
38
|
+
finished_at TEXT
|
|
39
|
+
);
|
|
40
|
+
|
|
41
|
+
CREATE INDEX IF NOT EXISTS idx_tasks_status ON tasks(status);
|
|
42
|
+
CREATE INDEX IF NOT EXISTS idx_tasks_project ON tasks(project, created_at DESC);
|
|
43
|
+
CREATE INDEX IF NOT EXISTS idx_tasks_created ON tasks(created_at DESC);
|
|
44
|
+
|
|
45
|
+
-- Audit mirror of events.jsonl. Append-only.
|
|
46
|
+
CREATE TABLE IF NOT EXISTS events (
|
|
47
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
48
|
+
task_id TEXT,
|
|
49
|
+
ts TEXT NOT NULL,
|
|
50
|
+
event TEXT NOT NULL,
|
|
51
|
+
payload TEXT
|
|
52
|
+
);
|
|
53
|
+
|
|
54
|
+
CREATE INDEX IF NOT EXISTS idx_events_task ON events(task_id, id);
|
|
55
|
+
CREATE INDEX IF NOT EXISTS idx_events_ts ON events(ts DESC);
|
|
56
|
+
|
|
57
|
+
-- Concurrency guard. Replaces lock files: a crashed process is reclaimed by
|
|
58
|
+
-- expiry rather than leaving a stale lock behind.
|
|
59
|
+
CREATE TABLE IF NOT EXISTS claims (
|
|
60
|
+
scope TEXT PRIMARY KEY,
|
|
61
|
+
task_id TEXT NOT NULL,
|
|
62
|
+
pid INTEGER,
|
|
63
|
+
claimed_at TEXT NOT NULL,
|
|
64
|
+
expires_at TEXT NOT NULL
|
|
65
|
+
);
|
|
66
|
+
|
|
67
|
+
CREATE TABLE IF NOT EXISTS meta (
|
|
68
|
+
key TEXT PRIMARY KEY,
|
|
69
|
+
value TEXT
|
|
70
|
+
);
|
taskproof/storage.py
ADDED
|
@@ -0,0 +1,372 @@
|
|
|
1
|
+
"""SQLite state store.
|
|
2
|
+
|
|
3
|
+
Contract
|
|
4
|
+
--------
|
|
5
|
+
* `connect()` returns a connection with `Row` factory, WAL, foreign keys on.
|
|
6
|
+
* All timestamps are ISO-8601 strings with a local UTC offset (see `now_iso`).
|
|
7
|
+
* Concurrency correctness lives in `concurrency.py`; this module only owns the
|
|
8
|
+
schema, connections and simple CRUD.
|
|
9
|
+
* Migrations are append-only: add a new file under `sql/` and extend `migrate()`.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
import os
|
|
14
|
+
import sqlite3
|
|
15
|
+
import time
|
|
16
|
+
from datetime import datetime, timezone
|
|
17
|
+
from typing import Optional
|
|
18
|
+
|
|
19
|
+
from .errors import TaskproofError
|
|
20
|
+
|
|
21
|
+
SCHEMA_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "sql")
|
|
22
|
+
|
|
23
|
+
DEFAULT_DB_NAME = "taskproof.db"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def now_iso() -> str:
|
|
27
|
+
"""Current time, ISO-8601 with the local offset (matches the old ledger format)."""
|
|
28
|
+
return datetime.now(timezone.utc).astimezone().isoformat(timespec="seconds")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def db_path(workspace: str) -> str:
|
|
32
|
+
return os.path.join(workspace, DEFAULT_DB_NAME)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def connect(path: str) -> sqlite3.Connection:
|
|
36
|
+
os.makedirs(os.path.dirname(os.path.abspath(path)), exist_ok=True)
|
|
37
|
+
conn = sqlite3.connect(path, timeout=30, isolation_level=None)
|
|
38
|
+
conn.row_factory = sqlite3.Row
|
|
39
|
+
conn.execute("PRAGMA journal_mode = WAL")
|
|
40
|
+
conn.execute("PRAGMA foreign_keys = ON")
|
|
41
|
+
conn.execute("PRAGMA busy_timeout = 30000")
|
|
42
|
+
return conn
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
#: Schema level this build writes. Bump it whenever `migrate()` learns a new
|
|
46
|
+
#: step that `schema.sql` alone cannot perform on an existing database.
|
|
47
|
+
SCHEMA_VERSION = 2
|
|
48
|
+
|
|
49
|
+
#: Columns added after v1. `schema.sql` is all ``CREATE TABLE IF NOT EXISTS``,
|
|
50
|
+
#: so it can never add a column to a table that already exists — these are the
|
|
51
|
+
#: PRAGMA-probed ``ALTER TABLE`` steps that upgrade a pre-existing workspace.
|
|
52
|
+
#: (table, column, declared type); identifiers are module constants, never user
|
|
53
|
+
#: input, so the f-string below is safe.
|
|
54
|
+
_ADDED_COLUMNS = (
|
|
55
|
+
("tasks", "pgid", "INTEGER"),
|
|
56
|
+
("tasks", "queue_seq", "INTEGER"),
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _table_columns(conn: sqlite3.Connection, table: str) -> set:
|
|
61
|
+
"""Column names of `table` (empty set when the table does not exist)."""
|
|
62
|
+
return {row[1] for row in conn.execute(f"PRAGMA table_info({table})")}
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def migrate(conn: sqlite3.Connection) -> None:
|
|
66
|
+
"""Bring `conn`'s database up to the current schema (idempotent).
|
|
67
|
+
|
|
68
|
+
`schema.sql` builds a fresh database in full; the probes below are what
|
|
69
|
+
upgrade a database created before `pgid`/`queue_seq` existed. Both halves are
|
|
70
|
+
safe to run any number of times, and every step either applies cleanly or is
|
|
71
|
+
skipped because it is already present.
|
|
72
|
+
"""
|
|
73
|
+
with open(os.path.join(SCHEMA_DIR, "schema.sql"), encoding="utf-8") as fh:
|
|
74
|
+
conn.executescript(fh.read())
|
|
75
|
+
|
|
76
|
+
for table, column, column_type in _ADDED_COLUMNS:
|
|
77
|
+
if column not in _table_columns(conn, table):
|
|
78
|
+
conn.execute(f"ALTER TABLE {table} ADD COLUMN {column} {column_type}")
|
|
79
|
+
|
|
80
|
+
row = conn.execute("SELECT MAX(version) AS v FROM schema_version").fetchone()
|
|
81
|
+
current = (row[0] if row is not None else None) or 0
|
|
82
|
+
if current < 1:
|
|
83
|
+
conn.execute(
|
|
84
|
+
"INSERT INTO schema_version (version, applied_at) VALUES (?, ?)",
|
|
85
|
+
(1, now_iso()),
|
|
86
|
+
)
|
|
87
|
+
if current < SCHEMA_VERSION:
|
|
88
|
+
# PRIMARY KEY on `version` makes the recording itself idempotent.
|
|
89
|
+
conn.execute(
|
|
90
|
+
"INSERT OR IGNORE INTO schema_version (version, applied_at) VALUES (?, ?)",
|
|
91
|
+
(SCHEMA_VERSION, now_iso()),
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
# --------------------------------------------------------------------------
|
|
96
|
+
# Implementation cards fill the bodies below. Signatures and semantics are the
|
|
97
|
+
# contract; raise NotImplementedError until then.
|
|
98
|
+
# --------------------------------------------------------------------------
|
|
99
|
+
|
|
100
|
+
# Every column of the `tasks` table. This is the whitelist for the dynamically
|
|
101
|
+
# built SET-clause in `update_task` — caller-supplied keys are validated against
|
|
102
|
+
# it before they ever reach a SQL string.
|
|
103
|
+
TASK_COLUMNS = frozenset(
|
|
104
|
+
{
|
|
105
|
+
"id",
|
|
106
|
+
"project",
|
|
107
|
+
"group_name",
|
|
108
|
+
"brief",
|
|
109
|
+
"status",
|
|
110
|
+
"adapter",
|
|
111
|
+
"model",
|
|
112
|
+
"reasoning",
|
|
113
|
+
"attempt",
|
|
114
|
+
"exit_code",
|
|
115
|
+
"pid",
|
|
116
|
+
"pgid",
|
|
117
|
+
"workdir",
|
|
118
|
+
"result_path",
|
|
119
|
+
"verify_cmd",
|
|
120
|
+
"verify_exit",
|
|
121
|
+
"files_changed",
|
|
122
|
+
"created_at",
|
|
123
|
+
"started_at",
|
|
124
|
+
"finished_at",
|
|
125
|
+
}
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _row_dict(task) -> dict:
|
|
130
|
+
"""Normalise a Task dataclass / mapping / sqlite3.Row into a plain dict."""
|
|
131
|
+
if isinstance(task, dict):
|
|
132
|
+
return dict(task)
|
|
133
|
+
if isinstance(task, sqlite3.Row):
|
|
134
|
+
return dict(task)
|
|
135
|
+
if hasattr(task, "to_row"):
|
|
136
|
+
return dict(task.to_row())
|
|
137
|
+
return dict(vars(task))
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def insert_task(conn: sqlite3.Connection, task) -> None:
|
|
141
|
+
"""Insert a new task row (status running). Raises on duplicate id."""
|
|
142
|
+
row = _row_dict(task)
|
|
143
|
+
# The model calls the column `group`; the table calls it `group_name`.
|
|
144
|
+
if "group" in row and "group_name" not in row:
|
|
145
|
+
row["group_name"] = row.pop("group")
|
|
146
|
+
if not row.get("status"):
|
|
147
|
+
row["status"] = "running"
|
|
148
|
+
if not row.get("created_at"):
|
|
149
|
+
row["created_at"] = now_iso()
|
|
150
|
+
|
|
151
|
+
cols = [c for c in row if c in TASK_COLUMNS]
|
|
152
|
+
if not cols:
|
|
153
|
+
raise TaskproofError("insert_task: no insertable columns in row")
|
|
154
|
+
placeholders = ", ".join("?" for _ in cols)
|
|
155
|
+
conn.execute(
|
|
156
|
+
f"INSERT INTO tasks ({', '.join(cols)}) VALUES ({placeholders})",
|
|
157
|
+
[row[c] for c in cols],
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def update_task(conn: sqlite3.Connection, task_id: str, **fields) -> None:
|
|
162
|
+
"""Patch the given columns of one task. Unknown field names are a bug."""
|
|
163
|
+
if not fields:
|
|
164
|
+
return
|
|
165
|
+
unknown = sorted(set(fields) - TASK_COLUMNS)
|
|
166
|
+
if unknown:
|
|
167
|
+
# Defence in depth: validate every key against the whitelist BEFORE it is
|
|
168
|
+
# interpolated into the statement. `fields.values()` always travel as
|
|
169
|
+
# bound parameters; only whitelisted identifiers are concatenated.
|
|
170
|
+
raise TaskproofError(
|
|
171
|
+
f"unknown task column(s): {', '.join(unknown)}",
|
|
172
|
+
hint=f"allowed columns: {', '.join(sorted(TASK_COLUMNS))}",
|
|
173
|
+
)
|
|
174
|
+
assignments = ", ".join(f"{name} = ?" for name in fields)
|
|
175
|
+
params = list(fields.values())
|
|
176
|
+
params.append(task_id)
|
|
177
|
+
conn.execute(f"UPDATE tasks SET {assignments} WHERE id = ?", params)
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def get_task(conn: sqlite3.Connection, task_id: str) -> Optional[sqlite3.Row]:
|
|
181
|
+
return conn.execute("SELECT * FROM tasks WHERE id = ?", (task_id,)).fetchone()
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def delete_task(conn: sqlite3.Connection, task_id: str) -> None:
|
|
185
|
+
"""Delete one task row AND its `events` rows.
|
|
186
|
+
|
|
187
|
+
The JSONL audit stream (`events-<month>.jsonl`) is append-only history and
|
|
188
|
+
is deliberately NOT touched: it is never read for logic, so leaving it
|
|
189
|
+
behind keeps the audit trail intact while removing the queryable record.
|
|
190
|
+
"""
|
|
191
|
+
conn.execute("DELETE FROM events WHERE task_id = ?", (task_id,))
|
|
192
|
+
conn.execute("DELETE FROM tasks WHERE id = ?", (task_id,))
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def list_tasks(conn, *, status=None, project=None, limit=50):
|
|
196
|
+
"""Most recent first. `status` may be a single value or an iterable."""
|
|
197
|
+
clauses = []
|
|
198
|
+
params = []
|
|
199
|
+
if status is not None:
|
|
200
|
+
statuses = [status] if isinstance(status, str) else list(status)
|
|
201
|
+
if not statuses:
|
|
202
|
+
return []
|
|
203
|
+
placeholders = ", ".join("?" for _ in statuses)
|
|
204
|
+
clauses.append(f"status IN ({placeholders})")
|
|
205
|
+
params.extend(statuses)
|
|
206
|
+
if project is not None:
|
|
207
|
+
clauses.append("project = ?")
|
|
208
|
+
params.append(project)
|
|
209
|
+
where = f" WHERE {' AND '.join(clauses)}" if clauses else ""
|
|
210
|
+
params.append(limit)
|
|
211
|
+
return conn.execute(
|
|
212
|
+
f"SELECT * FROM tasks{where} ORDER BY created_at DESC LIMIT ?", params
|
|
213
|
+
).fetchall()
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
#: Statuses that count as "in progress" in the project overview. These are the
|
|
217
|
+
#: non-terminal states: work the user is waiting on.
|
|
218
|
+
IN_PROGRESS_STATUSES = ("running", "verifying")
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def project_overview(conn: sqlite3.Connection):
|
|
222
|
+
"""Per-project task counts — the whole overview in ONE SQL statement.
|
|
223
|
+
|
|
224
|
+
Returns a list of dicts with ``project``, ``total``, ``in_progress``,
|
|
225
|
+
``failed`` and ``last_activity`` (the newest ``created_at`` for the
|
|
226
|
+
project, or ``None`` when it has no tasks). Ordered by most recent activity
|
|
227
|
+
first; projects that never ran a task sort last.
|
|
228
|
+
"""
|
|
229
|
+
placeholders = ", ".join("?" for _ in IN_PROGRESS_STATUSES)
|
|
230
|
+
rows = conn.execute(
|
|
231
|
+
"SELECT project, "
|
|
232
|
+
"COUNT(*) AS total, "
|
|
233
|
+
f"SUM(CASE WHEN status IN ({placeholders}) THEN 1 ELSE 0 END) AS in_progress, "
|
|
234
|
+
"SUM(CASE WHEN status = ? THEN 1 ELSE 0 END) AS failed, "
|
|
235
|
+
"MAX(created_at) AS last_activity "
|
|
236
|
+
"FROM tasks GROUP BY project "
|
|
237
|
+
"ORDER BY last_activity IS NULL, last_activity DESC, project ASC",
|
|
238
|
+
(*IN_PROGRESS_STATUSES, "failed"),
|
|
239
|
+
).fetchall()
|
|
240
|
+
return [dict(row) for row in rows]
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def group_tasks_by_project(tasks):
|
|
244
|
+
"""Group task mappings by project: newest-active block first.
|
|
245
|
+
|
|
246
|
+
Shared by the CLI and the board so the two surfaces order groups
|
|
247
|
+
identically. Groups are ordered by the most recent ``created_at`` inside
|
|
248
|
+
each group (desc); tasks within a group are returned newest-first too.
|
|
249
|
+
"""
|
|
250
|
+
groups: dict = {}
|
|
251
|
+
for task in tasks:
|
|
252
|
+
groups.setdefault(task.get("project") or "", []).append(task)
|
|
253
|
+
ordered = sorted(
|
|
254
|
+
groups.items(),
|
|
255
|
+
key=lambda item: max((t.get("created_at") or "") for t in item[1]),
|
|
256
|
+
reverse=True,
|
|
257
|
+
)
|
|
258
|
+
return [
|
|
259
|
+
(pid, sorted(items, key=lambda t: t.get("created_at") or "", reverse=True))
|
|
260
|
+
for pid, items in ordered
|
|
261
|
+
]
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def next_task_id(conn: sqlite3.Connection, *, prefix_date: str) -> str:
|
|
265
|
+
"""Allocate `t-<yyyymmdd>-<nnn>`; must be safe under concurrent dispatch.
|
|
266
|
+
|
|
267
|
+
Concurrency is guaranteed by the `meta` table, not by a read-then-write
|
|
268
|
+
race: the counter row is read and bumped inside a single `BEGIN IMMEDIATE`
|
|
269
|
+
transaction, so two processes can never both observe the same value. The
|
|
270
|
+
`meta.key` PRIMARY KEY is the unique constraint that backs the sequence,
|
|
271
|
+
and the retry loop re-attempts on the (theoretical) conflict instead of
|
|
272
|
+
trusting a stale read. Existing `tasks.id` values for the day are also
|
|
273
|
+
folded in, so out-of-band inserts can never be handed out twice.
|
|
274
|
+
"""
|
|
275
|
+
prefix = f"t-{prefix_date}-"
|
|
276
|
+
key = f"task_seq:{prefix_date}"
|
|
277
|
+
while True:
|
|
278
|
+
try:
|
|
279
|
+
conn.execute("BEGIN IMMEDIATE")
|
|
280
|
+
except sqlite3.OperationalError:
|
|
281
|
+
# Locked; busy_timeout usually hides this, but retry if it slips.
|
|
282
|
+
time.sleep(0.01)
|
|
283
|
+
continue
|
|
284
|
+
try:
|
|
285
|
+
seq = 0
|
|
286
|
+
row = conn.execute("SELECT value FROM meta WHERE key = ?", (key,)).fetchone()
|
|
287
|
+
if row is not None and row[0] is not None:
|
|
288
|
+
try:
|
|
289
|
+
seq = int(row[0])
|
|
290
|
+
except (TypeError, ValueError):
|
|
291
|
+
seq = 0
|
|
292
|
+
for existing in conn.execute(
|
|
293
|
+
"SELECT id FROM tasks WHERE id LIKE ?", (prefix + "%",)
|
|
294
|
+
):
|
|
295
|
+
tail = existing[0][len(prefix):]
|
|
296
|
+
if tail.isdigit():
|
|
297
|
+
seq = max(seq, int(tail))
|
|
298
|
+
seq += 1
|
|
299
|
+
conn.execute(
|
|
300
|
+
"INSERT INTO meta (key, value) VALUES (?, ?) "
|
|
301
|
+
"ON CONFLICT(key) DO UPDATE SET value = excluded.value",
|
|
302
|
+
(key, str(seq)),
|
|
303
|
+
)
|
|
304
|
+
conn.execute("COMMIT")
|
|
305
|
+
return f"{prefix}{seq:03d}"
|
|
306
|
+
except sqlite3.IntegrityError:
|
|
307
|
+
# Lost the race for the meta row — roll back and re-read.
|
|
308
|
+
conn.execute("ROLLBACK")
|
|
309
|
+
time.sleep(0.01)
|
|
310
|
+
continue
|
|
311
|
+
except BaseException:
|
|
312
|
+
conn.execute("ROLLBACK")
|
|
313
|
+
raise
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def _audit_dir(conn: sqlite3.Connection) -> str:
|
|
317
|
+
"""The workspace directory that holds both the db file and the audit stream.
|
|
318
|
+
|
|
319
|
+
`connect()` puts the database at `<workspace>/taskproof.db`, and ledger.py
|
|
320
|
+
writes `<workspace>/events-<month>.jsonl`, so the audit dir is simply the
|
|
321
|
+
directory containing the database file.
|
|
322
|
+
"""
|
|
323
|
+
try:
|
|
324
|
+
rows = conn.execute("PRAGMA database_list").fetchall()
|
|
325
|
+
except sqlite3.Error:
|
|
326
|
+
return ""
|
|
327
|
+
for row in rows:
|
|
328
|
+
# columns: seq, name, file
|
|
329
|
+
if row[1] == "main":
|
|
330
|
+
return os.path.dirname(os.path.abspath(row[2])) if row[2] else ""
|
|
331
|
+
return ""
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def append_event(conn: sqlite3.Connection, task_id: Optional[str], event: str, payload=None) -> None:
|
|
335
|
+
"""Append to BOTH the `events` table and the JSONL audit stream (see ledger.py).
|
|
336
|
+
|
|
337
|
+
One timestamp and one record dict are built and used for both sinks, so the
|
|
338
|
+
two entries are byte-for-byte the same content. The database insert runs in
|
|
339
|
+
an explicit transaction that is committed only after the JSONL line is on
|
|
340
|
+
disk: if the audit write fails, the row is rolled back, so the query surface
|
|
341
|
+
never disagrees with the audit trail (the JSONL stream is append-only and is
|
|
342
|
+
never read for logic, so a stray line is harmless whereas a missing one is
|
|
343
|
+
not).
|
|
344
|
+
"""
|
|
345
|
+
from . import ledger # local import: ledger imports now_iso from this module
|
|
346
|
+
|
|
347
|
+
ts = now_iso()
|
|
348
|
+
payload_text = json.dumps(payload, ensure_ascii=False) if payload is not None else None
|
|
349
|
+
record = {"ts": ts, "task_id": task_id, "event": event, "payload": payload}
|
|
350
|
+
audit_dir = _audit_dir(conn)
|
|
351
|
+
|
|
352
|
+
already_in_txn = conn.in_transaction
|
|
353
|
+
if not already_in_txn:
|
|
354
|
+
conn.execute("BEGIN IMMEDIATE")
|
|
355
|
+
try:
|
|
356
|
+
conn.execute(
|
|
357
|
+
"INSERT INTO events (task_id, ts, event, payload) VALUES (?, ?, ?, ?)",
|
|
358
|
+
(task_id, ts, event, payload_text),
|
|
359
|
+
)
|
|
360
|
+
ledger.append(audit_dir, record)
|
|
361
|
+
except BaseException:
|
|
362
|
+
if not already_in_txn:
|
|
363
|
+
conn.execute("ROLLBACK")
|
|
364
|
+
raise
|
|
365
|
+
if not already_in_txn:
|
|
366
|
+
conn.execute("COMMIT")
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
def list_events(conn: sqlite3.Connection, task_id: str):
|
|
370
|
+
return conn.execute(
|
|
371
|
+
"SELECT * FROM events WHERE task_id = ? ORDER BY id ASC", (task_id,)
|
|
372
|
+
).fetchall()
|