feat(database,features,service): an Inbox item holds a thread of messages

Merged
DA
9d ago
+16269-24

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_message holds the thread (packages/database/src/schemas/inbox-message.ts). Each row has:
    • the item, cascading on delete;
    • the author, member with their user_id (set null when the account goes) or agent, 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_attachment with a new message kind and a message_id link. They have the same bounds as an answer's file: the PDF bound, plus a name and a position. The kind check now pins message_id to the message kind alone.
  • Wire (inbox-wire.ts): an item read carries messages, oldest first, the latest MAX_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. messages is absent where the thread is empty.
  • Route: POST /api/inbox/{id}/messages takes { 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_at and nothing else. status, answer, and revision stand.
    • 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 clientKey is 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_item gains settled_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 optional settledAt, so the desktop no longer reads updatedAt, 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 its updated_at into settled_at before moving it, so every row reads correctly.
  • A reused key is refused: a clientKey that already names a different message (another member, other words, or other files) is refused as invalid-request. Only the send that wrote a message takes it back when its hand-off fails.
  • Client: HostedCodingAgentClient.messageInbox(itemId, request) and codingAgentInboxMessagedViewSchema, 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 updatedAt for 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_item rows, and a message updates that row, so a follow sees the change. That statement doesn't project messages yet; it doesn't project answerFiles either. Whichever of the two lands second should add MESSAGES (in inbox-store.ts) to that projection.
  • 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 EXISTS for inbox_message.
  • Nullable inbox_attachment.message_id and inbox_item.settled_at columns with no default, each a catalog-only change.
  • IF NOT EXISTS indexes. The inbox_attachment index covers a few rows per item, their bytes stored out of line, and its column is null on every row.
  • DROP CONSTRAINT IF EXISTS, then ADD, for each constraint.
  • The new FK on inbox_attachment and the replaced kind check are added NOT VALID, following 20261015114324_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 optional messages array of inboxMessage, and the optional settledAt count.
  • inbox-wire-inboxAnswerSchema.json, inbox-wire-inboxAnsweredSchema.json, coding-agent-view-codingAgentInboxAnswerViewSchema.json, coding-agent-view-codingAgentInboxAnsweredViewSchema.json: the same messages and settledAt additions, 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:affected gets 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, and biome check.
    • turbo run typecheck --concurrency=1 over every dependent of @luke/database and @luke/features, desktop and host included.
    • drizzle-kit check ("Everything's fine"), and db:generate reports no unmigrated schema.
    • The repository contract checks and the API callers check.
  • vitest run --project features --project database: 835 tests passed.
  • vitest run over coding-agents-app.test.ts, coder-host/, inbox/, and the web storage, functions, and services tests: 239 tests passed.

Invariants

  • ./scripts/check.sh green. (CI) Locally, its parts ran one after another, as above. CI runs it whole.
  • JSON Schema goldens unchanged (LUKE_UPDATE_FIXTURES not 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_at and 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 covers settledAt.
    • Route (coding-agents-app.test.ts): a message on an answered item wakes an agent whose turn ended (status completed → 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.
  • Gateway envelope goldens unchanged. (CI)
  • No new as type assertion outside the allowlisted files. (CI via anti-slop)
  • No Effect.runPromise/runSync/runFork outside the runtime edges and the shims tools/anti-slop/effect-edges.json records. (CI via anti-slop/no-run-promise-outside-edges)
  • No new setTimeout/setInterval/new Promise/AbortController/fs.watch outside the files tools/anti-slop/effect-edges.json records. (CI via anti-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 covers settledAt through 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.md sentence naming a changed part is edited; root pair stays byte-identical. None names these parts.
  • PRIVACY.md unchanged, or the change is a product decision called out in the PR body. Called out above.
  • Package package.json deps match what the sources import by bare specifier; effect via catalog:. (CI) Adds the ./schemas/inbox-message export.
  • 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 renderer node: 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_inbox and reply_inbox) stacks on it.
    • Whichever lands second of this PR and the realtime stack should add messages to the scope live statement, as noted under Summary.
  • Preview run: docs/WORKFLOW.md step 3's ./scripts/run.sh --preview needs the desktop half to draw the thread. Until then the route is covered by the tests above.

Made with Luke


Devin Review


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_message table stores each message with its author (member or the item's agent), Markdown text, files, and a per-item client_key for idempotent retries.
  • New POST /api/inbox/{id}/messages accepts { 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_item now stamps settled_at when 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_attachment under the new message kind.

Migration

  • inbox_message is created with CREATE TABLE IF NOT EXISTS.
  • inbox_attachment.message_id and inbox_item.settled_at are 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.