feat(question): save partial answers to a multi-step question (re-land of #57, 16k label cap)
People can now answer part of a multi-step question and finish it later, on any screen, even after a reload. The sandbox keeps the half-finished answers with the question itself. This re-lands #57 (itself the re-land of #55 with the adversarial-review fixes) with one change: the answer label cap is 16,000 characters instead of 4,000, so a long pasted custom answer is never rejected.
Context: #57 was backed out by #58 so the cap could change before anything shipped 路 the review that drove the fixes: https://github.com/replohq/replopencode/pull/55#pullrequestreview-5298285841 路 design call and thread: Slack 路 consumer: andytown #28427, whose pins move to the release cut from this PR 路 #58 has merged, so this diff is the whole feature plus the fixes with the raised cap.
TL;DR
- Save:
PUT /question/:requestID/progressstores the answers given so far. - Read:
GET /questionreturns them asprogress. - No migration: they live in the question row's existing JSON column.
- Bounded: a label is at most 16,000 characters, an answer at most 100 labels, and a save cannot carry more answers than the question has steps (400 naming both counts).
- Observable: every save logs
progress savedwith how many steps are answered, orprogress for unknown request.
Before
Each screen keeps half-finished answers in its own memory: the web card in the browser, Slack in the message, Messages in its delivery store.
flowchart TD
A["Person answers step 1 of 3"] --> B["Kept only in that screen's memory"]
B --> C["Reload, or switch from Slack to the web"]
C --> D["Starts over at step 1"]
Now
The answers are saved with the question, so every screen sees the same progress.
flowchart TD
A["Person answers step 1 of 3"] --> B["Client saves it: PUT /question/:id/progress"]
B --> C[("Question row in the sandbox database")]
C --> D["Any screen reads it: GET /question"]
D --> E["Resumes at step 2"]
What changed since #55
- Payload bounds.
QuestionAnswerin the shared schema caps label length and labels per answer, soreplyandprogressshare one bound. The review showed a 3 MB label saved in 8 ms and re-served by everyGET /question, which the coordinator lists on every reconnect and fans out to every browser in the project. Nothing else limited that body below Bun's 128 MB request cap; the worst case is now about 1.6 MB per question step. - Too many answers is a 400, not a stored misalignment.
saveProgresscompares against the row's question count and fails withInvalidRequestError, naming both counts. - Logging mirrors
replyandreject. The production listener runs withdisableLogger: trueand the coordinator's caller never reads the response, so without these lines a save that reached opencode was untraceable. - Contract text. The
progressdescription moved insideSchema.optional(...): the OpenAPI generator drops annotations on the wrapper, so it shipped in neitheropenapi.jsonnortypes.gen.ts. It now says what writers actually send: prefix arrays, whole-list replacement, absent until something saves. - Comments. The delete-as-claim note is back on
claim, and the harness seed says its row has no in-memory waiter.
What changed since #57
- The label cap is 16,000 characters (was 4,000). 4,000 was a judgment call; 16,000 covers a pasted page of text while keeping the row bounded.
Design decisions
- The caps live on
QuestionAnswerrather than a progress-only payload, so a reply can never exceed what progress accepts and one schema serves both routes. 100 labels covers every option of one question. - The answers-versus-questions check is a service check, not a schema cap, because the question tool decides the step count; a static cap could wedge a question that legitimately has more steps.
- Saving still publishes no event and stays last-write-wins. Clients read progress when they open the card; live cross-client sync was not the ask.
Works with old sandboxes
Sandboxes get a new binary only when they respawn, so old and new binaries run side by side for days.
- New binary, today's andytown: nothing changes until something saves.
- Rolled back after a save: the old binary drops the unknown key, and
GET /questionstill works. - Old binary, new andytown: the route does not exist, and the web app's catch-all answers
200 text/html; clients count a save only on JSONtrue.
Testing Done
bun typecheckinpackages/schemaandpackages/opencode; the commit hook ran turbo typecheck on every commit.- Question unit tests 17/17. The progress test asks with a
tooland asserts the whole listed request, rejects a third answer on a two-step question, and the isolation test provesrejectandsaveProgressfrom the other instance fail withNotFoundError. - Route harness
test:httpapi: 213/213 in coverage, auth, and effect modes with 0 missing, includingquestion.progress.oversized(16,001-character label, 400),question.progress.toomany(two answers on a one-step question, 400), and a seededquestion.listthat assertsprogresson the HTTP body. - SDK and openapi regenerated by hand, since the fork's generate job cannot run; the generated diff is the bounds and description text only.
Next
- Review and merge this PR. I will not merge it.
- Cut the next release as
v1.18.32-3(v1.18.32-2was deleted before anything pinned it). - Move the two andytown pins in #28427 to that tag.
馃 Generated with Claude Code