feat(service,features,database): keep each member's read state on Inbox items

Merged
DA
2d ago
+14837-55

Summary

This is the service and wire half of the Inbox list-row redesign (docs/proposals/inbox-threads.md §3–§4). Every Inbox item now carries the reader's unread flag. A member can mark one item read or unread, or mark every item read.

  • Storage. New table inbox_item_read: one marker per member per item, holding read_through (the item's updated_at the member has read up to) and marked_unread. There is a unique index on (item_id, user_id).
  • The rule (unreadFor in packages/service/src/inbox/inbox-store.ts). An item is unread for a member when they marked it unread, or when its updated_at is later than the later of their read_through and the item's own read_through.
    • Anything that moves updated_at makes the item unread again for everyone who had read it. That covers a post or repost under its key, a close, another member's answer or message, the agent's reply_inbox, and an answer being withdrawn.
    • A member's own answer, thread message, or withdrawn answer writes their marker in the same transaction. The item stays read for them and turns unread for teammates.
    • Marking an item unread sticks until that member reads it again.
  • No dots at launch (cutoff, no backfill). The migration adds inbox_item.read_through timestamptz DEFAULT now() and then drops the default in the same transaction.
    • now() is stable, so Postgres works it out once and keeps it in the catalog as the value for every existing row. No row is rewritten, and this is not a backfill.
    • Every item that exists at deploy reads as read for every member until it changes again. Items posted afterwards get NULL and start unread.
    • The SQL comments in the migration say the same. Because of this, the migration SQL was hand-edited after db:generate. The snapshot is unchanged (nullable column, no default), and drizzle-kit check passes.
  • Wire.
    • inboxItemSchema gains unread?: boolean. It is used by the plan inbox, the agent inbox, and the item returned by an answer or a message.
    • accountInboxEntrySchema and accountTasklessEntrySchema gain unread?: boolean.
    • The service always sends the field. It is optionalKey so that a desktop talking to an older service reads a missing value as "read", the same convention as startedBy.
  • Routes (one function, apps/web/server/routes/inbox/read.ts):
    • PUT /api/inbox/:id/read marks one item read. It answers { id, unread: false } and is idempotent: when the marker already stands, it writes nothing (a conflict setWhere skips the update). A read only ever moves a marker forward (greatest of the existing and new read_through), so a read that raced the member's own answer can't make that answer read as unread.
    • DELETE /api/inbox/:id/read marks one item unread. It answers { id, unread: true }.
    • PUT /api/inbox/read with body { workspaceId?: string } marks read every item the member's Inbox lists: items on tasks that aren't archived, and items of agents with no task. Settled items are included. It covers one workspace, or all of them when no workspace is given, and answers { marked: number }, the count that was unread. It clears the dots only; open items still wait.
    • Any member of the item's workspace may mark an item, and only their own marker changes. An item outside the account's workspaces is not-found.
    • The /api/inbox/read rewrite comes before /api/inbox/([^/]+). To allow that, SegmentRewrite.query is now optional.
  • Client. New HostedCodingAgentClient methods:
    • markInboxItemRead(itemId) and markInboxItemUnread(itemId) return a CodingAgentInboxReadStateView.
    • markInboxRead({ workspaceId? }) returns a CodingAgentInboxReadAllView.
  • Live reads.
    • The stamps a held read compares now carry the reader's unread, and cursorOf digests it when present. A member's own marker changing wakes their plan, agent, and account held reads, and leaves teammates' reads alone.
    • Rows without an unread state (chat, failed runs, plan comments) digest exactly as before, so their cursors don't change.
    • The Neon Realtime inbox follows (#3217, #3232) are not merged yet. Whichever lands second needs to treat a change to inbox_item_read for the reader as moving their inbox.

Evidence

  • Platform-independent checks: pnpm check:affected ran. The repository contract checks, check:migrations (drizzle-kit check), oxlint and biome, and knip all passed, along with the ui and service vitest suites. Some parts failed for sandbox reasons only, so I re-ran them on their own and they passed:
    • The @luke/service typecheck was OOM-killed (exit 137) and discarded-effect ran out of heap. Run one at a time, the service, web, host, and desktop typechecks all passed, and pnpm discarded-effect reported no discarded Effect in 2279 files.
    • desktop app-menu.test.ts fails because there is no Electron binary in the sandbox. It is unrelated to this change.
    • vitest --project web --project features --project database --project host passed: 174 files.
  • New tests, all on PGlite. Each fails without its behavior; I checked by mutating the store and the migration:
    • inbox-store.test.ts › "an inbox item's read state":
      • new items are unread, and so are items that changed after a read (reply, a teammate's answer, a repost, a close);
      • your own answer or message leaves the item read for you and unread for a teammate;
      • mark unread, then read again; a teammate's marker never moves yours; an outsider is refused;
      • mark all read, scoped to a workspace and across all of them, with settled items included and only unread items counted;
      • an item from before the release reads as read until it changes;
      • held reads (plan and account) wake when your own marker moves, and a teammate's cursor stays where it was.
    • apps/web/tests/inbox-item-read-migration.test.ts: this runs the real migration over an item that already exists. That item reads as read for both members, an item posted afterwards reads unread, no markers are written, and running the migration again changes nothing.
    • coding-agents-app.test.ts: covers the route (PUT/DELETE, mark all, 404 for an outsider, 405, 400).
    • coding-agent-client.test.ts: covers the three client methods, their paths and bodies, and their refusals.
  • Test count: +10 (7 store, 1 migration, 1 route, 1 client).

Invariants

  • ./scripts/check.sh green. (CI)
  • JSON Schema goldens changed, with LUKE_UPDATE_FIXTURES=1. Each change:
    • inbox-wire-inboxItemSchema, plus the schemas that embed it (inboxAnswerSchema, inboxAnsweredSchema, inboxMessagedSchema, codingAgentInboxAnswerViewSchema, codingAgentInboxAnsweredViewSchema, codingAgentInboxMessagedViewSchema): add the optional property unread: boolean.
    • account-inbox-wire-accountInboxEntrySchema and accountTasklessEntrySchema, plus the schemas that embed them (accountInboxAnswerSchema, codingAgentAccountInboxAnswerViewSchema, 6 lines each): add the optional property unread: boolean.
    • New goldens: inboxReadStateSchema ({ id, unread }), inboxReadAllRequestSchema ({ workspaceId? }), inboxReadAllSchema ({ marked }), codingAgentInboxReadStateViewSchema, and codingAgentInboxReadAllViewSchema (each answer, or a failure).
  • Every behavior change has, in this PR, the test that fails without it; no change-detector test added.
  • Gateway envelope goldens unchanged. (CI)
  • No new as type assertion outside the allowlisted files.
  • No Effect.runPromise/runSync/runFork outside the runtime edges.
  • No new setTimeout/setInterval/new Promise/AbortController/fs.watch.
  • No Effect, Stream, or Layer written as a statement and dropped.
  • Test count delta listed above (+10).
  • No hand-rolled file replaced.
  • No CLAUDE.md/AGENTS.md sentence names a changed part.
  • PRIVACY.md unchanged. A read marker is the member's own state on their own workspace's items.
  • Package deps match imports. The new @luke/database/schemas/inbox-item-read export was added.
  • Barrel/door rule: no new Node-reaching module behind a renderer barrel.

Notes

  • Rollout: a regular Drizzle migration. It creates one new empty table and adds one nullable column with a catalog-only default (no table rewrite). The foreign keys on the new table are checked against an empty table. I did not measure against the Dev database. The claim that this is catalog-only rests on Postgres's documented fast-default behavior for a non-volatile DEFAULT on ADD COLUMN.
  • Blockers or follow-up verification: the desktop rows ("Inbox list rows") read unread and call the three client methods. The host acts and IPC are left to that work.

Made with Luke


Devin Review


Summary by cubic

Keeps each member's own read state on Inbox items so the Inbox lists show what is unread for the reader. Every Inbox item now carries unread, and a member can mark one item read or unread, or mark every item their Inbox lists read.

Storage and rule

  • New inbox_item_read table holds one marker per member per item; an item is unread when marked unread or changed after the later of the member's and the item's read-through points.
  • A read never moves a marker backward: both read upserts keep the later of the standing and new read-through points and write only where the marker moves, so a member's own answer landing while their read waits on the marker's lock is not read as unread, and mark-all counts only what it moved.
  • A member's own answer, message, or withdrawn answer writes their marker in the same transaction, leaving the item read for them and unread for teammates.
  • Items that existed before this ships read as read until they change: the migration adds inbox_item.read_through with a catalog-only now() default and drops it in the same transaction, so no rows are rewritten and items posted afterwards start unread.

Wire and routes

  • inboxItemSchema and the account inbox entry schemas gain optional unread; a missing value reads as read, the same convention as startedBy.
  • New routes PUT/DELETE /api/inbox/:id/read (idempotent for an already-standing marker) and PUT /api/inbox/read (optionally scoped to one workspace) answer the item's read state and the count marked; the client gets three matching methods.
  • Held-read cursors digest the reader's unread state, so a marker change wakes only that member's held reads and leaves teammates' cursors alone.