From 654b200b8a211257e906f5bea6f65a452ec46eae Mon Sep 17 00:00:00 2001 From: Alex Soffronow Pagonidis <237136924+alex-clickhouse@users.noreply.github.com> Date: Wed, 5 Aug 2026 09:10:43 +0000 Subject: [PATCH 1/2] Record task status transitions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nerve kept no history of status changes. tasks holds only the current value, memu_audit_log is memU-only, and the markdown ## Updates list only gains a line when a note is passed or on done/reopen — so a plain status flip left no trace at all, at day granularity and with no actor. Tolerable while status changes were rare and deliberate. The board makes them a drag, so they're frequent, and /move routes through exactly the note-less path: every drag was an untracked mutation. A board is also what prompts the questions this answers — how long has this been in progress, which cards are aging, how often does work bounce back out. v044 adds task_events (task_id, from_status, to_status, actor, created_at), seeded with one origin row per existing task. from_status is NULL for a creation, so aging can tell "created here" from "moved here". Recording happens in the same transaction as the status write, from all three paths that can change one: update_task_status, move_task, and the full-row upsert_task — which is how task_update flips status when it also writes a note, and would otherwise have been a silent gap. A no-op transition records nothing, and that one rule is what keeps the table meaningful. reindex() rewrites every row on startup through that same upsert, so without it the history would double on each run; likewise a pure reorder within a lane, which is the most frequent board interaction there is. The case where reindex *does* change a status — resetting an orphaned row to match its directory — is a real correction and gets a row. actor is the session id for agent-driven changes and "web" for the HTTP API, so a person dragging a card is distinguishable from the agent moving it. The task routes now build their tool context with that name rather than the generic "system" sentinel. On the UI: an aging badge on board cards after three days in a status, and a history panel in the task detail view showing each transition with its dwell time. Tasks whose last change predates this table report no entry time rather than a guessed one — the indicator stays silent instead of showing an age that isn't true. Co-Authored-By: Claude Opus 5 --- docs/api.md | 13 +- docs/tasks.md | 25 +++ nerve/agent/tools/handlers/tasks.py | 8 +- nerve/db/migrations/v044_task_events.py | 69 +++++++ nerve/db/tasks.py | 96 ++++++++- nerve/gateway/routes/tasks.py | 27 ++- tests/test_task_board_api.py | 46 +++++ tests/test_task_events.py | 194 ++++++++++++++++++ web/src/api/client.ts | 15 ++ web/src/components/Tasks/Board/BoardCard.tsx | 32 ++- .../components/Tasks/Board/BoardColumn.tsx | 10 +- web/src/components/Tasks/Board/TaskBoard.tsx | 2 + web/src/components/Tasks/TaskDetailBody.tsx | 19 +- web/src/components/Tasks/TaskTimeline.tsx | 91 ++++++++ web/src/stores/taskStore.test.ts | 9 +- web/src/stores/taskStore.ts | 7 +- 16 files changed, 641 insertions(+), 22 deletions(-) create mode 100644 nerve/db/migrations/v044_task_events.py create mode 100644 tests/test_task_events.py create mode 100644 web/src/components/Tasks/TaskTimeline.tsx diff --git a/docs/api.md b/docs/api.md index de319688..8ed77bea 100644 --- a/docs/api.md +++ b/docs/api.md @@ -177,7 +177,8 @@ than the rest since it grows without bound. ```json Response: { "statuses": [{ "name": "pending", "label": "Pending", "color": "#...", ... }], - "lanes": [{ "status": "pending", "total": 12, "tasks": [ ... ] }] + "lanes": [{ "status": "pending", "total": 12, "tasks": [ ... ] }], + "status_since": { "2026-03-01-fix-bug": "2026-03-02T10:00:00Z" } } ``` @@ -223,6 +224,16 @@ Request: { "deadline": "" } Response: { "task": { ... }, "task_id": "2026-03-01-fix-bug", "updated": true } ``` +#### `GET /api/tasks/{id}/events` +A task's status history, oldest first. + +```json +Response: { "events": [ + { "id": 1, "task_id": "...", "from_status": null, "to_status": "pending", "actor": "system", "created_at": "..." }, + { "id": 2, "task_id": "...", "from_status": "pending", "to_status": "in_progress", "actor": "impl-abc123", "created_at": "..." } +] } +``` + #### `POST /api/tasks/{id}/move` Place a task in a lane — the board's drag-and-drop write. Send *intent* ("between these two cards"), not a computed rank: the server resolves the diff --git a/docs/tasks.md b/docs/tasks.md index 2ab541a8..02041d44 100644 --- a/docs/tasks.md +++ b/docs/tasks.md @@ -114,6 +114,31 @@ Two rules keep ranks meaningful: lane, so a status change places the card at the top of its destination instead of carrying over a number it was never ordered against. +### Status History + +Every status transition is appended to `task_events` (`task_id`, +`from_status`, `to_status`, `actor`, `created_at`), added in migration v044 +and seeded with one origin row per existing task. `from_status` is NULL on +the row recording a task's creation, so an aging calculation can tell +"created here" from "moved here". + +Recording happens inside the same transaction as the status write, from all +three paths that can change one: `update_task_status`, `move_task`, and the +full-row `upsert_task` (which is how `task_update` flips status when it also +writes a note). A no-op transition records nothing — that single rule is +what keeps `reindex()` from doubling the table on every run, since it +rewrites every row with the status it already had. The one case where +reindex *does* change a status, resetting an orphaned row to match its +directory, is a real correction and gets a row. + +`actor` is the session id for agent-driven changes, `web` for the HTTP API, +`system` otherwise. + +This powers the aging indicator on board cards and the timeline in the task +detail view. Tasks whose last transition predates v044 report no entry time +rather than a guessed one, so the UI stays silent instead of showing an age +that isn't true. + ### Live Updates Every task mutation broadcasts a `task_updated` WebSocket event on the diff --git a/nerve/agent/tools/handlers/tasks.py b/nerve/agent/tools/handlers/tasks.py index 79be2391..6575781e 100644 --- a/nerve/agent/tools/handlers/tasks.py +++ b/nerve/agent/tools/handlers/tasks.py @@ -256,6 +256,7 @@ async def task_create_handler(ctx: ToolContext, args: dict) -> ToolResult: deadline=deadline or None, tags=tags_to_string(tags), content=content, + actor=ctx.session_id, ) _tasks_read.add(task_id) @@ -427,6 +428,7 @@ async def task_update_handler(ctx: ToolContext, args: dict) -> ToolResult: title=final_title, status=final_status, content=content, + actor=ctx.session_id, **edits, ) await _emit_task_event(ctx, task_id, "updated") @@ -434,7 +436,7 @@ async def task_update_handler(ctx: ToolContext, args: dict) -> ToolResult: # Fall back to metadata updates when no task file was changed. if status: - await ctx.db.update_task_status(task_id, status) + await ctx.db.update_task_status(task_id, status, actor=ctx.session_id) if raw_tags is not None: await ctx.db.update_task_tags(task_id, new_tags_str) await _emit_task_event(ctx, task_id, "updated") @@ -540,7 +542,7 @@ async def task_done_handler(ctx: ToolContext, args: dict) -> ToolResult: if ctx.workspace: ensure_path_not_tracked_config(ctx.workspace / task["file_path"], "move") - await ctx.db.update_task_status(task_id, "done") + await ctx.db.update_task_status(task_id, "done", actor=ctx.session_id) # Mark any implementing plans for this task as done implementing_plans = await ctx.db.get_plans_for_task(task_id) @@ -635,7 +637,7 @@ async def task_reopen_handler(ctx: ToolContext, args: dict) -> ToolResult: is_error=True, ) - await ctx.db.update_task_status(task_id, status) + await ctx.db.update_task_status(task_id, status, actor=ctx.session_id) if src is not None: # Re-checked because the guard above is not atomic with the move. diff --git a/nerve/db/migrations/v044_task_events.py b/nerve/db/migrations/v044_task_events.py new file mode 100644 index 00000000..fa0ac814 --- /dev/null +++ b/nerve/db/migrations/v044_task_events.py @@ -0,0 +1,69 @@ +"""V44: status-transition history for tasks. + +``tasks`` stores only the current status, so every transition overwrote +the last one and nothing recorded that it happened. The markdown file's +``## Updates`` section was the closest thing to a history, and it only +gains a line when a note is passed or on done/reopen — a plain status +flip left no trace at all, at day granularity and with no actor. + +That was tolerable while status changes were rare and deliberate. The +task board makes them a drag, so they're frequent, and it raises the +questions this table answers: how long has this been in progress, which +cards are aging, how often does work bounce back out of review. + +Rows are append-only. ``from_status`` is NULL for the row recording a +task's creation, so a task's first event is its birth rather than an +implicit gap before the first transition. + +Timestamps are TEXT written Python-side with fixed-width UTC ISO strings, +matching every other table here — lexicographic order == chronological. +""" + +from __future__ import annotations + +import logging + +import aiosqlite + +logger = logging.getLogger(__name__) + +SQL = """ +CREATE TABLE IF NOT EXISTS task_events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + task_id TEXT NOT NULL, + from_status TEXT, + to_status TEXT NOT NULL, + actor TEXT NOT NULL DEFAULT 'system', + created_at TEXT NOT NULL +); + +-- The only read pattern: one task's history, oldest first. +CREATE INDEX IF NOT EXISTS idx_task_events_task + ON task_events(task_id, created_at); + +-- Aging queries sweep by time across all tasks. +CREATE INDEX IF NOT EXISTS idx_task_events_created + ON task_events(created_at); +""" + + +async def up(db: aiosqlite.Connection) -> None: + await db.executescript(SQL) + + # Seed one event per existing task so history has a floor rather than + # starting mid-air. created_at (not updated_at) is the honest stamp: + # we know when the task appeared, not when it reached its current + # status. from_status NULL marks these as synthesized origins, so an + # aging calculation can tell "created here" from "moved here". + await db.execute( + """ + INSERT INTO task_events (task_id, from_status, to_status, actor, created_at) + SELECT id, NULL, status, 'backfill', COALESCE(created_at, updated_at) + FROM tasks + WHERE COALESCE(created_at, updated_at) IS NOT NULL + """ + ) + + async with db.execute("SELECT COUNT(*) FROM task_events") as cursor: + seeded = (await cursor.fetchone())[0] + logger.info("v044: task_events created, seeded %d origin row(s)", seeded) diff --git a/nerve/db/tasks.py b/nerve/db/tasks.py index 70deb02f..89665408 100644 --- a/nerve/db/tasks.py +++ b/nerve/db/tasks.py @@ -60,6 +60,7 @@ async def upsert_task( tags: str | _Keep = KEEP, content: str = "", position: float | None = None, + actor: str = "system", ) -> None: """Insert or update a task row and its FTS entry. @@ -84,14 +85,18 @@ async def upsert_task( """ now = datetime.now(timezone.utc).isoformat() async with self._atomic(): - # One read resolves every preserve-on-omit column. The statement - # below stays a plain full replace, and the FTS text sees the - # tags that actually land in the row. + # One read serves every column this method resolves before the + # write. The statement below stays a plain full replace, and the + # FTS text sees the tags that actually land in the row. stored = await self._stored_task_columns(task_id) source = _resolve_kept(source, stored, "source", None) source_url = _resolve_kept(source_url, stored, "source_url", None) deadline = _resolve_kept(deadline, stored, "deadline", None) tags = _resolve_kept(tags, stored, "tags", "") + # A full-row upsert is one of the paths that can change status + # (task_update with a note, the PATCH route saving content), so + # it has to record transitions like the dedicated methods do. + previous_status = stored["status"] if stored else None if position is None: position = await self._resolve_upsert_position(stored, status) await self.db.execute( @@ -109,6 +114,8 @@ async def upsert_task( # The FTS5 tokenizer already splits the hyphenated slug into words # ("2026-03-10-distribution" → 2026, 03, 10, distribution), so slug # search works without rewriting the key — and the key stays joinable. + await self._record_status_event(task_id, previous_status, status, actor) + fts_content = f"{content} {tags.replace(',', ' ')}" if tags else content await self.db.execute("DELETE FROM tasks_fts WHERE task_id = ?", (task_id,)) await self.db.execute( @@ -121,6 +128,79 @@ async def get_task(self, task_id: str) -> dict | None: row = await cursor.fetchone() return dict(row) if row else None + # ── Status history (task_events) ───────────────────────────────────── + + async def _record_status_event( + self, + task_id: str, + from_status: str | None, + to_status: str, + actor: str, + ) -> None: + """Append a transition to ``task_events``, if it is one. + + Called from inside ``_atomic()`` by every path that can change a + task's status, so the event and the status commit together — there + is no window in which a task has moved but its history hasn't. + + A no-op transition writes nothing, and that one rule is what keeps + ``TaskManager.reindex()`` from flooding the table: reindex rewrites + every row with the status it already had, so from == to and nothing + is recorded. The single case where reindex *does* change a status — + resetting an orphaned row to match its directory — is a real + correction and worth a row. + """ + if from_status == to_status: + return + await self.db.execute( + "INSERT INTO task_events (task_id, from_status, to_status, actor, created_at) " + "VALUES (?, ?, ?, ?, ?)", + ( + task_id, from_status, to_status, actor or "system", + datetime.now(timezone.utc).isoformat(), + ), + ) + + async def list_task_events(self, task_id: str, limit: int = 200) -> list[dict]: + """A task's transitions, oldest first — the order a timeline reads.""" + async with self.db.execute( + "SELECT * FROM task_events WHERE task_id = ? " + "ORDER BY created_at ASC, id ASC LIMIT ?", + (task_id, limit), + ) as cursor: + return [dict(row) async for row in cursor] + + async def get_status_entry_times(self, task_ids: list[str]) -> dict[str, str]: + """When each task last *entered* its current status. + + The basis for card aging. Takes the newest event per task and keeps + it only if it still matches the live status: a row whose status was + last changed by a path predating this table, or by a direct DB edit, + has no truthful entry time. Omitting it leaves the indicator silent, + which beats inventing an age that reads as fact. + """ + if not task_ids: + return {} + placeholders = ",".join("?" * len(task_ids)) + async with self.db.execute( + f"SELECT task_id, to_status, MAX(created_at) FROM task_events " + f"WHERE task_id IN ({placeholders}) GROUP BY task_id", + tuple(task_ids), + ) as cursor: + newest = {row[0]: (row[1], row[2]) async for row in cursor} + + async with self.db.execute( + f"SELECT id, status FROM tasks WHERE id IN ({placeholders})", + tuple(task_ids), + ) as cursor: + live = {row[0]: row[1] async for row in cursor} + + return { + task_id: entered_at + for task_id, (to_status, entered_at) in newest.items() + if live.get(task_id) == to_status + } + # ── Board ordering (tasks.position) ────────────────────────────────── # # Lanes sort by ``position ASC``, so a *lower* rank is *higher* in the @@ -239,6 +319,7 @@ async def move_task( status: str | None = None, before_id: str | None = None, after_id: str | None = None, + actor: str = "system", ) -> dict | None: """Place a task at an explicit slot in a lane; return the new row. @@ -276,6 +357,10 @@ async def move_task( "UPDATE tasks SET status = ?, position = ?, updated_at = ? WHERE id = ?", (lane, position, now, task_id), ) + # A pure reorder leaves status alone, and _record_status_event + # drops the no-op — so dragging within a lane doesn't pollute + # the history with rows that say nothing changed. + await self._record_status_event(task_id, row[0], lane, actor) async with self.db.execute( "SELECT * FROM tasks WHERE id = ?", (task_id,), ) as cursor: @@ -368,7 +453,9 @@ async def count_tasks( row = await cursor.fetchone() return row[0] if row else 0 - async def update_task_status(self, task_id: str, status: str) -> None: + async def update_task_status( + self, task_id: str, status: str, actor: str = "system", + ) -> None: """Move a task to another status, re-ranking it into the new lane. A rank only means anything relative to its own lane, so carrying @@ -396,6 +483,7 @@ async def update_task_status(self, task_id: str, status: str) -> None: "UPDATE tasks SET status = ?, position = ?, updated_at = ? WHERE id = ?", (status, position, now, task_id), ) + await self._record_status_event(task_id, row[0], status, actor) async def update_task_tags(self, task_id: str, tags: str) -> None: now = datetime.now(timezone.utc).isoformat() diff --git a/nerve/gateway/routes/tasks.py b/nerve/gateway/routes/tasks.py index 75fe79be..f246dac5 100644 --- a/nerve/gateway/routes/tasks.py +++ b/nerve/gateway/routes/tasks.py @@ -186,7 +186,14 @@ async def task_board( ) lanes.append({"status": name, "total": total, "tasks": tasks}) - return {"statuses": statuses, "lanes": lanes} + # When each visible card last entered its current status — one query + # for the whole board rather than one per card. Tasks whose last + # transition predates task_events are simply absent, so the UI shows + # no age rather than a wrong one. + visible = [t["id"] for lane in lanes for t in lane["tasks"]] + status_since = await deps.db.get_status_entry_times(visible) + + return {"statuses": statuses, "lanes": lanes, "status_since": status_since} @router.get("/api/tasks/tags") @@ -266,7 +273,10 @@ async def move_task( if target != task["status"]: result = await get_tool_registry().invoke( "task_update", - build_route_tool_context(), + # "web" rather than the default "system" sentinel: this string + # lands in task_events.actor, and the point of that column is + # telling a person dragging a card from the agent moving it. + build_route_tool_context("web"), {"task_id": task_id, "status": target}, ) if result.is_error: @@ -274,6 +284,7 @@ async def move_task( moved = await deps.db.move_task( task_id, status=target, before_id=req.before_id, after_id=req.after_id, + actor="web", ) if not moved: raise HTTPException(status_code=404, detail="Task not found") @@ -282,6 +293,16 @@ async def move_task( return {"task": moved} +@router.get("/api/tasks/{task_id}/events") +async def list_task_events( + task_id: str, limit: int = 200, user: dict = Depends(require_auth), +): + """A task's status history, oldest first.""" + deps = get_deps() + limit = max(1, min(limit, 500)) + return {"events": await deps.db.list_task_events(task_id, limit=limit)} + + @router.get("/api/tasks/{task_id}") async def get_task(task_id: str, user: dict = Depends(require_auth)): deps = get_deps() @@ -365,7 +386,7 @@ async def update_task(task_id: str, req: TaskUpdateRequest, user: dict = Depends if len(payload) > 1: result = await get_tool_registry().invoke( - "task_update", build_route_tool_context(), payload, + "task_update", build_route_tool_context("web"), payload, ) # Previously swallowed: an unknown status returned {"updated": true} # while nothing had changed. Surface the handler's own message. diff --git a/tests/test_task_board_api.py b/tests/test_task_board_api.py index 95978021..99f8161c 100644 --- a/tests/test_task_board_api.py +++ b/tests/test_task_board_api.py @@ -286,6 +286,52 @@ async def test_move_on_a_missing_task_is_404(self, setup): resp = setup.client.post("/api/tasks/nope/move", json={"status": "pending"}) assert resp.status_code == 404 + # ── History ────────────────────────────────────────────────────────── + + async def test_events_endpoint_returns_the_transition_history(self, setup): + task_id = await self._create(setup, "Tracked task") + setup.client.patch(f"/api/tasks/{task_id}", json={"status": "in_progress"}) + + resp = setup.client.get(f"/api/tasks/{task_id}/events") + + assert resp.status_code == 200 + events = resp.json()["events"] + assert [(e["from_status"], e["to_status"]) for e in events] == [ + (None, "pending"), ("pending", "in_progress"), + ] + + async def test_events_route_is_not_shadowed_by_the_id_route(self, setup): + task_id = await self._create(setup, "Shadow check") + body = setup.client.get(f"/api/tasks/{task_id}/events").json() + assert "events" in body, "resolved to the task-detail route instead" + + async def test_move_via_http_is_recorded_with_the_web_actor(self, setup): + task_id = await self._create(setup, "Dragged task") + + setup.client.post(f"/api/tasks/{task_id}/move", json={"status": "in_progress"}) + + events = setup.client.get(f"/api/tasks/{task_id}/events").json()["events"] + assert events[-1]["to_status"] == "in_progress" + # Distinguishes a human dragging a card from the agent moving it. + assert events[-1]["actor"] == "web" + + async def test_reorder_within_a_lane_records_no_event(self, setup): + first = await self._create(setup, "Reorder one") + second = await self._create(setup, "Reorder two") + + setup.client.post(f"/api/tasks/{second}/move", json={"before_id": first}) + + events = setup.client.get(f"/api/tasks/{second}/events").json()["events"] + assert len(events) == 1, "a pure reorder is not a status change" + + async def test_board_reports_when_cards_entered_their_status(self, setup): + task_id = await self._create(setup, "Aging card") + + body = setup.client.get("/api/tasks/board").json() + + # Drives the card aging indicator; absent means "unknown", not zero. + assert task_id in body["status_since"] + # ── Create ─────────────────────────────────────────────────────────── async def test_create_returns_the_structured_task(self, setup): diff --git a/tests/test_task_events.py b/tests/test_task_events.py new file mode 100644 index 00000000..8cd24b33 --- /dev/null +++ b/tests/test_task_events.py @@ -0,0 +1,194 @@ +"""Status-transition history (``task_events``). + +Before v044 a task's status was overwritten in place and nothing recorded +that it had changed. The markdown ``## Updates`` list only gains a line +when a note is passed or on done/reopen, so a plain status flip left no +trace at all. + +Two properties matter and pull against each other: every *real* +transition must be recorded exactly once, and routine rewrites must +record nothing. The second is the fragile one — ``TaskManager.reindex()`` +rewrites every row on startup and through a full-row upsert, which is one +of the paths that can change status. Nothing but the no-op check stops it +from doubling the table every time it runs. +""" + +from __future__ import annotations + +import pytest + +from nerve.db import Database +from nerve.tasks.manager import TaskManager + + +async def _add(db: Database, task_id: str, status: str = "pending", **row) -> None: + row.setdefault("title", f"Task {task_id}") + row.setdefault("file_path", f"memory/tasks/active/{task_id}.md") + await db.upsert_task(task_id=task_id, status=status, **row) + + +async def _transitions(db: Database, task_id: str) -> list[tuple]: + events = await db.list_task_events(task_id) + return [(e["from_status"], e["to_status"]) for e in events] + + +@pytest.mark.asyncio +class TestRecording: + async def test_creation_records_an_origin_event(self, db: Database): + await _add(db, "t1") + # from_status NULL distinguishes "created here" from "moved here", + # which is what lets an aging calculation tell the two apart. + assert await _transitions(db, "t1") == [(None, "pending")] + + async def test_status_change_is_recorded(self, db: Database): + await _add(db, "t1") + await db.update_task_status("t1", "in_progress") + + assert await _transitions(db, "t1") == [ + (None, "pending"), ("pending", "in_progress"), + ] + + async def test_move_across_lanes_is_recorded(self, db: Database): + await _add(db, "t1") + await db.move_task("t1", status="done") + + assert await _transitions(db, "t1") == [ + (None, "pending"), ("pending", "done"), + ] + + async def test_upsert_that_changes_status_is_recorded(self, db: Database): + """A full-row upsert is a status-change path too. + + ``task_update`` with a note and the PATCH route saving content both + flip status this way, never touching ``update_task_status``. + """ + await _add(db, "t1") + await _add(db, "t1", status="deferred") + + assert await _transitions(db, "t1") == [ + (None, "pending"), ("pending", "deferred"), + ] + + async def test_actor_is_recorded(self, db: Database): + await _add(db, "t1") + await db.update_task_status("t1", "in_progress", actor="sess-abc") + + events = await db.list_task_events("t1") + assert events[-1]["actor"] == "sess-abc" + + async def test_actor_defaults_rather_than_nulls(self, db: Database): + await _add(db, "t1") + assert (await db.list_task_events("t1"))[0]["actor"] == "system" + + async def test_history_is_ordered_oldest_first(self, db: Database): + await _add(db, "t1") + for status in ("in_progress", "deferred", "in_progress", "done"): + await db.update_task_status("t1", status) + + assert await _transitions(db, "t1") == [ + (None, "pending"), + ("pending", "in_progress"), + ("in_progress", "deferred"), + ("deferred", "in_progress"), + ("in_progress", "done"), + ] + + +@pytest.mark.asyncio +class TestNoOpsAreNotRecorded: + """The half that keeps the table meaningful rather than merely large.""" + + async def test_same_status_update_records_nothing(self, db: Database): + await _add(db, "t1") + await db.update_task_status("t1", "pending") + + assert len(await db.list_task_events("t1")) == 1 + + async def test_reorder_within_a_lane_records_nothing(self, db: Database): + await _add(db, "a") + await _add(db, "b") + await db.move_task("b", before_id="a") + + # Dragging within a lane is the most frequent board interaction; + # recording it would bury the actual transitions. + assert len(await db.list_task_events("b")) == 1 + + async def test_upsert_with_unchanged_status_records_nothing(self, db: Database): + await _add(db, "t1") + await _add(db, "t1", title="edited") + + assert len(await db.list_task_events("t1")) == 1 + + async def test_reindex_does_not_duplicate_history(self, db: Database, tmp_path): + """The flooding case: reindex rewrites every row, repeatedly.""" + directory = tmp_path / "memory" / "tasks" / "active" + directory.mkdir(parents=True) + (tmp_path / "memory" / "tasks" / "done").mkdir(parents=True) + (directory / "t1.md").write_text("# t1\n", encoding="utf-8") + await _add(db, "t1", status="in_progress") + + before = len(await db.list_task_events("t1")) + manager = TaskManager(tmp_path, db) + await manager.reindex() + await manager.reindex() + + assert len(await db.list_task_events("t1")) == before + + async def test_reindex_records_an_orphan_correction(self, db: Database, tmp_path): + """...but a reindex that *does* change status has changed something.""" + active = tmp_path / "memory" / "tasks" / "active" + done = tmp_path / "memory" / "tasks" / "done" + active.mkdir(parents=True) + done.mkdir(parents=True) + # File under done/ but the row claims otherwise — the orphan state + # reindex exists to correct. + (done / "t1.md").write_text("# t1\n", encoding="utf-8") + await _add( + db, "t1", status="in_progress", + file_path="memory/tasks/done/t1.md", + ) + + await TaskManager(tmp_path, db).reindex() + + assert await _transitions(db, "t1") == [ + (None, "in_progress"), ("in_progress", "done"), + ] + + +@pytest.mark.asyncio +class TestStatusEntryTimes: + async def test_reports_when_the_current_status_was_entered(self, db: Database): + await _add(db, "t1") + await db.update_task_status("t1", "in_progress") + + entered = await db.get_status_entry_times(["t1"]) + events = await db.list_task_events("t1") + assert entered["t1"] == events[-1]["created_at"] + + async def test_omits_tasks_with_no_history(self, db: Database): + # A row whose status last changed by some path predating this table + # has no truthful entry time. Silence beats a fabricated age. + await _add(db, "t1") + await db._write("DELETE FROM task_events WHERE task_id = 't1'") + + assert await db.get_status_entry_times(["t1"]) == {} + + async def test_omits_tasks_whose_history_disagrees_with_the_row( + self, db: Database, + ): + await _add(db, "t1") + # Status changed behind the table's back (direct DB edit). + await db._write("UPDATE tasks SET status = 'deferred' WHERE id = 't1'") + + assert await db.get_status_entry_times(["t1"]) == {} + + async def test_handles_an_empty_request(self, db: Database): + assert await db.get_status_entry_times([]) == {} + + async def test_covers_many_tasks_in_one_call(self, db: Database): + for tid in ("a", "b", "c"): + await _add(db, tid) + await db.update_task_status("b", "done") + + entered = await db.get_status_entry_times(["a", "b", "c"]) + assert set(entered) == {"a", "b", "c"} diff --git a/web/src/api/client.ts b/web/src/api/client.ts index c7b4f504..2cc2ff91 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -10,6 +10,16 @@ export interface TaskStatusDef { created_at?: string; } +export interface TaskEvent { + id: number; + task_id: string; + /** null on the row recording the task's creation. */ + from_status: string | null; + to_status: string; + actor: string; + created_at: string; +} + export interface Task { id: string; title: string; @@ -376,6 +386,9 @@ export const api = { return request<{ statuses: TaskStatusDef[]; lanes: { status: string; total: number; tasks: Task[] }[]; + /** task_id → ISO time it entered its current status. Absent for + tasks whose last transition predates the history table. */ + status_since: Record; }>(`/tasks/board${q ? '?' + q : ''}`); }, listTaskTags: (includeDone = false) => @@ -392,6 +405,8 @@ export const api = { method: 'POST', body: JSON.stringify(data), }), + listTaskEvents: (id: string) => + request<{ events: TaskEvent[] }>(`/tasks/${id}/events`), getTask: (id: string) => request(`/tasks/${id}`), createTask: (data: { title: string; content?: string; deadline?: string; diff --git a/web/src/components/Tasks/Board/BoardCard.tsx b/web/src/components/Tasks/Board/BoardCard.tsx index 1d76644f..96802fed 100644 --- a/web/src/components/Tasks/Board/BoardCard.tsx +++ b/web/src/components/Tasks/Board/BoardCard.tsx @@ -1,7 +1,7 @@ import { memo } from 'react'; import { useSortable } from '@dnd-kit/sortable'; import { CSS } from '@dnd-kit/utilities'; -import { Calendar, Clock, ExternalLink } from 'lucide-react'; +import { Calendar, Clock, ExternalLink, Hourglass } from 'lucide-react'; import type { Task } from '../../../api/client'; import { formatTimeAgo } from '../../../utils/dateGroups'; @@ -26,8 +26,27 @@ function parseTags(tags: string | null | undefined): string[] { return (tags || '').split(',').map((t) => t.trim()).filter(Boolean); } +/** + * Days a card has sat in its current status, once that's long enough to + * be worth saying. Thresholds are deliberately coarse — the signal is + * "this has stalled", not a precise duration, and a badge on every card + * would be noise rather than information. + */ +function laneAge(since: string | undefined): { days: number; tone: string } | null { + if (!since) return null; + const ms = Date.now() - new Date(since).getTime(); + if (Number.isNaN(ms)) return null; + const days = Math.floor(ms / 86_400_000); + if (days < 3) return null; + if (days >= 14) return { days, tone: 'text-hue-red' }; + if (days >= 7) return { days, tone: 'text-hue-orange' }; + return { days, tone: 'text-text-faint' }; +} + export interface BoardCardProps { task: Task; + /** ISO time the task entered its current status; absent = unknown. */ + statusSince?: string; onOpen: (task: Task) => void; } @@ -39,12 +58,13 @@ export interface BoardCardProps { * is a drag handle shouldn't also contain a control that swallows pointer * events. Changing status here is the drag itself. */ -function BoardCardInner({ task, onOpen }: BoardCardProps) { +function BoardCardInner({ task, statusSince, onOpen }: BoardCardProps) { const { attributes, listeners, setNodeRef, transform, transition, isDragging, } = useSortable({ id: task.id, data: { type: 'task', task } }); const tags = parseTags(task.tags); + const age = laneAge(statusSince); return (
{formatTimeAgo(task.updated_at)} )} + {age && ( + + {age.days}d + + )} {task.source && task.source !== 'manual' && ( {task.source} )} diff --git a/web/src/components/Tasks/Board/BoardColumn.tsx b/web/src/components/Tasks/Board/BoardColumn.tsx index c8e84a93..46a03cda 100644 --- a/web/src/components/Tasks/Board/BoardColumn.tsx +++ b/web/src/components/Tasks/Board/BoardColumn.tsx @@ -9,13 +9,14 @@ export interface BoardColumnProps { lane: Lane; status: TaskStatusDef | undefined; collapsed: boolean; + statusSince: Record; onToggleCollapse: (status: string) => void; onCreate: (status: string) => void; onOpenTask: (task: Task) => void; } export function BoardColumn({ - lane, status, collapsed, onToggleCollapse, onCreate, onOpenTask, + lane, status, collapsed, statusSince, onToggleCollapse, onCreate, onOpenTask, }: BoardColumnProps) { // A lane must accept a drop even with no cards in it, and an empty // SortableContext registers no droppable of its own — hence the explicit @@ -97,7 +98,12 @@ export function BoardColumn({ strategy={verticalListSortingStrategy} > {lane.tasks.map((task) => ( - + ))} diff --git a/web/src/components/Tasks/Board/TaskBoard.tsx b/web/src/components/Tasks/Board/TaskBoard.tsx index b5988e01..cd72b14f 100644 --- a/web/src/components/Tasks/Board/TaskBoard.tsx +++ b/web/src/components/Tasks/Board/TaskBoard.tsx @@ -43,6 +43,7 @@ export function TaskBoard({ onOpenTask }: { onOpenTask: (task: Task) => void }) const lanes = useTaskStore((s) => s.lanes); const boardLoading = useTaskStore((s) => s.boardLoading); const boardError = useTaskStore((s) => s.boardError); + const statusSince = useTaskStore((s) => s.statusSince); const moveTask = useTaskStore((s) => s.moveTask); const setShowCreateDialog = useTaskStore((s) => s.setShowCreateDialog); const searchQuery = useTaskStore((s) => s.searchQuery); @@ -142,6 +143,7 @@ export function TaskBoard({ onOpenTask }: { onOpenTask: (task: Task) => void }) lane={lane} status={statusByName.get(lane.status)} collapsed={collapsed.includes(lane.status)} + statusSince={statusSince} onToggleCollapse={handleToggleCollapse} onCreate={(status) => setShowCreateDialog(true, status)} onOpenTask={onOpenTask} diff --git a/web/src/components/Tasks/TaskDetailBody.tsx b/web/src/components/Tasks/TaskDetailBody.tsx index dee04e70..60c89194 100644 --- a/web/src/components/Tasks/TaskDetailBody.tsx +++ b/web/src/components/Tasks/TaskDetailBody.tsx @@ -1,9 +1,10 @@ import { useCallback, useEffect, useState } from 'react'; -import { Calendar, Edit3, ExternalLink, Eye, Save } from 'lucide-react'; +import { Calendar, Edit3, ExternalLink, Eye, History, Save } from 'lucide-react'; import type { Task } from '../../api/client'; import { useTaskStore } from '../../stores/taskStore'; import { StatusBadge, StatusSelect } from './StatusControls'; import { MarkdownContent } from '../Chat/MarkdownContent'; +import { TaskTimeline } from './TaskTimeline'; /** * The task editor itself — metadata row, edit/preview toggle, markdown @@ -19,6 +20,7 @@ export function TaskDetailBody({ task }: { task: Task }) { const updateStatus = useTaskStore((s) => s.updateStatus); const [mode, setMode] = useState<'edit' | 'preview'>('preview'); + const [showHistory, setShowHistory] = useState(false); const [localContent, setLocalContent] = useState(''); const [dirty, setDirty] = useState(false); @@ -76,6 +78,15 @@ export function TaskDetailBody({ task }: { task: Task }) { )}
+ {dirty && (
+ {showHistory && ( +
+ +
+ )} + {mode === 'edit' ? (