f2bb8a62929c9bc4a594c8683327705e947f7d13
39 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
b872923331 |
fix(improvise-2mqz): export the requesting session view
Co-authored-by: Ed Langley <fiddlerwoaroof@users.noreply.github.com> |
||
|
|
e0f04ee334 |
feat(improvise-2mqz): add wire forms for SaveAs and ExportCsv downloads
This commit implements the download flow for SaveAs and ExportCsv per the USER RULING on improvise-2mqz. The correct shape is: - A request frame asks the host for BYTES - ServerFrame returns the bytes (or Rejected) - Browser triggers downloads via Blob + <a download> Key changes: Protocol layer (improvise-protocol): - Add Request::DownloadDocument and Request::DownloadCsv variants - Add ClientFrame::DownloadDocument and ClientFrame::DownloadCsv - Add ServerFrame::DocumentBytes and ServerFrame::CsvBytes - Add KeyOutcome::LocalThenRequest variant for operations that need both local fold (mode change, buffer clear) AND a server request - Update stranding() to unstrand SavePrompt and ExportPrompt modes - Update keymap to use rebind() instead of commit() for commit-save and commit-export (because LocalThenRequest can't be in a Sequence) Host layer (improvise-command): - Handle new ClientFrames in host.rs returning bytes or Rejected - Add Rejection::ExportFailed variant for CSV export failures - Update CommitSave and CommitExport to clear their buffers - Make render_csv() public in improvise-io for host use WASM client layer (improvise-wasm-client): - Add PendingDownload type with filename, bytes, and kind - Store pending downloads for JavaScript retrieval via take_pending_download() Browser layer (web/app.js): - Add triggerDownload() function using Blob + <a download> - Poll for pending downloads after frame handling This unstrands SavePrompt and ExportPrompt which were previously refused by stranding() due to missing wire forms. Refs: improvise-2mqz Co-authored-by: Ed Langley <fiddlerwoaroof@users.noreply.github.com> |
||
|
|
2cf3303437 |
improvise-wpxr: a cell write naming a view the document lacks resolves nothing
Answers the ChatGPT Codex P2 on
|
||
|
|
58cd5d3858 |
improvise-wpxr: SetCell/ClearCell name the view that drew them
An empty record exists only as a layout position, so resolve it against the layout of the view the command names -- the one grid_in projects for that session -- rather than the host's own layout. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze |
||
|
|
3c6001ac7b |
Merge remote-tracking branch 'gh/main' into fix/6oy4-float-roundtrip
# Conflicts: # context/test-evidence/command.md # context/test-evidence/protocol.md |
||
|
|
c831e5ea4a | WIP: float_roundtrip on frame parsers; failing NaN quiescence test (improvise-6oy4, improvise-4w09) | ||
|
|
a77e3a9146 |
A read-only tab refuses edits instead of painting what it cannot save (improvise-j4qt)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze |
||
|
|
699f0da05e |
WIP: a read-only worker refuses edits before they are applied (improvise-j4qt)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze |
||
|
|
253b84b2da |
fix(protocol): a refused frame is put back by the fold that made it (improvise-nxah)
`Client::key` folds a locally-resolved batch before sending it, so the page redraws on the keystroke. A refusal left those folds in place: measured against a real `Host`, a refused commit left the client one row down with its edit buffer cleared while the host's mirror of that same client was still at the origin holding the user's typing. What rolls back is `ViewState` — the thing the fold produces — never a cache. A `Command::Model` is a no-op in `reduce_view`, so the optimistic fold never touched the caches, and the bug report's own evidence (`page_coords`) cannot diverge this way: it moves only under `Projection::Grid`. What diverges is the mode, cursor, status and buffers that ride along with the model command. The rollback needed a positive acknowledgement first, which the protocol did not have: with only `Rejected` on the wire, a client holding two frames in flight cannot tell which one an answer addresses. `ServerFrame::Accepted` closes that — emitted once per accepted mutating frame, last, after everything the frame produced — the same shape as the basis-publication gap ADR 001 originally left. Two existing facts make the rule usable rather than a convention: only `Command::Model` can be refused, so a frame carrying none is answered by neither and needs no entry; and `Rejection::refused_an_edit` already separates the two `Save` refusals, so a failed write cannot consume the answer owed to an edit. A refusal drops one frame and replays the rest, because a single saved snapshot is unsound under pipelining — it would discard a later frame the host applied. This does not do improvise-3al1's force, and it does not contradict it: the 2026-09-21 wave recorded the two tickets as contradictory on the ground that a rollback would wipe the staged typing. It does the opposite — the typing frames carry no model command, are never refused, and have already settled, so rolling back the commit restores the buffer to the typing. Asserted, not inferred. Reasoning: context/code/clients.md, context/code/protocol.md §4. Evidence: context/test-evidence/clients.md. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH |
||
|
|
d00cea2477 |
fix: show decimals by default, and let a formula name its own format
improvise-e0ex. `Margin = Profit / Revenue` drew as `1`: `View::new` seeded
`,.0` — zero decimals — so a 0.66 ratio rounded half-away-from-zero. The cause
is the format, not the arithmetic; exact decimal math would not change the
rendering by one character, so nothing here touches the evaluator and gotcha 8
is unchanged.
Two halves, per the user's ruling:
* `View::new` seeds `,.2`. Revenue draws `18,400.00` and Margin `0.66`. The
noise on whole numbers is the cheaper error — a currency column with two
redundant zeroes is legible, a ratio with none is wrong — and a format cannot
be guessed from a value.
* A formula may carry its own format, which wins for its target; a raw measure
keeps the view's. `formula.pest` gains
`format_clause = ${ ^"FORMAT" ~ sp+ ~ format_spec }`, with `sp+` for `WHERE`'s
reason (a predicate is invisible to the grammar-walking generator) and
`FORMAT` added to `quote::KEYWORDS`.
`improv.pest` is deliberately unchanged: `formula_raw` already carries the
`## Formulas` line verbatim, so the format rides in the formula text with
nothing to add — and the generator, which reads `improv.pest`, sees no new
spelling to violate.
Resolution has one home. `FormatPlan::of(model, view)` replaces the
`(comma, decimals)` pair every render path threaded — `GridLayout::display_text`,
`viewport::grid_body`/`compute_col_widths`, `project::grid_in` and
`persistence/export.rs::render_csv` — rather than being added beside it, which
would be two answers to "what format is this cell". It is asked of the cell's
`CellKey`, so a cell aggregating over `_Measure` keeps the view's format by
construction. `GridBody::col_formats` carries the answer to the totals strip,
which is formatted on its own path.
Persistence is untouched except for metadata: `format_f64` is display,
`persistence/format.rs::format_number` writes round-trip precision, and the two
have no call between them. Newly created views write `format: ,.2`; existing
documents are unaffected, because `format_md` has always written the line
explicitly. `web/demo.improv` moves to `,.2` on both views.
The trap this sets is gotcha 34's: `format_md` writes `Formula::raw`, so a
`FORMAT` suffix round-trips whether or not the walker parsed it. The round-trip
test therefore asserts the parsed field — injecting a walker that drops the
clause fails that one test and nothing else in `improvise-io`, 242 tests
including the whole characterization suite and the grammar properties.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
|
||
|
|
12714c7527 |
feat(browser): a boot failure is a state with a reason, not silence
improvise-1wft and the five beads it blocked were one failure: nothing
carried a boot failure's REASON from the worker to the page. The channel
is one rule -- a protocol frame crosses the port as a STRING, a shell
notice as an OBJECT -- so it needs no protocol change and the
discriminator is total.
A boot failure is a STATE, not a rejected promise. A document another
context holds the exclusive handle to now OPENS read-only: measured first
(harness probe, Chromium + Firefox), getFile() reads the bytes while the
handle is held, with the second-handle refusal asserted so the probe
cannot pass vacuously.
- improvise-1wft the channel: Notice/NoticeKind, boot_notice, take_notice
- improvise-i0v9 read-only boot over vfs::ReadOnly + OpfsFs::read_unattached
- improvise-86nw start() rejecting for any reason now posts BootFailed
- improvise-9cma boot::document_name -- and a/b/c.improv was ACCEPTED as
c.improv, not rejected, which is worse than the report
- improvise-iobc vfs::Origin: a seed error is a notice only when the
document really had to be created without it
- improvise-vep8 the watchdog arms BEFORE transport.ready; its message
stopped claiming the connection was accepted
- improvise-7gip DedicatedWorkerGlobalScope (needs a web-sys feature)
- improvise-zre1 a persist that fails raises a notice; the trigger is the
dirty flag surviving, observed rather than predicted
- improvise-7gf8 the render breaker trips once instead of retrying forever
- improvise-ociy non-OPEN sends are loud; a viewport announcement is not
- improvise-y01k the three ws-server drops are logged
- improvise-r4nf serde failures throw instead of reading as "nothing to send"
Every notice routes through shell.fail, so gotcha 48's disarm half is not
re-broken. Two comments in shell.spec.js stated the opposite of the new
arming order and were corrected rather than left.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
|
||
|
|
1dd6950ed3 |
A view-scoped ModelCommand names its view (improvise-lltt)
Nine ModelCommand variants configure a view and named it implicitly: the effect read App::view_mut(), the HOST's slice. Since improvise-jytm made ViewState::active_view authoritative per client, a client on another view moved the host's view instead of its own -- its grid did not change and every other client's did. Each of the nine now carries `view: String`; model_effects maps it to effect::ViewTarget::Named, and the nine effects carry a ViewTarget rather than reading an ambient one. There is no operandless form left: App::view_mut() is deleted and replaced by view_mut_of(&ViewTarget), so a future geometry effect has to say which view it writes. The host's key path passes ViewTarget::Active, which is right -- the TUI is the host and has no session to ask. A Named view the document has not got is a no-op rather than resolve_view's fallback onto the default. Second half, which the ticket did not predict: naming the view made a reframe of a view the host is NOT on reachable, and adopt_cursor_epoch could not see one -- cursor_epoch is bumped only by the host's own reset_cursor, so those clients were reset by nobody and told nothing (gotcha 24's strictly-worse-than-nothing state). App::last_reframed_elsewhere is the second trigger; its opening frames are taken lazily inside view_mut_of, the one door such a write goes through, so the held-key path still hashes one view and improvise-mcq4's declined cost is not paid. Evidence and the three injected faults: context/test-evidence/command.md. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH |
||
|
|
3f8f7cb765 |
refactor(core): evaluate_aggregated takes Aggregation, not a list it cannot read
The parameter was none_cats: &[String] and the body consulted it exactly once,
for emptiness -- a boolean wearing a list's type, since the aggregation runs
over key.0 through matching_values. git log -S back to the commit that
introduced Axis::None shows it never examined an element, so the name promised a
filter the function has never performed.
Aggregation::{Exact, OverHidden} admits only the two states the body
distinguishes, and Aggregation::of reduces the list once at the boundary that
holds it. The regression guard is the signature: two lists with the same
emptiness and different contents is no longer a constructible call.
recompute_formulas and aggregate_raw keep &[String] -- they walk the elements.
improvise-5jm5
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
|
||
|
|
7d25762725 |
test: the worker's dirty-marker test could not see the widget vanishing
Re-review finding, low severity and correct. The test asserted only `!document.dirty`, and `DocumentCache::default()` is already clean — so it could not tell "told it cleared" from "never told anything", and passed under an injection that made `document_update` always return `None`. It is the test that names the browser deployment, which is the one place nothing ever sends ClientFrame::Save, so it is the only guard on that path. It now records every marker the client is told and asserts `[true, false]` for the edit. The bootstrap seeds its own clean marker first, so the sequence is cleared after the handshake and the assertion is about the edit rather than about the connect. Verified failing under both injections: dropping the worker's re-projection gives `[true]`, and stopping the widget being projected at all gives `[]`. Declined, with the reason: a test for `GridLayout::record_cell_key` returning a key for a `RecordCol::Category` column. The injection the reviewer describes would make a coordinate column project `Derived` where a formula owns that coordinate — and the reviewer's own harder case established that a formula targeting a regular category SHOULD project `Derived` there, in both modes. So the injected behaviour is not clearly a fault, the exhaustive match already forces a decision on any new `RecordCol` variant, and a test would be pinning an answer nobody has argued for. Recorded as a known gap rather than guarded. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
24d9f61e35 |
fix: improvise-ir7l was not closed in records mode, and a save told no client
Two findings from the adversarial review of #51, both demonstrated before being fixed and both re-proved by injection afterwards. FINDING 1 — a formula-owned cell was still a writable Record in records mode, and my own guard test's written premise was false. "A formula target lives only in measure_item_names" does not hold: an item can be BOTH an explicit _Measure item and a formula target, and improvise-poia in this same PR made the `+` column that builds one a single keystroke away. Type `Profit` into `+` on a document that already has `Profit = Revenue - Cost`, and the column projects CellId::Record; `999` commits, is stored, and is then shadowed forever by formula_cache — the exact defect ir7l exists to close, in both front ends. The question is now asked of the record's REAL coordinate. GridLayout::record_cell_key is the one mapping from a records position to a model cell; cell_id and the new CmdContext::owning_formula both go through it. The synthetic (_Index, _Dim) key names no measure, which is why EditOrDrill's guard could not fire there. typing_into_a_records_formula_column_is_refused_on_the_key_path drives the reviewer's repro through App::handle_key with no test API. It exists because the projection half and the EditOrDrill half are separately breakable — injecting each alone fails a different assertion. FINDING 2 — DocumentCache::dirty never cleared on a client. Both save paths cleared model_state.dirty OUTSIDE a projection: Host::save returned only a possible Rejected, and Worker::on_frame built its frames and persisted after. So after the user's last keystroke a browser showed [+] on a saved document, indefinitely — which is the failure mode protocol.md's own z754 section names as the thing the design avoids. The z754 test that should have caught it called sessions.project_all by hand, which is exactly the call both production paths omit. Not a test comparing a thing with itself; a test exercising a call the deployment never makes. The new ones go through Host::handle and through the worker's real message loop. Also filed improvise-8ec3: the reviewer measured improvise-io's test binary at 3.7-5.2s where this machine reads 1.4-1.7s, so the 2s per-binary rule is breached on some machines — on main too — and the figure design-principles §7 pins is not reproducible. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
0f14b2a39e |
fix(protocol): a formula-owned cell projects CellId::Derived, not Record
improvise-ir7l. project::cell_id decided Record vs Aggregate purely on whether a regular category sat on Axis::None; it never asked whether the coordinate named a formula target. So a formula cell was a Record, render.rs::editable said true, and a write to it landed, was stored, and was then shadowed forever by formula_cache — not a revert, a permanent silent shadow, on the demo's front page. Rather than a second "but not really" flag beside CellId (the two-answers shape ADR 002 exists to forbid), a formula-owned cell now projects a different kind of identity. Derived carries the formula's target and no surrogate, so the write is unconstructible rather than refused. The predicate is Model::formula_for(&CellKey), one home, which already existed three times open-coded inside model/types.rs. The TUI lands in the same change: EditOrDrill calls the same predicate and refuses, or the two front ends diverge. Two orderings are load-bearing and tested: Derived is asked before the record branch (the coordinate is stored), and after the aggregate branch (gotcha 10 — an aggregating formula cell is still drillable). Two tests were passing while their names were false for the Profit column and are renamed and two-sided now. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
625f4acb80 |
docs(worker-server): move the browser-host argument to context/code/clients.md
The 121-line module doc on worker.rs, vfs.rs's seeding and flat-root rules, bindings.rs's async-seam argument and the crate lib.rs coverage statement now live in context/code/clients.md; the four bug narratives in the tests move to context/test-evidence/clients.md, keyed by test name. No behaviour change: comments only. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
f12a2d3517 |
docs: reconcile the docs with the code after wave 1
An independent review checked every factual claim in the docs against the code at |
||
|
|
22d43b2b30 |
Merge pull request #23 from fiddlerwoaroof/wave/a-grid-frame
CI / Test and lint (host) (push) Has been cancelled
CI / wasm32 check (browser bundle crates) (push) Has been cancelled
CI / Knowledge base consistency (push) Has been cancelled
CI / Browser harness (OPFS contract + shell) (push) Has been cancelled
CI / Browser bundle + static site (push) Has been cancelled
CI / Deploy to GitHub Pages (push) Has been cancelled
WU-A: the grid frame on the wire (improvise-anr9, improvise-s1xw absorbed, improvise-1125). row_labels/col_labels become one AxisFrame per axis carrying the five facts gotcha 44 enumerated, so the browser draws multi-level headers, repeat suppression, group spans and collapse indicators instead of Food/Alice. Includes the user's P1: a cell's identity is cats zipped with its tuple, and invalidate_relabelled compared tuples alone — so an axis swap left every CellDisplay and column total standing under a coordinate that no longer named them, and because grid_updates diffs against the framed mirror, the producer then sent nothing at all. A client wrong forever. |
||
|
|
22360d46e5 |
Merge main into wave/a-grid-frame
Two conflicts, both expected.
**`invalidate_relabelled` (improvise-hkt6, WU-K).** Both sides kept: the
signature and the item-tuple comparison are this branch's — `old` is an
`AxisFrame` now, and a group header appearing or collapsing must not blank
cells it did not rename — and the totals blanking and its whole argument are
main's. `grid_updates` still diffs the totals against the *framed* mirror,
which is what makes a renamed column whose total did not change send one;
`a_relabelled_column_drops_its_total`,
`a_relabelled_row_leaves_the_totals_alone` and
`a_renamed_column_whose_total_did_not_change_still_gets_its_total_back` all
pass against the new frame.
**`lib.rs`'s composition test.** Main's deletion taken:
|
||
|
|
819a165802 | Merge remote-tracking branch 'gh/main' into wave/c-host-and-save | ||
|
|
90ea26e7fa |
feat(command): one basis per frame, then the chain runs in order
The user has settled the contract, and it rejects the "at most one document
mutation per frame" rule this branch shipped. Verbatim: *"a sequence of
commands sent to the server should assume that it's played in order from
start to end and that each command sees the state after the previous command
runs"*, sent as *"a single transaction from a given base"*. Four clauses:
1. A frame states **one basis**, describing the base the client composed
against.
2. That basis is checked **once**, against that base, before anything runs.
3. Commands are applied **in order**, and command N sees the state command
N-1 left. Data dependence between them is expected, not an error.
4. **All or nothing.**
This commit lands clauses 1–3 and removes the rejected rule. **Clause 4 is
not implemented** and is documented as an open hole rather than a closed one;
the design note on PR #26 covers what rollback costs.
**Per-command basis checking is not merely awkward, it is incapable**, which
is the finding that makes clause 3 necessary rather than preferable. A client
cannot claim a counter for a record its own chain is about to create, because
`BasisCache::claim` filters to records the cache has been told about — so
`[AddItem("East"), SetCell(East)]`, the archetypal chain, is refused
`RecordGone`/`RecordUnstated` by a per-command check *in any order*. The base
describes what the client **saw**; the chain's internal coherence is the
transaction's job, not the basis check's.
So `CheckedBatch::against` answers the basis on the first command that
carries one and admits the rest through a private
`Checked::within_an_answered_frame`. Private is what stops it being a bypass:
from outside the module the only doors are still `Checked::against` and
`CheckedBatch::against`.
**Two things the rejected rule got wrong, kept as the lesson.** *Arity is not
a property of a variant* — a future `Command::RunScript` is one variant
performing unbounded mutations, so no count over the vocabulary can ever be
right. And *the transaction boundary was never the frame*: because one
command may already be a chain, clause 4 is needed inside a command as much
as across a frame of them, which is why the design note puts the mechanism at
`App::apply_effects`.
`mutates_the_document` is **deleted rather than kept**, which is a deliberate
deviation from the instruction to keep it: with the rule gone it has no
caller, and dead production code behind an `#[allow]` is worse than none. The
reason it was made exhaustive — a `matches!` answers `false` for a future
variant in silence — is preserved in the `CheckedBatch` doc, where clause 4
will read it when the classifier returns.
`Rejection::FrameNotAtomic` goes with the rule; nothing can produce it. The
taxonomies on `Rejection` and `ServerFrame::Rejected` return to two classes
and name the third that arrives with rollback.
Evidence, both faults injected:
- `a_data_dependent_chain_lands_in_order` — `[AddItem("East"), SetCell(East)]`
through a real `Host`. Restoring the per-command check fails it, which is
the whole point. It asserts **both** commands landed, because asserting
only the first would pass against a host that dropped the second.
- `a_frame_whose_later_mutation_fails_is_not_yet_undone` characterizes the
open hole with `[RemoveItem("North"), SetCell(North)]`: the removal lands,
the write resolves to nothing and `model_effects` returns an **empty**
batch, and the frame is reported accepted having applied half of itself.
Both the partial application and the silence are open. **This test inverts
when clause 4 lands**; until then its job is to fail loudly if anyone
believes the hole is closed.
- The worker's `a_partly_refused_batch_…` fixture moved its injected
staleness to the **first** command, since a later one no longer carries an
answer of its own. Removing the injection still fails it.
The hole is unreachable from any client in this workspace — the only producer
of a multi-command frame is `keys::commit`, one mutation among view commands
— which is what makes it safe to document rather than rush.
Merged `main` (
|
||
|
|
affb9a1812 |
fix(harness): make the two expected-failures deterministic, and unstale 4 docs
The browser job **failed in CI** on the previous head while passing locally, with both `test.fail` tests reporting "expected to fail, but passed". That is the worst possible outcome for an expected-failure: it is a check that is green when the bug is present and red when it is absent, so a flaky one inverts CI's meaning rather than merely being noisy. Copilot independently flagged the same test from a different direction on the same head. Two timing assumptions, both wrong off my machine: **A closed port is not a deterministic "refused before open".** Port 9's `close` timing is a property of the host's network stack, and on the GitHub runner it did not arrive inside the window, so `#status` still read "cannot reach" and the assertion passed. Replaced with the harness's **own http server**: it accepts the TCP connection and then fails the WebSocket upgrade, so `error` and `close` both fire promptly, everywhere. The page cannot tell the two apart and neither can `web/app.js` — in both cases the socket never opened. **A timer does not order a close after an open.** The watchdog test's mock closed 300ms in; on a slow host that can land before the page sees `open`, in which case `armWatchdog` never runs and the test passes having exercised nothing. Both mocks now close on the page's **first frame**, which the page sends only after `transport.ready` resolves — so "opened, then dropped" is true by construction rather than by luck. And the setup assertions now assert the **precondition of the bug** rather than that something happened: the m18f test polls the recorded `#status` history for "cannot reach" before enabling `test.fail`, because a bare non-empty check is already satisfied by "connecting to …" (Copilot's exact point). Verified three ways: `--repeat-each=3` green; breaking `paintStatus` gives 2 real failures, not expected ones; simulating PR #21's fix still flips the m18f guard to "expected to fail, but passed". **Four checked-in explanations this branch made false**, also Copilot's, and gotcha 48's third rule for the second time in this PR — when a fact is stated in more than one place, changing it is a grep, not an edit: - `worker-server/src/lib.rs` said `storage_contract` "has still never been run" against `OpfsFs` and that browser verification is deferred. - `bindings.rs` said it is "the only part of this crate that no test in the repository can reach". Still true of the shim, for a narrower reason now worth stating: the harness drives `OpfsFs` directly and never instantiates it. - `vfs.rs::holds_a_document` and its test both said `attach` indexes the zero-byte entry it creates. It no longer does, which is the whole of the fix three commits ago — so the check is now belt and braces rather than the mechanism, and says so. `web/app.js` and `web/worker.js` carry the same stale "untested JavaScript" claim and are **not this unit's files**; reported for the orchestrator rather than edited across the boundary. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
436faf8d1c |
fix: SwitchView is a document mutation, and a save frame is not retried
Copilot's third round of PR #26, and the first finding is the **third** time the same mistake has been caught here: an argument that holds for the command that came to mind. **1. `SwitchView` counts.** I excluded it on the grounds that it carries no basis and a record surrogate is view-independent, so a switch cannot invalidate an edit beside it. That is true of `SetCell` and `ClearCell` — and false of *every other* model command, because `TransposeAxes`, `SetAxis`, `HideItem`, `SetNumberFormat` and their siblings all act on the **active view**. So `[SwitchView("Second"), TransposeAxes]` passed both checks and then transposed `Second` rather than the view the transpose was composed against. The rule is now "at most one **document mutation** per frame", where `mutates_the_document` is the two `Command` variants `Sessions::apply` routes away from the mirror. Deliberately the *document* question rather than "does it carry a basis", which is exactly the distinction I got wrong. No client here batches a `SwitchView` with anything, so it costs nothing. **2. An explicit `ClientFrame::Save` is not also auto-persisted.** A failed write leaves `dirty` set — deliberately, so the next frame retries — and the worker's automatic trigger runs after *every* frame. A save frame therefore attempted the same write twice. On a permanent failure that is duplicate I/O; on a transient one it is worse, because the answer was composed from the first attempt, so the client was told `SaveFailed` about a document that was now on disk. A per-frame question is the right shape here precisely because the automatic write exists for edits **nobody asked** to save. **3.** `Rejection`'s summary line now says it answers a *frame*, since two of its three classes are not refused commands at all. Evidence, both faults injected: - `a_frame_carrying_two_document_mutations_is_refused_whole` gains a third shape. Dropping `SwitchView` from `mutates_the_document` fails it. - `an_explicit_save_is_not_retried_by_the_automatic_write` asserts the **number of write attempts**, which took a counting, always-failing `Storage`. Both properties are load-bearing: "it was refused" is true whether the write happened once or twice, and a *successful* save clears `dirty` so the redundant attempt never happens — the bug is invisible from both the refusal and the happy path. Restoring the retry gives 2 where the test requires 1. `a_refused_batch_leaves_the_commands_before_it_unapplied` now leads with `SetSelected` rather than `SwitchView`, which the new rule would refuse first. That is not a concession: a view command is the shape a real frame has, and it is still observable in the asker's mirror. 1208 passing; clippy, fmt and the wasm gate clean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
373965c596 |
fix(protocol): saving is a frame, and a frame may not replace its own basis
Copilot's review of PR #26 found two real defects, both of which this branch
introduced, and both of which are the same mistake in different clothes: an
argument that held for the case I tested and not for the case I did not.
**1. `Command::Save` put a runtime-fallible operation inside a transactional
batch.** `Host::commands` guarantees a frame is applied whole or not at all,
but `apply_save` could still answer `Applied::Rejected { SaveFailed }` from
*inside* that loop — so `[Save, SetCell]` reported a failed write and then
applied the edit anyway, and `[SetCell, Save]` reported "refused" for a
frame that had fully applied. A client reading `ServerFrame::Rejected` could
not tell which. My own doc on `apply_checked` said it "cannot return
`Applied::Rejected`", and that was simply false.
**Saving is now `ClientFrame::Save`, not `Command::Save`.** That is the
honest home for it and I would argue it even without the bug: a `Command` is
something the shared state folds, and saving mutates no shared state and no
client's slice — `reduce_view` had nothing to fold, and `Checked` had no
basis to answer for, so both arms were shims. As a frame it sits beside
`ClientFrame::Viewport`, which is the other session input no `Command`
carries, and **it cannot be composed with a mutation at all**: the batching
question stops being a rule and becomes unconstructible
(`doctrine(an_invariant_is_made_unrepresentable_not_validated)`). The user's
ruling was "add Save to the wire"; it is on the wire, one layer out.
`Host::save` answers both refusals directly, so `Checked` goes back to being
about ADR 001 staleness and nothing else. The behaviour a client sees is
unchanged: `NoDocumentPath` decided before anything runs, `SaveFailed` read
off `App::last_batch_aborted` after a batch that is exactly `[Save]`, both
addressed to the asker alone, success silent.
**2. The documented "residue" was under-stated, and the argument only covered
the command I had tested.** A frame whose earlier command replaces the
document leaves its later ones checked against the outgoing one. I argued
that was harmless because a `SetCell` names its cell by surrogate (ADR 002)
and an outgoing surrogate resolves to nothing in the incoming document. True
— and it covers exactly one command. `TransposeAxes` names no surrogate, so
`[LoadModel, TransposeAxes]` passed every check against the outgoing `App`
and then reshaped a freshly loaded workbook under a basis describing the
previous one: precisely the cross-document mutation the generation tier
exists to reject.
Re-checking the remainder is not available as a fix — it rejects *after* the
load has landed, which is the partial application the batch check exists to
prevent. So `CheckedBatch::against` **refuses the frame whole**, with
`Rejection::DocumentReplacedMidFrame`. `replaces_the_document` is an
exhaustive match beside `records_named`, so a new `WholeWorkbook` variant
fails the build until it classifies itself.
This is the one place here where the *variant* is the right question rather
than "did the thing move" (gotchas 24, 39): a client composing a frame that
**may** invalidate its own basis half way through has composed an incoherent
claim whether or not the load succeeds.
**3. Copilot also caught three stale documents**, two already fixed in
|
||
|
|
748db12f9f |
review: close the /code-review backlog on this branch
Four findings from running the repo's own review skill over the branch, all of which are gaps in evidence or in stated argument rather than wrong lines. **A doctrine was cited by the wrong name.** The two-part batch test cited `doctrine(an_order_test_needs_two_histories)`, which is about telling "sorted correctly" from "hashed that way today". The doctrine it actually rests on is `a_helper_that_builds_the_world_the_code_wants_tests_nothing`: *pair a refusal test with an accepting one, or the refusal can pass by refusing everything*. Corrected, because a doctrine name in a doc comment is a pointer somebody will follow. **The worker's persist trigger had no guard on the fact it now depends on.** `touches_document` used to name `SwitchView` explicitly; it now asks the document, which only works because `effect::SwitchView` declares `Model(LayoutGeometry)`. Reclassifying it `ViewOnly` would make a browser silently stop writing view switches back — `Workbook::active_view` persists as the `Initial View` line — and nothing would have failed. `a_wire_view_switch_marks_the_document_dirty` pins it; injecting that reclassification fails it, and fails nothing else on the worker side, which is exactly the silence it exists to break. **The dirty raise over-approximates and did not say so.** An effect that declares a document change and turns out to do nothing — a `LoadModel` that cannot read its path — leaves the flag raised. That is the same direction and the same reason as `layout_version`, which bumps for a `LayoutGeometry` effect that no-ops, and as `Dirty::of`. The cost is one redundant write; the other direction costs an edit. Now written next to the raise. **Two arguments existed only in a commit message.** `Sessions::apply`'s doc enumerates which commands do not stay on the mirror and did not mention `Command::Save` — a third kind again: no document half, but a storage half. And the worker's retry-on-failed-write behaviour is new (a permanently pathless worker now retries every frame) and is harmless for a reason worth stating: the only thing a failed `Save` produces is a status line, and nothing in a worker renders one. Also checked and found clean: layering (`forbidden/2` — nothing new crosses a crate boundary), `Option`/`Result` kept at the edges (`CheckedBatch::against` returns one `Result` at the door and the commands inside it are concrete), exhaustive matches (`reduce_view` names `Command::Save` rather than widening a wildcard), and no command reaching for mutable state. Two `kb.pl` facts and one repo-map gotcha are now stale as a result of this branch; they are orchestrator-owned and are reported as text in the hand-back rather than edited here. 1197 passing; clippy, fmt and the wasm gate clean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
301a74821a |
feat(protocol): the grid's frame on the wire, as AxisFrame
`GridCache::row_labels`/`col_labels` were `Vec<String>` built by `GridLayout::row_label`, which slash-joins a multi-level tuple — the exact rendering `two_row_categories_produce_cross_product_labels` asserts must not appear. So the protocol carried a form of the labels the TUI itself considered wrong, and `improvise-wasm-client/src/render.rs` drew precisely that form: the browser showed `Food/Alice` where the TUI showed two header columns, with no groups and no collapse indicators (gotcha 44). Both fields become one `AxisFrame` per axis, carrying the five facts gotcha 44 enumerates: the categories (hence the header levels), the label *tuples* and the inline group headers, the group a position sits in, and each group's collapse state. `record_col` stays off — session-local, keys pending drill edits, correct to keep off. It is one type and not a `row_levels` beside `row_labels`, because two answers to "what are the labels" is the disease (improvise-edp). The rules that used to be written twice are methods on it and are now called from both front ends: `shows_level` (repeat suppression, improvise-jjx's fix), `group_of`, `body_from`, and `header_widths` — the row-header width rule, which existed twice already, inline in `render_grid` and again in `viewport::compute_row_header_width` (improvise-1125). `GridWidget` loses its `&View`: nothing in it reads the view any more. Evidence. `the_whole_frame_is_unchanged` — the exact-buffer characterization over a grid with row groups, column groups, a page axis, multi-level headers and a total row — passes byte for byte, so the TUI is unchanged. The new `tests/grid_frame_agreement.rs` drives the widget into a ratatui buffer and the browser client through a `Snapshot` of the *same* cache, and compares what each draws; re-introducing the slash-join on either side was injected and watched to fail before the test was kept. `tests/web_demo_model.rs::the_web_demo_has_one_category_per_visible_axis` is deleted: it guarded the gap this closes, and a consolidation that keeps its compatibility test is unfinished. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
85bbe97636 |
fix(host): a frame is answered whole before any of it is applied (improvise-955n)
ADR 001 is emphatic that a stale batch is never applied, **not even partially**, and improvise-ondj went to the trouble of a *type* to make the unchecked path unconstructible. But `Checked` is **per command**, and a `ClientFrame::Commands` is a sequence of them: `Host::commands` applied in order and abandoned at the first rejection, so a frame whose second command was refused had already applied its first. The guarantee was true of a single command and false of a batch — exactly one level above the gap improvise-ondj closed, and answered the same way, with a type rather than a line at the top of a function. `CheckedBatch::against` answers for every command in a frame, and there is no way to reach the commands without it. **The question the ticket said to answer first**, and the reason it was declined rather than patched on PR #13: *what does a batch's basis mean when its later commands were composed against its earlier ones' effects?* A client resolving one keypress emits `SetSelected` plus the offsets it computed from the selection it just set. The answer is that there is **one** basis and it describes the document the client was looking at when it composed the frame. That is not a choice; it is the only claim a client can construct. `BasisCache::claim` hands back what the server last *published*, and the server publishes after a batch and never during one, so no client can hold a basis describing the document as it would be halfway through its own frame. Answering every command against the pre-batch state is therefore not a relaxation of ADR 001 — it is the only reading under which the claims that can actually exist mean what they say. Re-asking per command would have been the *wrong* answer in the other direction: it rejects a later command for staleness its own siblings caused, which is a race nobody lost. A corollary that looks like a loophole and is not: a frame cannot smuggle in a second write to one record. The second `SetCell`'s claim states that record's *pre-batch* counter, which is still what is compared, so the pair is accepted as one composed intent — last-writer-wins within a frame is what the client asked for. **The residue**, stated rather than hidden and filed for the orchestrator: a batch whose earlier command *replaces the document* leaves its later commands checked against the outgoing one. It does not corrupt today, and the reason is not the basis check: a `SetCell` names its cell by surrogate (ADR 002), and a surrogate minted against the outgoing document does not resolve in the incoming one, so `model_effects` produces an empty batch. That is a property of `resolve_cell`, which is why it gets a test of its own rather than a comment — `a_batch_that_replaces_the_document_leaves_its_later_commands_unresolved` would go quiet if `resolve_cell` ever got looser. **The second-order effect the ticket recorded is closed twice over.** `Worker::on_frame` skipped `persist()` when any frame in the answer was `Rejected`, so a batch that *did* apply a geometry command before being refused left the document moved in memory and never written back — disk and worker disagreeing, silently. Nothing applies partially any more, and the persist trigger became the document's own dirtiness in the previous commit, so either change alone would close it. `a_partly_refused_batch_does_not_leave_disk_and_memory_disagreeing` fails only when *both* old behaviours are restored, which is how it was verified. Mechanically: `Sessions::apply_checked` is the door that takes a proof rather than asking for one, `Sessions::apply` is now check-then-`apply_checked` and stays as the single-command convenience its tests use, and `Checked::command` hands back a `&'a Command` — not one borrowed from `&self` — so a caller can dispatch on the variant and still move the proof into what it dispatched to. Evidence, faults injected and watched to fail: - `a_refused_batch_leaves_the_commands_before_it_unapplied` — written first and failing on the old code. **Two histories** (`doctrine(an_order_test_needs_two_histories)`): the same leading `SwitchView` followed by a *current* basis must land, or a `SwitchView` that simply never worked would clear the first assertion and prove nothing. Its fixture gives the document a second view for the same reason. Its stale claim is built by `caught_up` and then has **one** tier bumped, so the refusal can only be about the tier that moved — a first draft that hand-built the basis was stale in two tiers at once and answered `RecordUnstated` where it asserted `LayoutMoved`. - `a_batch_is_abandoned_at_the_first_rejection` is kept: the two halves (nothing after, nothing before) fail independently. 1196 passing, from a 1189 baseline. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
0b298d7f25 |
fix(command): raise dirtiness where the effect is classified (improvise-gv9r)
A client's edit left the authoritative document reporting **clean**, on every transport. `reduce::model_effects` maps `ModelCommand::SetCell` to `effect::SetCell`, which writes the cell and never touched `model_state.dirty`; dirtiness was a *separate* `MarkDirty` effect that only the command/key path composed. So "save if dirty" was a trigger that never fired — a host built on it loses every client edit, silently — and anything else that read the flag about a client-edited document got the wrong answer. The interesting part is not the missing line, it is **why** it was missing. improvise-kmgs derived `ModelCommand` from the effects **one at a time**, and per element that derivation is right: `SetCell` really is the effect a `SetCell` command names. What it could not see is that the key path never emits `SetCell` alone — it emits the *pair*. **A derivation correct per element and wrong for a set.** The pairing existed nowhere except at 17 hand-written call sites, which is exactly the kind of obligation this codebase makes unrepresentable rather than validating. So dirtiness now comes from `App::apply_effects`, off the same `EffectKind` the `Dirty` and the two lower basis tiers are already read from (gotcha 40): one classification, three readers, no second table to drift from. There is no longer any way to compose a document change that omits it — on the wire, from a keypress, or from a host acting on its own behalf. `MarkDirty`, `effect::mark_dirty()` and every call site are **deleted**; leaving them would have been a second answer to one question, and the next reader would have believed the call sites were the mechanism. All three `ModelChange` levels raise it, `WholeWorkbook` included. A replaced document is not the document `file_path` names — `LoadModel` does not move `file_path` — so in both the wizard and the load case the bytes on disk and the bytes in memory have parted company, which is all `dirty` claims. `AcceptWizardModel`'s own `dirty = true` line goes for the same reason the call sites do. **The worker's persist trigger moves with it.** `touches_document` enumerated command variants *because* the flag was unusable, and it said so. With the flag trustworthy, the trigger is the observation — the same choice the cursor reset makes by watching `cursor_epoch` instead of matching on `CycleAxis` (gotcha 24), and the rebase by watching `document_generation` instead of matching on `LoadModel` (gotcha 39). A table of variants is a second description of which commands change the document, free to disagree. It is also strictly tighter: a refused batch changes nothing and so raises nothing, which retires the "was anything rejected" guard beside it. Evidence: - `a_wire_model_command_marks_the_document_dirty` (`ui/reduce.rs`) — written first and watched to fail on the old code. It asserts all three `ModelChange` levels separately, so an effect reached by only one of them cannot pass on a sibling's evidence. - `a_view_only_batch_leaves_the_document_clean` (`ui/app.rs`) — the negative arm, without which "raise it unconditionally" would pass. It is aimed at `apply_effects` and **not** at a `Command`, because a first draft written against the session door was **vacuous**: a view-slice command is folded straight into `ViewState` and never reaches the loop at all, so it passed with the raise made unconditional. Injecting that fault is how the vacuity was found; against the effect batch the same injection fails it. - The worker's `an_edit_is_persisted_without_the_client_asking_to_save` fails when the new trigger is disabled. Three `cmd/cell.rs` tests counted the dropped companion effect and are adjusted; two are renamed, since they no longer assert anything about dirty. 1193 passing, from a 1189 baseline. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
37d06fcc13 |
fix(opfs): list answers in the same path shape as every other backend
improvise-vhcz. `OpfsFs::list` returned bare entry names where `MemoryFs`
and `PhysicalFs` return `dir`-joined ones — paths a caller can hand
straight back to `read`. At `Path::new("")` the two shapes coincide, and
the flat root is the only `dir` the worker ever passes, so the
disagreement was invisible: a worker that organised documents under
`docs/` would have got a **full picker in every host test and an empty one
in a browser**, with nothing saying why.
The ticket asked which of the doc and the impl was wrong. It is the impl.
The trait already says "backends that are not trees treat `dir` as a name
prefix", which is exactly what this backend needs to do and was not doing
— it ignored `dir` on the way out as well as on the way in.
`dir` stays decoration rather than becoming a filter, because the backend
is flat by construction: `entry_name` reduces every path to its file name,
and an OPFS entry name may not contain a separator at all, so
`docs/a.improv` and `a.improv` are the same entry. Joining makes `list`
agree with `read`/`write`/`delete` about which names exist; filtering
would make it disagree.
`vfs.rs`'s "the root is flat" section is rewritten rather than deleted:
the flat root is still load-bearing, but for the collapse of `dir`, not
for a `list` shape that no longer differs.
No host test can demonstrate this — `OpfsFs` is `cfg(target_arch =
"wasm32")` and has never been run anywhere. The regression guard is the
browser harness in the next commit, which runs `storage_contract` against
`OpfsFs` at `docs/` as well as at the root; reverting this line fails it
with `lists the one document`.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
92a3d3e179 |
feat(io): a shared backend is a backend, so the async seam survives boxing
improvise-hmws. `OpfsFs` keeps `open`/`attach`/`refresh`/`commit_deletes` off the `Storage` trait so the trait can stay synchronous — gotcha 28, and not negotiable, because `createSyncAccessHandle` really does yield a blocking handle. But `App::storage` is a `Box<dyn Storage>`, so handing the backend to the `App` puts all four permanently out of reach: the caller has to hold the concrete backend *and* give the `App` a `Storage`, which means sharing one. The ticket offered two fixes — ship a forwarding newtype from `improvise-io`, or have `OpfsFs` hand out an attachable handle. Neither is needed, and the reason is worth stating: `impl Storage for Rc<OpfsFs>` is refused in `improvise-worker-server` because *there* both types are foreign. In `improvise-io` the trait is local, so the impl is simply allowed. It is blanket — `impl<T: Storage + ?Sized> Storage for Rc<T>` — because nothing about it is OPFS-shaped; it is the general statement that a shared handle to a backend is usable as that backend. `Rc` and not `Arc` because the one caller is a Web Worker and wasm32 has no other threads. So `bindings::Shared` and its `_shim_is_a_storage` canary are deleted rather than moved: there is no newtype to ship and no second copy to keep. The seam now costs an `Rc::clone`. Evidence: `a_shared_backend_is_a_backend_and_a_clone_is_the_same_one`. The regression guard is the *signature* — it does not compile without the impl — so what the body asserts is the part a hand-written newtype could still get wrong: that the clone the `App` holds and the handle the message loop keeps are one backend. Proved discriminating by making the forwarded `write` a no-op and watching it fail. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
66df7056e3 |
fix(worker): give report() its own doc, instead of stealing start()'s
I inserted report() between start()'s fifteen-line doc comment and start itself, so rustdoc attached the whole of it -- 'Boot the worker over OPFS', the out-of-band naming argument, the reason the document is an argument rather than a fourth ClientFrame -- to a two-line JsError shim, and left the exported entry point undocumented. Compiles either way, which is why nothing caught it: a doc comment is legal on any item, so the only symptom is rustdoc nobody regenerated. Found by the PR agent reviewing PR #17. Documentation placement only, no behaviour change, so no test. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EoW5BB87ZZdm96SvBTWawH |
||
|
|
56c1c77141 |
fix(worker): attach creates the entry, so listing it is not proof of a document
The first browser run of the deployed page: 'Cannot open demo.improv'. OpfsFs::attach must create the file to obtain a sync access handle for a document that does not exist yet, and it indexes what it created. boot attaches BEFORE it opens, so on a fresh origin documents() already contained demo.improv as a ZERO-BYTE entry, vfs::open read that as 'already there', skipped the seed, and handed load_from an empty buffer. The question vfs::open needs to ask is not 'is there an entry' -- which is what list answers -- but 'is there something to load'. holds_a_document asks that. Why no host test could see it: MemoryFs has no attach. It is not a Storage method, so the seeding tests passed for a reason that cannot hold in a browser. The new test builds the empty entry by hand to stand in for what attach does, and fails on the old code. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EoW5BB87ZZdm96SvBTWawH |
||
|
|
62bc9bab51 |
fix(worker): report an error with its cause, not just its label
CI / Test and lint (host) (push) Has been cancelled
CI / wasm32 check (browser bundle crates) (push) Has been cancelled
CI / Knowledge base consistency (push) Has been cancelled
CI / Browser bundle + static site (push) Has been cancelled
CI / Deploy to GitHub Pages (push) Has been cancelled
The first real browser run of the deployed page produced 'Cannot open
demo.improv' and nothing else. That string is the CONTEXT vfs::open attaches;
anyhow's Display prints only the outermost context, so mapping through
e.to_string() kept the label and discarded the Storage or parse failure
underneath.
The worst possible place for it: bindings.rs is wasm-gated and is the one
module no test here can reach, so a devtools console is the only instrument
pointed at it -- and it was the code path discarding its own diagnostics.
report() renders with {:#}, which walks the chain. It lives in lib.rs rather
than bindings.rs so it can be tested at all. The test is written against the
old rendering and pins anyhow's Display as the trap, so the helper stops
being needed if that ever changes.
This does not fix the underlying failure; it makes the next reload name it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EoW5BB87ZZdm96SvBTWawH
|
||
|
|
12febcf73b |
Merge: the client resolves its own cursors (improvise-bpq2)
Keys become commands in the browser; the server must not know the keymap. What made it urgent is not latency. Host::handle answers ClientFrame::Key with an explicit empty vec, so an upstream key is a BLACK HOLE, not a slow path -- every KeyOutcome::Upstream names a binding that does nothing at all in a browser. And a_key_resolved_on_the_host_moves_state_no_client_can_hear still holds: bpq2 closes the divergence by AVOIDING host-side resolution, not by contradicting the property. Three costs. GridExtent on GridCache -- the whole grid, of which the labels are one window -- because a viewport-indexed cache cannot tell 'the last visible row' from 'the last row'. The window is the client's OWN fact, recorded from what it announced, because row_labels.len() is the window CLIPPED and so differs exactly at the bottom edge where scroll arithmetic matters. And ClientFrame::Commands replaces the singleton, because one cursor key is SetSelected plus the offsets viewport_effects returns. Two corrections to the ticket's own gap list: panel cursor movement was never a gap (improvise-edp already made flatten_cat_tree the one home both ends call), and the unmodified-characters rule was MIS-SCOPED rather than wrong. Each resolver mirrors its Cmd line for line, including where siblings disagree about a no-op move. Tidying that into one rule would make the client a second derivation, which is what lockstep testing exists to rule out. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EoW5BB87ZZdm96SvBTWawH |
||
|
|
43422d4d21 |
docs(worker): the Key arm's emptiness is now the answer, not the gap
`a_key_resolved_on_the_host_moves_state_no_client_can_hear` still holds and is still the reason — nothing in the projection stream carries the host's own `ViewState`. What changed is the conclusion drawn from it: improvise-bpq2 did not close the divergence by deriving `Command`s from a `ViewState` delta, it moved resolution to the client, so a key the client can answer never arrives as a `Key` frame at all. Two doc comments and an assertion message that read as "not built yet" now say what is settled and what genuinely remains. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EoW5BB87ZZdm96SvBTWawH |
||
|
|
c848d93255 |
refactor(protocol): ClientFrame batches, because one keypress is several commands
A cursor key resolved on a client is `SetSelected` plus the scroll
offsets that follow it (`viewport_effects`), and a host that folded the
selection but not the offset projects that client the wrong window. They
must arrive whole, so `ClientFrame::Command(Command)` becomes
`Commands(Vec<Command>)` — the same argument `ServerFrame::Commands`
already carries, and the same conclusion: `Commands(vec![c])` expresses
everything a singleton variant could, so there is one variant, not two.
`ClientFrame::command(c)` is the constructor every existing call site now
spells.
`Host::commands` applies a batch in order and abandons it at the first
rejection: the commands after a refused one were composed against a
picture the document has just contradicted, so applying them invents a
basis nobody claimed. That does leave a hole ADR 001's "never applied,
not even partially" does not have — a refused batch keeps its earlier
commands — which no client in this workspace can reach and which is
stated on the variant rather than hidden.
`the_retired_spellings_do_not_parse` grows the client half. It is the
sharper case of the three: the singleton was this crate's own spelling
until this commit, and `{"Command":{...}}` differs from
`{"Commands":[{...}]}` by a pair of brackets — exactly what a permissive
alias would paper over. Both new host tests had their fault injected.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EoW5BB87ZZdm96SvBTWawH
|
||
|
|
a550201fae |
feat(web): the page spawns the worker, opens a demo, and reads a key
Three pieces of the standalone deployment, all in web/.
improvise-djm — app.js picks its peer instead of assuming a socket. The
wasm bundle is the same artifact either way (improvise-xks6), so the
difference is confined to one factory: `?ws=` selects the websocket, its
absence spawns `new Worker('./worker.js?doc=&seed=', {type:'module'})`.
The websocket path is KEPT, because improvise-ws-server is how a
TUI-hosted document is shared and nothing else can reach it; the presence
of the parameter is the whole selection mechanism, since a second knob
naming the same fact is a second thing to get wrong.
improvise-bck — web/demo.improv, built against an assertion rather than
by flipping an axis. examples/demo.improv leaves Customer/Date on None,
so every cell is an Aggregate and nothing is editable. The new demo puts
exactly one category on each visible axis (Product on row, _Measure on
column, Quarter on page) and carries Profit = Revenue - Cost, so a
visitor sees the formula move beside the cell they typed in, with no
keystroke and no scrolling. Two tests hold it: tests/web_demo_model.rs
projects the whole grid through grid_of and asserts every CellId is
Record (ADR 002 — that is what `editable` means), and post_message.rs
drives the shipped bytes through the real seed -> VFS -> open -> Snapshot
chain. The first was proved discriminating by pointing it at
examples/demo.improv, where it names the aggregate at (0,0).
One category per axis is asserted too: GridCache slash-joins a
multi-level tuple (gotcha 44), so a second category on an axis puts that
known gap on the front page.
improvise-hwpi — all four. A keydown listener on window feeds
Client::on_key_event, so the classifier is reachable at last; a frame
that will not fold now writes to #status instead of only the console; the
socket has an error listener, so "not running" no longer reads like
"still connecting"; and close() has a re-entry guard. Plus one the ticket
could not have predicted: a worker whose start() rejects is caught inside
worker.js and fires no `error` on the main thread, so the same silence
was about to be rebuilt in the new path — a first-frame watchdog covers
both deployments, because "the peer never answered" is the one condition
that means the same thing on each.
Verified: cargo test --workspace 1159 passing (1155 + 4), clippy clean,
fmt clean, actionlint clean, ./web/build.sh end to end (345 KB / 123 KB
gzipped client, 1.33 MB / 498 KB gzipped worker), every call present in
the generated .d.ts with the right arity, every relative reference in the
assembled site resolving. NOT verified: there is no browser here. No line
of JavaScript in this commit has ever run.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EoW5BB87ZZdm96SvBTWawH
|
||
|
|
bdce1636bb |
feat(worker-server): the whole App in a Web Worker, over postMessage + OPFS
improvise-9x6: the server half of the standalone static-web deployment (epic improvise-tm6). `improvise-worker-server` hosts the authoritative `App`, the command pipeline, the projection layer and VFS persistence behind `self.onmessage`/`self.postMessage`. It is small because everything it needs already existed: the frames are `improvise_protocol::frame`'s (improvise-xks6), the session loop is `Host` (previous commit), the basis check is `Sessions::apply`/`Checked` (improvise-ondj), and persistence is `Storage` + `OpfsFs` (improvise-6mq, i34). What is genuinely new is three things. `vfs.rs` — how a document gets INTO a browser's empty filesystem. Loading is unchanged: it is `persistence::load_from` on a `Storage`, the same call the native binary makes. Seeding is the new problem, and it is a `Storage::write`, so a fetched demo, an upload and a saved document take the same parse path and a seed never clobbers an edit. `list` is the document picker a browser has no OS file manager for. `worker.rs` — the loop: one port, one client, the `Viewport` handshake (dropped rather than closed, since there is no connection to close), and the filter from `Addressed`. Every decision is here and every test points at it. `bindings.rs` — `cfg(wasm32)`, decision-free, and the only part no test here can reach: it awaits `OpfsFs::open`/`attach`/`refresh`/`commit_deletes` and forwards strings. Two gaps found and reported rather than papered over. No client on any transport can ask its host to save — `effect::Save` is `ViewOnly`, so the wire vocabulary derived from the model effects cannot name it — and a wire `ModelCommand::SetCell` never sets `model_state.dirty`, because dirtiness is raised by a separate `MarkDirty` effect the key path emits. So the worker persists on its own behalf, keyed off the command variant rather than off a flag that is never set; `touches_document` carries the argument. `ClientFrame::Key` is still answered with nothing. A worker CAN call `reduce_key` — it holds the `App` — but nothing in the projection stream carries the host's own `ViewState`, so a half-wired arm diverges rather than under-delivers. `a_key_resolved_on_the_host_moves_state_no_client_can_hear` demonstrates it against a real `App` and `Sessions` (improvise-bpq2). 22 tests, all host-side: 16 in the crate and 6 in `tests/post_message.rs`, which drives the worker with nothing but the JSON a `MessagePort` carries. Bundle: 1,334,892 bytes raw, 496,965 gzipped — 485 KB against a 3 MB budget. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EoW5BB87ZZdm96SvBTWawH |