feat(database,features,service): an Inbox item holds a thread of messages
Merged
DA
9d ago+16269+16269+01234567890123456789012345678901234567890123456789-24-24-01234567890123456789
Summary
The ask: Dean wants to be able to add a message to an Inbox item's thread at any point, not only the one answer an item takes today. This is the first of two PRs, the service half. The second PR adds the agent's read_inbox thread and a reply_inbox tool. A separate desktop worker builds the window on top of this one.
- Table:
inbox_messageholds the thread (packages/database/src/schemas/inbox-message.ts). Each row has:- the item, cascading on delete;
- the author,
memberwith theiruser_id(set null when the account goes) oragent, the item's own; - the words as Markdown, kept verbatim as an answer's note is, so mentions survive;
- an optional
client_key, unique per item; created_at.
- Files: a message's files reuse
inbox_attachmentwith a newmessagekind and amessage_idlink. They have the same bounds as an answer's file: the PDF bound, plus a name and a position. The kind check now pinsmessage_idto the message kind alone. - Wire (
inbox-wire.ts): an item read carriesmessages, oldest first, the latestMAX_MESSAGES_READ(50) of them. Each message is{ id, author: "member" | "agent", member?: { userId, name }, text, files?: [{ attachmentId, name, mediaType }], createdAt }. A file is fetched through the existing attachment path.messagesis absent where the thread is empty. - Route:
POST /api/inbox/{id}/messagestakes{ text, attachments?, clientKey }, with words or files or both, and answers{ item }.- Who may post: any member of the item's workspace, on any item they could answer, open, answered, or closed.
- What it changes: it writes the message and its files, and moves
updated_atand nothing else.status,answer, andrevisionstand. - How it reaches the agent: through the same steer as a tab message, under
INBOX_MESSAGE_LEAD("Inbox message on " - Failures and repeats: a hand-off that fails takes the message back off the thread, as a failed answer is taken back. A repeat under the same
clientKeyis written once and handed off again, and the steer's key makes it reach the agent once. An agent whose session has ended for good keeps the message.
settled_at:inbox_itemgainssettled_at, written when an item is answered or closed and cleared when a post or a withdrawn answer reopens it. It reaches the wire as an optionalsettledAt, so the desktop no longer readsupdatedAt, which a message now moves, as the time an item settled.- There is no backfill. A read takes
coalesce(settled_at, updated_at)for a settled item, and the first message on an old row copies itsupdated_atintosettled_atbefore moving it, so every row reads correctly.
- There is no backfill. A read takes
- A reused key is refused: a
clientKeythat already names a different message (another member, other words, or other files) is refused asinvalid-request. Only the send that wrote a message takes it back when its hand-off fails. - Client:
HostedCodingAgentClient.messageInbox(itemId, request)andcodingAgentInboxMessagedViewSchema, for the desktop's Gateway. Path builder:inboxMessagesPath. - A post under the item's key keeps the thread:
postInboxItem's attachment sweep now spares the message kind. - Reads that watch for changes:
- Plan and agent inbox reads: they digest
updatedAtfor every open and latest-settled item, so a message wakes a held read at once, settled items included. - Account read: it digests open items alone. A message on an open item wakes it at once. A message on a settled item moves the account's plan list (
max(updated_at)) at the hold's end, as an archive does today. - The realtime inbox stack (#3217 → #3232 → #3264 → #3306) is still open. Its plan and agent live statement selects
inbox_itemrows, and a message updates that row, so a follow sees the change. That statement doesn't projectmessagesyet; it doesn't projectanswerFileseither. Whichever of the two lands second should addMESSAGES(ininbox-store.ts) to that projection.
- Plan and agent inbox reads: they digest
- PRIVACY.md: adds one sentence saying a thread's messages and files are kept with the item, under the member who wrote them, and handed to the agent. Dean decided the feature (members post on any item, and the message reaches the agent); this sentence states it.
Migration (20261017134832_inbox_message_thread)
Online-safe and idempotent:
CREATE TABLE IF NOT EXISTSforinbox_message.- Nullable
inbox_attachment.message_idandinbox_item.settled_atcolumns with no default, each a catalog-only change. IF NOT EXISTSindexes. Theinbox_attachmentindex covers a few rows per item, their bytes stored out of line, and its column is null on every row.DROP CONSTRAINT IF EXISTS, thenADD, for each constraint.- The new FK on
inbox_attachmentand the replaced kind check are addedNOT VALID, following20261015114324_message_sender. Every existing row already satisfies both, so nothing is scanned under the lock.
JSON Schema goldens (LUKE_UPDATE_FIXTURES=1)
inbox-wire-inboxItemSchema.json: adds the optionalmessagesarray ofinboxMessage, and the optionalsettledAtcount.inbox-wire-inboxAnswerSchema.json,inbox-wire-inboxAnsweredSchema.json,coding-agent-view-codingAgentInboxAnswerViewSchema.json,coding-agent-view-codingAgentInboxAnsweredViewSchema.json: the samemessagesandsettledAtadditions, through the item they embed.- New:
inbox-wire-inboxMessageSchema.json(one message),inbox-wire-inboxMessageRequestSchema.json(the POST body),inbox-wire-inboxMessagedSchema.json(its answer),coding-agent-view-codingAgentInboxMessagedViewSchema.json(that answer or a failure, for the Gateway).
Evidence
- Platform-independent checks:
pnpm check:affectedgets killed by the sandbox's 8 GB limit (exit 137) when its tasks run in parallel, so I ran its parts one after another. All passed:pnpm knip,pnpm discarded-effect,pnpm oxlint, andbiome check.turbo run typecheck --concurrency=1over every dependent of@luke/databaseand@luke/features, desktop and host included.drizzle-kit check("Everything's fine"), anddb:generatereports no unmigrated schema.- The repository contract checks and the API callers check.
vitest run --project features --project database: 835 tests passed.vitest runovercoding-agents-app.test.ts,coder-host/,inbox/, and the web storage, functions, and services tests: 239 tests passed.
Invariants
-
./scripts/check.shgreen. (CI) Locally, its parts ran one after another, as above. CI runs it whole. - JSON Schema goldens unchanged (
LUKE_UPDATE_FIXTURESnot run, or diff explained line by line). (CI) Explained above. - Every behavior change has, in this PR, the test that fails without it; no change-detector test added. (manual; root
AGENTS.md"Testing")- Store, PGlite (
inbox-store.test.ts): the starter and a teammate post on an open item and on an answered one; an outsider writes nothing; status, answer, and revision stay;updated_atand the plan read's cursor move; messages read oldest first with their author. A second test covers a message's files kept in order and served, a repost keeping the thread and its files, a repeat under the same key writing once and moving nothing, other words under that key refused as the key reused, and a withdrawn message going with its files. A third coverssettledAt. - Route (
coding-agents-app.test.ts): a message on an answered item wakes an agent whose turn ended (statuscompleted→running) and reaches eve under "Inbox message" with its words and file, while the item stays answered at its revision. A repeat reaches eve once, and other words under the same key are refused without reaching eve; a teammate posts once they join and is refused before; no bearer and empty words are refused. A second test checks that a hand-off eve refuses leaves no message on the thread. - Client (
coding-agent-client.test.ts): the path, the trimmed body, the answer, a refusal named, and nothing sent for an empty message.
- Store, PGlite (
- Gateway envelope goldens unchanged. (CI)
- No new
astype assertion outside the allowlisted files. (CI via anti-slop) - No
Effect.runPromise/runSync/runForkoutside the runtime edges and the shimstools/anti-slop/effect-edges.jsonrecords. (CI viaanti-slop/no-run-promise-outside-edges) - No new
setTimeout/setInterval/new Promise/AbortController/fs.watchoutside the filestools/anti-slop/effect-edges.jsonrecords. (CI viaanti-slop/no-raw-async-primitives) - No Effect, Stream, or Layer written as a statement and dropped; a description a caller walks past runs nothing. (CI via
pnpm discarded-effect) - Test count equals the pre-PR count or the delta is listed. (manual:
vitest run --reporter=json) +6: three store tests (the third coverssettledAtthrough an answer, a close, a row settled before the column, a reopen, and a message on an open item), two route tests, and one client test. - Each hand-rolled file the PR replaces is deleted, or the part that remains is a permanent adaptor named in root
AGENTS.md's "The permanent adaptors" with the reason it stays. Nothing is replaced. - Every
CLAUDE.md/AGENTS.mdsentence naming a changed part is edited; root pair stays byte-identical. None names these parts. -
PRIVACY.mdunchanged, or the change is a product decision called out in the PR body. Called out above. - Package
package.jsondeps match what the sources import by bare specifier;effectviacatalog:. (CI) Adds the./schemas/inbox-messageexport. - Barrel/door rule: no Node-reaching Effect module (
@effect/platform-node,effect/sql) behind a barrel the renderer or a web function opens. (CI: existing renderernode:grep plus a new grep for those specifiers in renderer bundles)
Notes
- Follow-ups:
- The desktop half builds on this branch.
- PR 2 (the agent's
read_inboxandreply_inbox) stacks on it. - Whichever lands second of this PR and the realtime stack should add
messagesto the scope live statement, as noted under Summary.
- Preview run:
docs/WORKFLOW.mdstep 3's./scripts/run.sh --previewneeds the desktop half to draw the thread. Until then the route is covered by the tests above.
Made with Luke
Summary by cubic
Lets any workspace member add follow-up messages to an inbox item's thread, beyond the single answer an item holds today.
- New
inbox_messagetable stores each message with its author (member or the item's agent), Markdown text, files, and a per-itemclient_keyfor idempotent retries. - New
POST /api/inbox/{id}/messagesaccepts{ text, attachments?, clientKey }from any member of the item's workspace, on open, answered, or closed items. - A post writes the message and its files and moves only
updated_at, leaving status, answer, and revision untouched.inbox_itemnow stampssettled_atwhen an item is answered or closed and clears it when it opens, so a message no longer moves when the item settled. - The message reaches the agent through the same steer as a tab message, under an "Inbox message" lead, waking a stopped agent. A failed hand-off withdraws the message only for the send that wrote it; a send under a key that already named another message is refused.
- Item reads carry up to 50 messages, oldest first, each with author, text, files, and timestamp. Message files reuse
inbox_attachmentunder the newmessagekind.
Migration
inbox_messageis created withCREATE TABLE IF NOT EXISTS.inbox_attachment.message_idandinbox_item.settled_atare added as nullable, no-default columns (catalog-only changes); no backfill, as an item settled before the column reads its last change as settled until its first message.- Indexes and constraints are added idempotently; the new FK and kind check are
NOT VALID, so no existing rows are scanned under lock.