feat(kilo-chat): attachments backend (R2 + presigned URLs + plugin)#3304
Draft
iscekic wants to merge 47 commits into
Draft
feat(kilo-chat): attachments backend (R2 + presigned URLs + plugin)#3304iscekic wants to merge 47 commits into
iscekic wants to merge 47 commits into
Conversation
…rets Operator action required pre-deploy: - create R2 bucket kilo-chat-media - create bucket-scoped R2 API token - put R2_ACCESS_KEY_ID_KILOCHAT_MEDIA + R2_SECRET_ACCESS_KEY_KILOCHAT_MEDIA into Secrets Store
Spec note: route takes ?conversationId=<ULID> query param to avoid a global attachment-id index DO.
Annotate the two secrets-store bindings (R2_ACCESS_KEY_ID_KILOCHAT_MEDIA, R2_SECRET_ACCESS_KEY_KILOCHAT_MEDIA) with @from hints so dev/local/env-sync auto-populates the local secrets store from .env.local on first run.
Without atomicity, a crash between the message INSERT and the attachment UPDATE loop leaves messages referencing attachments that remain 'pending'. getAttachmentForRead only returns 'linked' rows, so such attachments become permanently orphaned but still referenced by the committed message. Wrapping both operations in a single db.transaction() ensures either the message is created and all attachments are linked, or neither happens. Regression test for the mid-flight crash path is omitted: reproducing it would require monkey-patching the Drizzle tx object mid-iteration, which is invasive and fragile. The structural fix is the important part.
Previously scheduleOrphanSweepIfNeeded and the re-arm in sweepOrphanAttachments only set the alarm when none existed or the existing one was already past. A stale orphan-sweep alarm (further out than 24h) would never be corrected. Add existing > target to both conditions so a far-future orphan-sweep alarm is pulled in to the ~24h window.
Add foreign keys for message_id → messages.id and uploader_id → members.id on the attachments table, matching the style of botMessageNotifications. Regenerate migration 0002 to include the FK clauses in the CREATE TABLE DDL.
…bhook Adds .min(1) to filename in attachmentBlockSchema and the inline attachment object in messageCreatedWebhookSchema, mirrored in the kiloclaw plugin synced copies, closing the contract gap with attachmentInitRequestSchema which already enforced non-empty.
Add tests asserting that a bot authenticated for sandbox-B is rejected with 403 when it references a conversationId created under sandbox-A membership, for both the init and geturl attachment routes.
Use R2_ACCESS_KEY_ID and R2_SECRET_ACCESS_KEY (the worker binding names) instead of the Secrets Store secret_name keys, with comments naming the upstream secret and store_id for reference.
The attachmentInitRequestSchema requires `.positive()` since a zero-byte upload makes no sense, but the downstream `attachmentBlockSchema` and the message.created webhook accepted `.nonnegative()`. Align both with the init schema so a stored attachment can never claim zero bytes.
… ctx The presigned R2 GET URLs we packed into `MediaUrl`/`MediaUrls` on the inbound context payload expire after 1 hour and no downstream code in kiloclaw consumes them — the agent runner reads `MediaPath` for the local file copy. Removing the fields stops persisting stale URLs into session ctx for no benefit.
Was duplicated across the init request schema, ConversationDO, the plugin channel, and the plugin webhook dispatcher. Export it from @kilocode/kilo-chat (and the plugin's synced mirror) so the cap stays in lockstep if it ever changes.
… test helper The DO exposed a public 'bootstrapConversation' RPC method that was documented test-only but was reachable from any caller with a stub. Move the equivalence into a 'bootstrapConversationForTest' helper in the test support file so production code only ships 'initialize'.
…ttachmentForRead
Both DO methods now return discriminated-union results matching the
createMessage pattern:
{ ok: true, ... } | { ok: false, code: 'forbidden' | 'invalid', error }
The route handlers map the codes to 4xx responses directly instead of
regex-matching error messages, which was brittle and would break the
moment an error string ever changed.
Test consumers use a new unwrap() helper that asserts the success branch
or throws.
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.
Summary
Adds user/bot attachments to kilo-chat conversations. Bytes live in Cloudflare R2; upload via presigned PUT, download via presigned GET; metadata in ConversationDO SQLite.
Implemented per plan
docs/superpowers/plans/2026-05-18-attachments-backend.md(26 tasks, 9 phases).Architecture
aws4fetchfor direct client/plugin ↔ R2 transfer.ConversationDOowns the attachment row lifecycle:pending→linked, sync-delete on message edit/delete and conversation destroy, 24h orphan-sweep alarm.capabilities: Capability[]field so clients can gate the attachment picker.What's included
Shared schemas (
@kilocode/kilo-chat):capabilitySchema('attachments').attachmentBlockSchemajoined intocontentBlockSchema/inputContentBlockSchema.messageCreatedWebhookSchema.attachments[](max 10), text relaxed to allow empty when attachments present.botStatusRequestSchema/botStatusRecordSchema/botStatusEventSchemaaccept optionalcapabilities.services/kilo-chatworker:MEDIA_BUCKETR2 binding +aws4fetchdependency.R2_ACCESS_KEY_ID_KILOCHAT_MEDIA,R2_SECRET_ACCESS_KEY_KILOCHAT_MEDIA.R2_ACCOUNT_ID/R2_BUCKET_NAME/KEY_PREFIXvars. (Dev override:wrangler dev --var KEY_PREFIX:dev/.)src/util/presigner.ts(mintPutUrl/mintGetUrl) andsrc/util/attachment-key.ts.attachmentstable on ConversationDO + migration; newcapabilitiescolumn onbot_status+ migration.initAttachment(30s idempotency),getAttachmentForRead,createMessagelinks rows,editMessageallows attachment subset,deleteMessage/destroy purge from R2, 24h orphan-sweep alarm.POST /v1/attachments/initandGET /v1/attachments/:id/url?conversationId=…, mounted on both user (/v1/...) and bot (/bot/v1/sandboxes/:sandboxId/...) scopes.buildPayloademits attachments; tolerates empty text when attachments are present.services/kiloclaw/controller:/attachments/initand/attachments/:id/url(preserves query string).Plugin (
services/kiloclaw/plugins/kilo-chat):KiloChatClient.initAttachment+getAttachmentUrl.PLUGIN_CAPABILITIES = ['attachments']propagated throughsendBotStatuscalls (sendPresence,handleBotStatusRequest).outbound.attachedResults.sendMedia(init → PUT to R2 → createMessage).dispatchInbounddownloads attachments and populatesMediaPaths/MediaUrls/MediaTypes.Mobile app:
apps/mobilerenders attachment content blocks (paperclip icon + filename). Full UX (thumbnails, tap-to-download) deferred.Tests
packages/kilo-chat: 84 tests passing (+18 new).services/kilo-chat: 368 tests passing across 38 files (+~50 new). Clean exit, no leaked unhandled rejections.services/kiloclaw/plugins/kilo-chat: 186 tests passing across 17 files (+16 new).pnpm run typecheck: clean.pnpm run lint: clean.jestsuite fails on PG connection in this worktree (pre-existing infra dependency on a running Postgres, unrelated to this change).Operator action required before deploy
wrangler r2 bucket create kilo-chat-mediakilo-chat-mediawith Object Read + Write.R2_ACCESS_KEY_ID_KILOCHAT_MEDIAandR2_SECRET_ACCESS_KEY_KILOCHAT_MEDIAinto the Secrets Store under store_id342a86d9e3a94da698e82d0c6e2a36f0.wrangler.jsoncto match the existing kilo-chat values — no placeholders remain.)Test plan
dev/attachments/<convId>/<botId>/...; human client renders attachment block.curl POST /v1/attachments/init→PUTto returned URL →POST /v1/messageswith attachment block. Plugin webhook deliversattachments[]; files land under sandboxmedia/inbound/; agent responds based on attachment content.wrangler r2 object get→ 404).bot.statusevent includescapabilities: ['attachments']in client DevTools.Notes
GET /attachments/:id/urlroute takes?conversationId=<ULID>to avoid a separate global attachment-id index DO.editMessagemay only drop existing attachments, never add new ones (matches spec).