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.
Files changed (51) hide show
  1. taskproof/__init__.py +23 -0
  2. taskproof/__main__.py +8 -0
  3. taskproof/adapters/__init__.py +101 -0
  4. taskproof/adapters/base.py +228 -0
  5. taskproof/adapters/claude.py +46 -0
  6. taskproof/adapters/codex.py +140 -0
  7. taskproof/adapters/custom.py +61 -0
  8. taskproof/adapters/gemini.py +45 -0
  9. taskproof/adapters/opencode.py +46 -0
  10. taskproof/api/__init__.py +1 -0
  11. taskproof/api/server.py +822 -0
  12. taskproof/cli.py +1400 -0
  13. taskproof/concurrency.py +329 -0
  14. taskproof/dispatch.py +2289 -0
  15. taskproof/errors.py +55 -0
  16. taskproof/ledger.py +185 -0
  17. taskproof/models.py +155 -0
  18. taskproof/registry.py +1585 -0
  19. taskproof/schemas/result.json +58 -0
  20. taskproof/sql/schema.sql +70 -0
  21. taskproof/storage.py +372 -0
  22. taskproof/verify.py +615 -0
  23. taskproof/web/assets/MatrixView-BM8nu9s2.css +446 -0
  24. taskproof/web/assets/MatrixView-BM8nu9s2.css.gz +0 -0
  25. taskproof/web/assets/MatrixView-BMsawnxh.js +475 -0
  26. taskproof/web/assets/MatrixView-BMsawnxh.js.gz +0 -0
  27. taskproof/web/assets/ProjectsView-BcXcx5Gw.js +302 -0
  28. taskproof/web/assets/ProjectsView-BcXcx5Gw.js.gz +0 -0
  29. taskproof/web/assets/ProjectsView-CqNFL-fi.css +23 -0
  30. taskproof/web/assets/SettingsView-D_-waf0s.js +585 -0
  31. taskproof/web/assets/SettingsView-D_-waf0s.js.gz +0 -0
  32. taskproof/web/assets/SettingsView-aqTS3ubB.css +19 -0
  33. taskproof/web/assets/TaskDrawer-6JoGTH0s.js +597 -0
  34. taskproof/web/assets/TaskDrawer-6JoGTH0s.js.gz +0 -0
  35. taskproof/web/assets/TaskDrawer-N299VKzd.css +465 -0
  36. taskproof/web/assets/TaskDrawer-N299VKzd.css.gz +0 -0
  37. taskproof/web/assets/TasksView-BWhSHCHi.js +114 -0
  38. taskproof/web/assets/TasksView-BWhSHCHi.js.gz +0 -0
  39. taskproof/web/assets/TasksView-Bn08731V.css +30 -0
  40. taskproof/web/assets/icon-Dw-ohsdc.png +0 -0
  41. taskproof/web/assets/index-B-PLdDbR.js +75421 -0
  42. taskproof/web/assets/index-B-PLdDbR.js.gz +0 -0
  43. taskproof/web/assets/index-NKj7kbYl.css +1234 -0
  44. taskproof/web/assets/index-NKj7kbYl.css.gz +0 -0
  45. taskproof/web/index.html +23 -0
  46. taskproof-0.3.0.dist-info/METADATA +470 -0
  47. taskproof-0.3.0.dist-info/RECORD +51 -0
  48. taskproof-0.3.0.dist-info/WHEEL +5 -0
  49. taskproof-0.3.0.dist-info/entry_points.txt +2 -0
  50. taskproof-0.3.0.dist-info/licenses/LICENSE +201 -0
  51. 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
+ }
@@ -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()