[1/7] Task board API: ordering, reopen path, and live updates - #272
Open
alex-clickhouse wants to merge 2 commits into
Open
[1/7] Task board API: ordering, reopen path, and live updates#272alex-clickhouse wants to merge 2 commits into
alex-clickhouse wants to merge 2 commits into
Conversation
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>
This was referenced Aug 5, 2026
There was a problem hiding this comment.
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 withtag+sort=position. - Implements reopen behavior (done → active) and emits
task_updatedevents 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.
3 tasks
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
marked this pull request as ready for review
August 5, 2026 12:25
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Backend for a Kanban-style
/tasksview. 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 REALtasks.position, backfilled per status in the order the list view already used — without the backfill every row sits at0and 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_donewas a one-way door — it writes the markdown intodone/and unlinks the source. Dragging a card out of Done needs the inverse, and its absence fails quietly:reindex()treats a file underdone/as terminal by definition, so a row pointing there with an active status is an orphan it force-resets back todone. A move that looked like it worked would undo itself later.task_updatenow routes both directions.Live updates. Task mutations broadcast
task_updatedon__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. Mirrorsworkflow_run_update.Routes
GET /api/tasks/boardPOST /api/tasks/{id}/moveGET /api/tasks/tagsGET /api/taskstag, andsort=positionPATCH /api/tasks/{id}tagsPOST /api/tasksTwo behaviour changes worth your attention
POST /api/tasksresponse 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.deadlineandtagsnow read by presence ontask_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:
PATCHused to swallow handler errors and return{"updated": true}after an invalid status changed nothing. It now surfaces a 400.The one sharp edge
positionis preserve-on-omit inupsert_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'stagscolumn fell into, which is whytest_task_position.pydrives the real callers rather than callingupsert_taskdirectly: 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/ -q— 3073 passed (59 new)tests/test_task_position.py— rank math, renormalization, cross-lane moves, and preservation through every production callertests/test_task_reopen.py— file round-trip, guard ordering, and the reindex-survives assertion that is the actual pointtests/test_task_board_api.py— the first HTTP-level tests for/api/tasks*; that surface was previously covered only through the handler layerMigration verified against a
nerve backup-ed copy of a live DB before running anywhere real.Follow-up, not fixed here
_make_task_idhas 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