Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 57 additions & 7 deletions docs/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -156,8 +156,10 @@ For streaming, use the WebSocket endpoint.

### Tasks

#### `GET /api/tasks?status=pending`
List tasks with optional status filter. Valid statuses: `pending`, `in_progress`, `done`, `deferred`, or empty (all non-done).
#### `GET /api/tasks?status=pending&tag=backend&sort=position`
List tasks. All filters optional. Statuses are configurable — see
`GET /api/task-statuses`; empty means all non-done. `sort` accepts `deadline`
(default), `updated_at`, `created_at`, or `position` (board order).

#### `GET /api/tasks/search?q=keyword&status=`
Full-text search on task titles and content (FTS5). Optional status filter.
Expand All @@ -166,11 +168,37 @@ Full-text search on task titles and content (FTS5). Optional status filter.
Response: { "tasks": [{ "id": "2026-03-01-fix-bug", "title": "Fix bug", "status": "pending", ... }] }
```

#### `GET /api/tasks/board?limit=100&tag=`
Every lane in one round trip — the board's only read. Returns the configured
statuses plus one page of each, ordered by `position`. `total` is the lane's
true count so a column can offer "+N more"; the `done` lane is capped tighter
than the rest since it grows without bound.

```json
Response: {
"statuses": [{ "name": "pending", "label": "Pending", "color": "#...", ... }],
"lanes": [{ "status": "pending", "total": 12, "tasks": [ ... ] }]
}
```

#### `GET /api/tasks/tags?include_done=false`
Distinct tags with their task counts, most-used first (filter-bar facets).

```json
Response: { "tags": [{ "name": "backend", "count": 7 }] }
```

#### `POST /api/tasks`
Create a task.
Create a task. Returns the created row; **409** if the duplicate guard refuses
(retry with `confirm_duplicate: true` to override), **422** if `status` names a
status that does not exist — a retry cannot fix that one, so it is kept
distinct from the collision.

```json
Request: { "title": "Fix bug", "content": "Details...", "deadline": "2026-03-01" }
Request: { "title": "Fix bug", "content": "Details...", "deadline": "2026-03-01", "tags": "backend,urgent" }
Response: { "task": { "id": "2026-03-01-fix-bug", ... }, "message": "Task created: ..." }
409: { "detail": { "reason": "duplicate", "duplicates": [ ... ], "message": "..." } }
422: { "detail": { "reason": "invalid_status", "duplicates": [], "message": "..." } }
```

#### `GET /api/tasks/{id}`
Expand All @@ -181,11 +209,33 @@ Response: { "id": "2026-03-01-fix-bug", "title": "Fix bug", "status": "pending",
```

#### `PATCH /api/tasks/{id}`
Update a task. All fields are optional. `content` replaces the full markdown file; title and deadline are re-synced to SQLite.
Update a task. All fields are optional. `content` replaces the full markdown
file; title and deadline are re-synced to SQLite. Returns the full updated row.

`deadline` and `tags` read by **presence**: omit the key to leave the field
alone, or send `""` to clear it. An invalid status is a 400 (previously
reported as a success that changed nothing).

```json
Request: { "status": "done", "note": "Fixed in PR #123" }
Request: { "content": "# Updated Title\n\n**Deadline:** 2026-03-15\n\nNew details..." }
Request: { "deadline": "" }
Response: { "task": { ... }, "task_id": "2026-03-01-fix-bug", "updated": true }
```

#### `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
neighbours itself, so a stale client board can't corrupt the ordering.
`before_id` is the card that ends up directly above, `after_id` the one
directly below; omit both to append. Omit `status` to reorder in place.

Moving into or out of `done` moves the markdown file between `active/` and
`done/` as a side effect.

```json
Request: { "status": "done", "note": "Fixed in PR #123" }
Request: { "content": "# Updated Title\n\n**Deadline:** 2026-03-15\n\nNew details..." }
Request: { "status": "in_progress", "before_id": "2026-03-01-a", "after_id": "2026-03-01-b" }
Response: { "task": { "id": "...", "status": "in_progress", "position": 3072.0, ... } }
```

### Workflow Runs
Expand Down
47 changes: 47 additions & 0 deletions docs/tasks.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,53 @@ Setting a task's status to `done` via `task_update` automatically delegates to `

This prevents orphan tasks (status=done in DB but file still in active/).

Leaving `done` delegates the same way, to the inverse path: `task_update`
routes a task whose stored status is `done` through the reopen handler, which
moves the file back to `active/` and appends a `REOPENED` line. Both
directions check the tracked-config guard *before* the status flip, so a
refused move never leaves a task whose status and file disagree.

The inverse matters because the disagreement is silent rather than loud.
`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 status change that appeared to work would quietly undo itself the
next time anything reindexed. A *missing* file is worse: `reindex()` only
walks files that exist, so it never sees that row to repair it. Hence the
reopen refuses outright when the file is already gone.

### Ordering (`position`)

Board lanes are hand-ordered, so the order is stored rather than derived:
`tasks.position` is a sparse REAL rank, ascending (lower sorts higher in the
lane), added in migration v043 and backfilled per status.

A move sends *intent* — "put this card between A and B" — and the server takes
the midpoint of the two neighbours' ranks. One drag is one UPDATE, with no
renumbering of the lane. If repeated midpoint inserts ever exhaust float
precision between two neighbours, that lane is re-spaced at even intervals and
the move retried.

Two rules keep ranks meaningful:

- **Preserved on omit.** `upsert_task(position=None)` keeps the stored rank.
Nothing in the markdown file encodes a rank, so callers that rebuild a row
from disk (`reindex`, `task_write`, the PATCH route) *cannot* supply one —
under replace semantics, appending a note would silently reset the card's
place in its lane.
- **Re-ranked across lanes.** A rank only means something relative to its own
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.

### Live Updates

Every task mutation broadcasts a `task_updated` WebSocket event on the
`__global__` channel carrying the whole row (`event` is one of `created`,
`updated`, `moved`, `done`). It fires from both the HTTP 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. Broadcast
failures are logged and swallowed — a stale card is never worth failing a
write over.

### FTS Index

Tasks are indexed in an FTS5 virtual table (`tasks_fts`) for fast full-text search. The index is synced on every `upsert_task()` call. On startup, an integrity check compares task count vs FTS count — if they diverge, the index is automatically reseeded from the database.
Expand Down
36 changes: 36 additions & 0 deletions nerve/agent/streaming.py
Original file line number Diff line number Diff line change
Expand Up @@ -317,6 +317,26 @@ async def broadcast_workflow_progress(
"workflow": workflow,
})

async def broadcast_task_event(self, task: dict[str, Any], event: str) -> None:
"""A task row changed — ``created``/``updated``/``moved``/``done``.

Fanned out on ``__global__`` rather than a session channel because
the task board is a global view: a card has to move whether the
mutation came from the HTTP API, another browser tab, or the agent
working in some unrelated session. ``session_id`` is None for the
same reason — the frontend drops view-scoped events addressed to a
session the client isn't looking at, and this one is for everyone.

The whole row travels with the event so a client can upsert it
without a follow-up fetch (same shape as ``workflow_run_update``).
"""
await self.broadcast("__global__", {
"type": "task_updated",
"session_id": None,
"event": event,
"task": task,
})

async def broadcast_error(self, session_id: str, error: str) -> None:
await self.broadcast(session_id, {"type": "error", "session_id": session_id, "error": error})

Expand All @@ -338,3 +358,19 @@ async def broadcast_file_changed(

# Global broadcaster instance
broadcaster = StreamBroadcaster()


async def emit_task_event(task: dict[str, Any] | None, event: str) -> None:
"""Best-effort task broadcast — never let the UI break a mutation.

Every task write site calls this, including ones that run with no
websocket listeners at all (CLI, cron, tests). A broadcast failure is
a cosmetic problem — a stale card until the next refresh — so it is
logged at debug and swallowed rather than propagated into the write.
"""
if not task:
return
try:
await broadcaster.broadcast_task_event(task, event)
except Exception: # noqa: BLE001
logger.debug("task event broadcast failed", exc_info=True)
Loading
Loading