feat(question): save partial answers to a multi-step question (re-land of #55)

Merged
KE
1mo ago
+518-20

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 #55 with the fixes from its adversarial review: saved answers are size-bounded, every save leaves a log line, and the field's contract is written where clients can read it.

Context: re-lands #55, which was merged and then backed out by #56 so the fixed version could be reviewed as one PR 路 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 路 #56 has merged, so dev is back to the pre-#55 tree and this PR's diff is the whole feature plus the fixes.

TL;DR

  • Save: PUT /question/:requestID/progress stores the answers given so far.
  • Read: GET /question returns them as progress.
  • No migration: they live in the question row's existing JSON column.
  • Bounded: a label is at most 4,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 saved with how many steps are answered, or progress 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. QuestionAnswer in the shared schema now caps label length and labels per answer, so reply and progress share one bound. The review showed a 3 MB label saved in 8 ms and re-served by every GET /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 a few hundred KB per question step.
  • Too many answers is a 400, not a stored misalignment. saveProgress compares against the row's question count and fails with InvalidRequestError, naming both counts.
  • Logging mirrors reply and reject. The production listener runs with disableLogger: true and the coordinator's caller never reads the response, so without these lines a save that reached opencode was untraceable.
  • Contract text. The progress description moved inside Schema.optional(...): the OpenAPI generator drops annotations on the wrapper, so it shipped in neither openapi.json nor types.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.

Design decisions

  • The caps live on QuestionAnswer rather than a progress-only payload, so a reply can never exceed what progress accepts and one schema serves both routes. They are first values and easy to tune: 4,000 characters covers a typed paragraph, 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 /question still 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 JSON true.

Testing Done

  • bun typecheck in packages/schema and packages/opencode; the commit hook ran turbo typecheck on every commit (30/30).
  • Question unit tests 17/17. The progress test now asks with a tool and asserts the whole listed request, rejects a third answer on a two-step question, and the isolation test proves reject and saveProgress from the other instance fail with NotFoundError.
  • Route harness test:httpapi: 213/213 in coverage, auth, and effect modes with 0 missing, including the new question.progress.oversized (4,001-character label, 400), question.progress.toomany (two answers on a one-step question, 400), and a seeded question.list that asserts progress on the HTTP body.
  • SDK and openapi regenerated by hand, since the fork's generate job cannot run; the generated diff is the new bounds and description text only.

Next

  1. Review and merge this PR. I will not merge it.
  2. Cut the next release. v1.18.32-2 was deleted before anything pinned it; v1.18.32-3 avoids reusing a tag name that once existed.
  3. Move the two andytown pins in #28427 to that tag.

馃 Generated with Claude Code