Skip to content

[1/7] Task board API: ordering, reopen path, and live updates - #272

Open
alex-clickhouse wants to merge 2 commits into
mainfrom
alex-clickhouse/task-board-api
Open

[1/7] Task board API: ordering, reopen path, and live updates#272
alex-clickhouse wants to merge 2 commits into
mainfrom
alex-clickhouse/task-board-api

Conversation

@alex-clickhouse

Copy link
Copy Markdown
Collaborator

Backend for a Kanban-style /tasks view. No UI change ships here — the board lands separately against this API. Reviewable and mergeable on its own.

What

Ordering. Board lanes are hand-ordered, so order becomes stored state rather than something derived from deadline/created_at. Migration v043 adds a sparse REAL tasks.position, backfilled per status in the order the list view already used — without the backfill every row sits at 0 and lane order is arbitrary until each card is dragged once.

A move sends intent ({before_id, after_id}) and the server takes the midpoint. One drag is one UPDATE, and a client holding a stale board can't write a conflicting absolute rank. If midpoint inserts ever exhaust float precision between two neighbours, the lane re-spaces itself and the move retries.

Reopen. task_done was a one-way door — it writes the markdown into done/ and unlinks the source. Dragging a card out of Done needs the inverse, and its absence fails quietly: reindex() treats a file under done/ as terminal by definition, so a row pointing there with an active status is an orphan it force-resets back to done. A move that looked like it worked would undo itself later. task_update now routes both directions.

Live updates. Task mutations broadcast task_updated on __global__ carrying the whole row, fired from both the routes and the tool handlers — so a card moves on any open board whether the change came from the web UI, another tab, or the agent working in an unrelated session. Mirrors workflow_run_update.

Routes

Route
GET /api/tasks/board every lane in one round trip, with true per-lane totals
POST /api/tasks/{id}/move reorder / change lane; returns the full row
GET /api/tasks/tags tag facets with counts
GET /api/tasks gains tag, and sort=position
PATCH /api/tasks/{id} returns the full row; accepts tags
POST /api/tasks returns the created task

Two behaviour changes worth your attention

  1. POST /api/tasks response shape changed — from a tool text blob to {task, message}, and the duplicate refusal is now a 409 instead of a 200 that no client could distinguish from success. Any external caller parsing the old blob breaks. The agent tool surface is unchanged.
  2. deadline and tags now read by presence on task_update — omitting the key leaves the field alone; sending "" clears it. Previously clearing was unexpressible, so the UI could add a tag but never remove the last one. Every in-tree caller omits both keys, so nothing else changes. Tool schema descriptions updated to match.

Also fixed in passing: PATCH used to swallow handler errors and return {"updated": true} after an invalid status changed nothing. It now surfaces a 400.

The one sharp edge

position is preserve-on-omit in upsert_task, against that method's usual full-row-replace semantics. Nothing in a task's markdown encodes a rank, so every caller that rebuilds a row from disk (reindex, task_write, the PATCH route) omits it — under replace semantics, appending one note would silently reset the card's place in its lane. This is the trap v018's tags column fell into, which is why test_task_position.py drives the real callers rather than calling upsert_task directly: a fix that only patched the signature default would still fail them.

Ranks are re-minted on a lane change, since a rank means nothing relative to a lane it was never ordered against.

Testing

  • pytest tests/ -q3073 passed (59 new)
  • tests/test_task_position.py — rank math, renormalization, cross-lane moves, and preservation through every production caller
  • tests/test_task_reopen.py — file round-trip, guard ordering, and the reindex-survives assertion that is the actual point
  • tests/test_task_board_api.py — the first HTTP-level tests for /api/tasks*; that surface was previously covered only through the handler layer

Migration verified against a nerve backup-ed copy of a live DB before running anywhere real.

Follow-up, not fixed here

_make_task_id has no uniqueness check — same title, same day, same id. Pre-existing, but a board makes it visible as colliding React keys. Filing separately rather than widening this PR.

🤖 Generated with Claude Code

Backend for a Kanban-style /tasks view. No UI change ships here — the
frontend lands separately against this API.

Ordering. Lanes are hand-ordered, so order becomes stored state rather
than something derived from deadline/created_at: v043 adds a sparse REAL
`tasks.position`, backfilled per status in the order the list view already
used. A move sends intent ("between A and B") and the server takes the
midpoint, so one drag is one UPDATE and a stale client board cannot write
a conflicting absolute rank. Lanes re-space themselves if midpoint inserts
ever exhaust float precision.

`position` is preserve-on-omit in `upsert_task`, which is the load-bearing
detail: nothing in a task's markdown encodes a rank, so every caller that
rebuilds a row from disk (reindex, task_write, the PATCH route) omits it.
Under this table's usual replace semantics, appending one note would have
silently reset the card's place in its lane — the trap v018's `tags`
column fell into, and why test_task_position.py drives the real callers
rather than upsert_task directly. Ranks are re-minted across a lane change,
since a rank means nothing relative to a lane it was never ordered against.

Reopen. `task_done` was a one-way door: it writes the file into done/ and
unlinks the source. Dragging a card out of Done needs the inverse, and its
absence fails quietly rather than loudly — reindex() treats a file under
done/ as terminal, so a row pointing there with an active status is an
orphan it force-resets back to done, undoing the move later. task_update
now routes both directions, each checking the tracked-config guard before
the status flip so a refusal can't desync status from file.

Live updates. Task mutations broadcast `task_updated` on __global__ with
the whole row, from both the routes and the tool handlers — so a card
moves on any open board whether the agent, another tab, or the API made
the change. Failures are logged and swallowed; a stale card is not worth
failing a write over.

Routes: GET /board (all lanes, one round trip), POST /{id}/move, GET
/tags (facets); list gains tag+position, PATCH returns the full row and
surfaces handler errors instead of reporting success, POST returns the
created task and 409s on the duplicate refusal.

Two behaviour changes worth a reviewer's attention:
  - POST /api/tasks response shape changed from a tool text blob to
    {task, message}, and the duplicate refusal is now 409 rather than a
    200 no client could distinguish from success. The agent tool surface
    is unchanged.
  - `deadline` and `tags` now read by presence on task_update: omitting
    the key leaves the field alone, sending "" clears it. Previously
    clearing was unexpressible. Every in-tree caller omits both keys.

Adds the first HTTP-level tests for /api/tasks*.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds the backend API surface and persistence needed for a Kanban-style task board, including stable per-lane ordering, support for moving tasks out of the terminal done state, and WebSocket broadcasts so multiple clients stay in sync.

Changes:

  • Introduces tasks.position (migration v043) plus server-side “intent-based” reordering (before_id/after_id) with renormalization.
  • Adds new task board and tag-facet routes (GET /api/tasks/board, POST /api/tasks/{id}/move, GET /api/tasks/tags) and extends listing with tag + sort=position.
  • Implements reopen behavior (done → active) and emits task_updated events from both HTTP routes and tool handlers; updates tests and docs accordingly.

Reviewed changes

Copilot reviewed 12 out of 12 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
tests/test_task_reopen.py New regression + ordering tests for done→active reopen behavior.
tests/test_task_position.py New rank math + “preserve-on-omit” coverage via real write paths.
tests/test_task_board_api.py First HTTP-level tests for /api/tasks*, including board envelope + move semantics.
nerve/gateway/routes/tasks.py Adds board/tags/move routes; extends list/patch/create behavior and responses.
nerve/db/tasks.py Adds position to task storage, move/re-rank logic, tag counts/facets helpers.
nerve/db/migrations/v043_task_position.py Migration adding tasks.position with per-lane backfill + index.
nerve/agent/tools/schemas.py Updates tool schema docs for presence-based deadline/tags.
nerve/agent/tools/registry.py Expands ToolResult to support structured outcomes for in-process callers.
nerve/agent/tools/handlers/tasks.py Adds reopen path + live update emission; presence-based deadline/tags.
nerve/agent/streaming.py Adds global task_updated broadcasting with best-effort emission helper.
docs/tasks.md Documents reopen behavior, ordering rules, and live update events.
docs/api.md Documents new task routes, response shapes, and presence semantics.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread nerve/gateway/routes/tasks.py
Comment thread nerve/agent/tools/handlers/tasks.py
Comment thread nerve/agent/tools/handlers/tasks.py Outdated
@alex-clickhouse alex-clickhouse changed the title [1/6] Task board API: ordering, reopen path, and live updates [1/7] Task board API: ordering, reopen path, and live updates Aug 5, 2026
Two review findings on the board API.

POST /api/tasks answered 409 for every refusal, but the handler refuses two
different ways: a duplicate collision and a status that does not exist. Only
the first is a conflict a client can resolve by retrying with
confirm_duplicate, so answering both the same way tells a caller to offer
"create anyway" for a status no retry can conjure. An unknown status now gets
the 422 that /move already returns for the identical mistake.

task_reopen checked the tracked-config guard before flipping the status but
never checked that the file it was about to move still existed. With the file
gone the flip still landed, stranding a row with an active status whose
file_path points into done/. Every other route to that shape is repaired by
reindex(); this one is not, because reindex() only walks files that exist. So
it is the one case that must refuse rather than proceed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@alex-clickhouse
alex-clickhouse marked this pull request as ready for review August 5, 2026 12:25
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants