Repository navigation
Fix what the Channels QA found: attachments, live resume, delegated answers, approvals, contacts, and a 1 MiB socket ceiling - #1010
Conversation
The generated client read every non-JSON body with response.text(), so a channel line's image arrived as a mangled string typed Blob and the channel timeline threw on URL.createObjectURL (QA B1). Patch the generator's getResponseBody (via patch_generated_imports.mjs, so regeneration keeps it) to return a Blob for anything that is not JSON or text, and route channels.file through HttpClient.requestBytes like the other file reads. Co-Authored-By: Claude Opus 5.5 <[email protected]>
A shared image whose bytes come back as anything but a Blob no longer throws out of URL.createObjectURL and takes the channel route down (QA B1): the chip says it could not show the image and still offers the file, and a boundary around each line's parts keeps the rest of the timeline drawn if one part cannot be. Co-Authored-By: Claude Opus 5.5 <[email protected]>
…A M22) Co-Authored-By: Claude Opus 5.5 <[email protected]>
Pretty output interpreted [..] in user content: a channel titled "[live] x" lost its prefix, and a member could style text or inject links into another member's terminal. Values are now escaped where they meet markup in the shared renderer (lists, details, transcripts, schemas), in fail(), and in the other renderers that print server-sourced names and messages. Co-Authored-By: Claude Opus 5.5 <[email protected]>
messages list/thread, inbox and search printed the generic table: id, channel, time, seq, deleted -- no sender, no text. They now show seq, time, sender name and kind, the text on one line (cut at 100 chars unless --full), and what is true of the line: replies, edited, deleted, withheld, members only, a question and its state. --json is unchanged. Co-Authored-By: Claude Opus 5.5 <[email protected]>
A malformed id printed Python's "badly formed hexadecimal UUID string"; as_uuid now raises LemmaInvalidIdError (a LemmaError and a ValueError) naming the value, before any request. Every 409 got "Something with that name already exists", including answering a closed question; that hint is now kept for 409s whose code or message says duplicate, and the rest show the server's message alone. Also pins, over the real HTTP layer, that channels.update(topic=None) sends an explicit null and channels.file() returns the bytes. Co-Authored-By: Claude Opus 5.5 <[email protected]>
The @ menu moves out of the composer into useMentionPicker, which the box a line is changed in now uses too. The box opens with the caret after the last word. A change to who the line names sends the parts with it -- the mentions, and the files and links it already had -- so the newly named are told, as on a post; a change to the words alone still sends only the words. The SDK's channels.edit takes those parts (the generated type flattened them to null). A space typed straight after a pick is the one the pick already wrote, so '@bolt ' then ' what' no longer posts a double space. Co-Authored-By: Claude Opus 5.5 <[email protected]>
…kips - Assigning a contact seats the assignee, and a contact's line is pushed to its assignee even when they are not in the channel (H3). - /inbox lists contacts waiting on a reply: asking for a person, or assigned to the reader with the contact's word last (M16). - A contact held by a member who has not written yet is acknowledged, and a held contact's channel asks for a reply until the assignee writes (M17). - A push is skipped only once the member read the line; having the app open holds it a few minutes and it goes if still unread (M15). - Every reader of a line sees where it went outside Lemma; pushes stay the recipient's (M14). - Delivery retry keeps its attempt count, clears the old reason, and a push is retried by its recipient. Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
- GET /contacts and /contacts/{id} say which handles opted out; a contact's
channel detail carries opted_out (M18).
- A STOP leaves a members-only note in the contact's channel, naming its
assignee (M28).
Co-Authored-By: Claude Opus 5.5 <[email protected]>
- New channel: the pod's members are asked for again when the dialog
opens (someone who joined since the space opened was missing), and each
member checkbox is named for its person.
- An answered agent question says what was answered: answerSummary reads
the {answers: {header: value}} the API stores, and {approved}.
- About panel: the people picker sits under the people and the agent
picker under the Agents heading; its selects are painted for the dark
theme; a truncated name has its full name as a tooltip.
- Mobile: the contact filter chips wrap onto a row of their own, and the
channel list leaves room under its last row for the floating Ask pill.
- The sidebar's Channels badge counts what the bell counts -- questions
waiting, mentions, new lines in your own channel -- so the two agree; a
public group with new lines and nothing for you no longer counts.
- Search results show the line's own words with markdown read as plain
text, cut at word edges, the match marked; the server's cut only where
the line's text is withheld.
- The reader's own line is matched by its server id, so a changed
client_message_id never shows it twice (a test pins it).
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
…ere it waits - A conversation unseated when its contact was merged is folded into the one that kept the seat: the agent and the visitor's other browser read it (M12; edit in agent/contracts/contact_conversations.py). - A member's line to a web contact is SENT with a reason saying it waits in their chat until they next open it (M13). Co-Authored-By: Claude Opus 5.5 <[email protected]>
…ead, say why when there is none
- H1: a run woken by the answers of people the agent asked for the member
(message_replies / pod-ask answers) posts its answer in the member's
Lemma channel, after the line the conversation took up last.
- H5: in a group, a line in another thread than the run its agent is
working on waits (Defer) and is answered by its own run in its own thread.
- H6: the prompt reads a member's line as it says now: edited text, or that
it was deleted -- never the stored copy.
- H4/M20: a failed or stopped run, or one refused before it starts, says so
where it was asked. A contact's failed run calls for a person and, if no
run ever got further, uncounts the contact. Visitor stream gets an
"error" frame (then "done"); the widget renders it. A members' note naming
an agent is answered, as a note.
- H2: approval lines show the tool and its arguments, secrets masked, clipped.
- AG-8: the withheld-lines note gives the reason of the view it was read for.
- AG-9: a question written past is CANCELLED, not ANSWERED {"decision":"DENY"}.
- AG-10: a workflow form line carries its step's label.
- L12: an ASK choice must be one of the options (free text goes as text).
- messaging.md: "send everything first" (no wait wording).
Co-Authored-By: Claude Opus 5.5 <[email protected]>
…ccess on open sockets - B2: LineRepository.changed_since no longer caps at MAX_LINES, so the limit+1 probe can see "too many": a resume or a drain past one read now sends every line, or resyncs too_many past LIVE_MAX_CHANGES_PER_BATCH. - L8: a batch's lines are bounded by the head read for it (up_to_rev): no line past its own cursor, no steady-state duplicate line.created. - L9: subscribed echoes `since` only when it resumes from it; a cursor whose line is ahead of its change resyncs cursor_expired. - M10 (socket): `read` bounds seq to int64 and answers invalid. - L11: the client frame limit counts UTF-8 bytes. - L7: an open socket rechecks pod.read on each slot refresh and on `presence`, and closes 4403 when it is gone (slot and presence released). - M21: the inbox remembers what each reader was told; counts that did not move send no frame and read no totals. - L13: new questions are found by query, not in the first 50 lines. - L10: the Python SDK carries `events` frames (LiveTopicEvent) and any topic via LiveSession.add(topic=, params=) and watch_topic(). Co-Authored-By: Claude Opus 5.5 <[email protected]>
- A new Telegram surface whose webhook is refused is deleted, not left in
ERROR holding its name (M26).
- GET /surfaces and /surfaces/{name} carry problem: why the surface cannot
work (webhook refused, or credentials refused); /setup is not ready and
leads with it.
- A send answered 401 records its provider status, notes the problem on the
bot's surface, and a contact's channel asks for a person (M27).
Co-Authored-By: Claude Opus 5.5 <[email protected]>
…od access on open sockets
- A refused WhatsApp group start says retrying will not help (and names a refused token), rather than 'try again shortly'. - A WhatsApp message from the business's own number is not a person. - A message in a group the bot did not create leaves a log line. - WhatsApp /setup is ready once messages arrive through the webhook. - The email-not-configured push reason no longer carries operator instructions; they are in the log. Co-Authored-By: Claude Opus 5.5 <[email protected]>
…ep the ratchet channel_answers.py and pause_questions.py grew past the 600-line ratchet: the agent's answer/notice lines move to channel_answer_lines.py and an approval's shown action to approval_action.py. with_channel_background drops back under the complexity cap. A refusal notice is not carried to a group's place. Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
…last owner
- Text a person sends cannot hold a NUL (post, parts, title, topic, list and
search q, notices): 422, not a 500 from Postgres. Line numbers are bounded
by bigint (REST seq params, /read).
- Naming an agent that is not the pod's is refused (422); platform lines drop
it rather than hit the mention FK.
- client_message_id is kept per member (member:{user}:{name}) and shown back
to its writer as sent; an Idempotency-Key names the line when no id does,
and both sent must agree. Reusing a key/name for something else is 422 on
lines, groups and notices, saying which was sent.
- Read state: unread lines are the timeline's (thread replies excluded, by
number: channels.replies vs read_state.read_replies); a mention is read
where it was said (channel_mentions.read_at; /read {seq, thread_root_id});
a reply reads its thread, not the channel; joining starts at last_seq;
muted channels are not counted as new.
- A group keeps an owner and a private one somebody: LAST_OWNER /
LAST_MEMBER (409); the last owner leaving hands ownership on.
- Threads: root carries its reply count, deleted replies leave it, and
replies page backwards (newest, before_seq).
- Semantic error codes on channel refusals, documented responses on the
routers; PATCH {} is not an edit; a deleted last line says deleted; list q
matches every word and DIRECT/CONTACT by person; snippets keep words whole;
notify_via is a known platform; members add forbids unknown fields;
notices are rate limited per sender.
Schema: edits to the unreleased 0058 in place (client_message_id 128,
channel_mentions.read_at + partial index, channels.replies,
channel_read_state.read_replies).
Co-Authored-By: Claude Opus 5.5 <[email protected]>
…ion, each line its own thread
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
…ounded contact search Co-Authored-By: Claude Opus 5.5 <[email protected]>
…ts repository Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
uvicorn's ws_max_size is now WS_MAX_BYTES (app/core/server_limits.py), passed explicitly by every start of the API -- Dockerfile (plus UVICORN_WS_MAX_SIZE), Railway, compose, make dev, Desktop, the local stack, the harnesses -- and a gate test holds each to the constant. A message past it closes the channels live socket and the datastore /changes socket with 1009 as it arrives. The ceiling stays at 16 MiB, not the 1 MiB asked for: the Agent Host link is a socket of the same server and installed hosts send messages past 1 MiB on it (op data, process output, inline images). At 1 MiB the real-binary host e2e fails its 2 MiB file round trip. Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
… one websocket ceiling # Conflicts: # lemma-backend/app/modules/channels/domain/entities.py # lemma-backend/app/modules/channels/infrastructure/message_models.py # lemma-backend/app/modules/channels/infrastructure/rows.py # lemma-backend/app/modules/channels/services/messages.py # lemma-backend/docs/modules/channels.md # lemma-backend/migrations/versions/2026-10-17_channels_0058.py # lemma-frontend/tests/channel-timeline-dom.test.ts
Owner decision: nothing a client means to send over a socket is larger than 1 MiB. Files and other bulk bytes travel over the HTTP API. Co-Authored-By: Claude Opus 5.5 <[email protected]>
config_source stays a string on the wire (validated server-side, 422 for an unknown source): a named enum becomes a strict TypeScript enum in the generated client and breaks every caller passing the literal. Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
…is unread Marking a timeline (or thread) read joined channel_mentions to channel_messages on every read -- including the read a member's own line takes as it is posted -- so a member's first Telegram message read one more channel_messages than its budget. Whether anything naming them is unread is now asked of the unread-only mention index (in the read-state update's RETURNING for the timeline), and the lines are visited only then. Co-Authored-By: Claude Opus 5.5 <[email protected]>
The API reads no websocket message past 1 MiB, and the link sent more: a
file.read answer carried up to 1 MiB of base64 (1.33 MiB), and a batch of
256 events carried generated images inline. A 1009 closes the link for every
run on it, and the host resends the same frame after each reconnect.
File chunks now cross HTTP as transfers the op names. file.write names a
chunk Lemma staged (GET /agent-host/transfers/{id}); file.read names a slot
the host fills (PUT /agent-host/transfers/{id}, x-lemma-sha256) and answers
size + sha256 instead of data. A transfer is the host's own (host secret as
bearer, like the link), used once, digest- and size-checked, and expires a
minute past its op's deadline; bytes wait in Redis. The relay does the HTTP
half outside the sandbox, so the exec-server is unchanged.
Run-event images go up first as blobs of the run (PUT
/agent-host/runs/{run_id}/blobs/{sha256}, only for a run the host holds,
kept as long as the run's event stream) and the event names them with
lemma_blob; the artifact writer reads them back. Event batches are bounded by
bytes (512 KiB) on the host, an event still too large has its longest strings
cut, and any frame past 1 MiB is refused before it is sent.
What still rides the link inline is held to 256 KiB: process.read (new
max_bytes), process.input, secret.deliver, initial_input. A host without the
new `transfers` capability is served by its link session, which inlines a
write's chunk and delivers a clamped read's answer into the slot.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
…he link past 1 MiB
Moves sender_values and nameless from lines.py to rows.py, keeping lines.py inside the architecture ratchet's 600 lines. Co-Authored-By: Claude Opus 5.5 <[email protected]>
…s regenerated ReadState (mark_read, mark_thread_read, read_seq(s)) moves out of members.py into read_state.py, mixed into MemberRepository, keeping both inside the architecture ratchet. The share route gets its contract entry and route-inventory line; scenario coverage is regenerated. Co-Authored-By: Claude Opus 5.5 <[email protected]>
…red for itself A run stopped half way left its request and tool calls in history with nothing saying it was over; the next run in the conversation finished it and posted "Done" under the new line. The history now closes a stopped run with a notice, before anything said after Stop. Co-Authored-By: Claude Opus 5.5 <[email protected]>
"Cats" was refused against the option "cats". Choices now match without regard to case and are stored as the option spells them. Co-Authored-By: Claude Opus 5.5 <[email protected]>
The refusal told the member to set the webhook manually to the address of a surface that had just been deleted, and a webhook set by hand would lack the secret Lemma checks. It now says Telegram refused the bot, with Telegram's reason, and to check the token and try again. Co-Authored-By: Claude Opus 5.5 <[email protected]>
A validly signed token naming a pod that is gone failed the suppression's pod foreign key and answered 500. It now answers the friendly 404 page. Co-Authored-By: Claude Opus 5.5 <[email protected]>
On a line near the top of the timeline the menu opened upward and its first names were hidden under the channel header. It now measures the room between the box and the top of what it scrolls in, and opens below when the menu would not fit. Co-Authored-By: Claude Opus 5.5 <[email protected]>
The send is still out when the assertion runs; asserting before React flushed the pending state failed about half the runs. Co-Authored-By: Claude Opus 5.5 <[email protected]>
|
Important Review skippedToo many files! This PR contains 336 files, which is 186 over the limit of 150. To get a review, reduce the PR to 150 files or fewer by splitting it into smaller PRs or changing its base branch. Upgrade to a paid plan to raise the limit. Usage-priced reviews support at most 300 files. ⚙️ Run configuration
⛔ Files ignored due to path filters: 1
📒 Files selected for processing: 336
You can disable this status message by setting the
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
CodeQLNo new findings on lines this pull request changed (as of Updated on every push. A finding judged safe goes in |
The host-to-Lemma direction was bounded; Lemma-to-host was not. An mcp_ok
carried tool-result images inline (up to 5 MiB, ~6.7 MiB base64), commands
and control_ok carried every due command with unbounded prompts, and an old
host's op got a 1 MiB write chunk inline (1.33 MiB).
Large parts are now staged for the host to fetch: a tool result or command
payload past 512 KiB is replaced by {"lemma_staged": {id, size, sha256}} and
the host GETs it from /agent-host/transfers/{id}, checks size and digest, and
puts it back before anyone reads the frame (LinkHandle::mcp, the worker's
command intake). Staged bodies are reusable until they expire, because a
command or tool answer may be sent again. Commands are packed into frames of
at most 960 KiB; what does not fit beside a control_ok follows as pushes.
One guard in LinkWriter.send_frame refuses any outbound frame past
WS_MAX_BYTES (agent.agent_host_link.frame_too_large): an answer becomes a
FRAME_TOO_LARGE error for that request, a push or op fails to its sender, and
the link stays open.
Hosts say they fetch staged parts with staged_bodies on hello. An older host
gets tool results with images dropped (and a note to the agent), and is not
sent a command too large for a frame. Write chunks are now 512 KiB so an old
host's inline chunk fits.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
…deQL false positive - test_a_member_assigned_a_contact_is_told_when_they_write drives work in-process, so it is marked drives_work (test_drives_work_contract). - gitleaks generic-api-key read `secret file", ttl_seconds=60` as a key and value; the call now puts each argument on its own line, and the commit already in this branch's history is allowlisted by exact value, like the ttl_seconds=3600 entry. - CodeQL py/incomplete-url-substring-sanitization flagged a test assertion on a bare hostname; it asserts on the whole URL instead. Co-Authored-By: Claude Opus 5.5 <[email protected]>
Factory review: Needs a person's reviewfirst round · reviewed at Channels QA fixes land, and the Mac's bytes leave the socket
What could break
691 of 20,260 changed lines worth reading. The rest is mechanical, generated or lockfiles. Decisions this PR makes
Findings · 2 open
Worth fixing · 2 warnings1. The new columns arrive by editing a migration that is already on main — This PR adds Suggested fix: 2. An old Desktop host's large process.read now closes its link with 1009 —
Suggested fix: One comment, edited after every round. Last updated 20 Oct, 11:34 UTC. |
|
Co-Authored-By: Claude Opus 5.5 <[email protected]>
What changes
Fixes what a local end-to-end QA of Channels v2 on main (#1000/#1001/#1004) found, and lands the product calls made from it.
Blockers and highs
Blob(patched in the generator script, so regeneration keeps it).channels.filegoes through the byte path. A part that can't be shown is a chip that says so, and an error boundary keeps the rest of the timeline drawing.changed_sincedefeated thetoo_manyprobe.resync too_manyis sent. A batch never runs past its own cursor.Mediums and lows, grouped by area:
client_message_idis echoed as the client sent it.reply_countis correct, and threads page newest-first.Product decisions made in review
WS_MAX_BYTES;test_ws_max_size_everywhereholds every launcher to it). Bulk bytes travel over the HTTP API:GET/PUT /agent-host/transfers/{id}), with size and SHA-256 enforced, single use and expiry.PUT /agent-host/runs/{run}/blobs/{sha256}) and referenced by digest.make desktop-agent-host-e2e(17/17) passes a 2 MiB file round trip and a 3 MiB tool-result image reaching the agent whole, both under the 1 MiB cap.Why
The QA list, with repro steps and evidence, is summarised above.
How to verify
Results on the final branch, after merging main:
make quality: 32/32 gates.make quality-frontendandmake format-checkpass.make desktop-agent-host-e2ewith a freshly built host binary: 17/17.cargo testandmake desktop-lintpass.Live re-check on a fresh local stack running this branch:
resync too_many), and H1–H7, moderation, search, M1–M11, H3/H4, the WhatsApp window, STOP and the 1 MiB cap.Checklist
0058_channelsis edited in place (owner's decision; no database has applied it). Its upgrade/downgrade e2e passes.lemma-client.jsregenerated and committed.0058is edited in place, so a revert means resetting any database created from this branch.Compatibility and rollback notes
0058_channelsedited in place. It addschannel_messages.shared_at,deleted_by_user_idand the search expression;channel_mentions.read_at;channels.repliesandchannel_read_state.read_replies; andclient_message_idbecomes VARCHAR(128). No database has applied 0058, per the owner.ChannelMessageResponse:visibility,can_share,shared_at,removed_by, anddeliveredon every readable line.ChannelDetailResponse.opted_out.InboxResponse.contacts.ContactResponse.labelandContactIdentityResponse.opted_out.AgentSurfaceResponse.problem.newest/before_seqandnext_before_seq.ReadRequest.thread_root_id.channel.message.share, andqonGET /contacts.codes;notify_viaonly accepts known platforms;POST …/membersrejects unknown fields; notices can return 429.lemma_stagedreferences, which the host fetches over HTTP and checks against their digest. Commands are packed into frames of at most 960 KiB. One guard in the link writer refuses any frame over 1 MiB withFRAME_TOO_LARGEand keeps the link open. Older hosts (nostaged_bodiesinhello) get oversized tool-result images dropped, with a note to the agent; an oversized command isn't sent to them, so that run fails at its dispatch deadline.{"type":"error"}frame, always followed bydone.Left for later, by decision or out of scope:
🤖 Generated with Claude Code