Repository navigation
App kit - #11
Merged
Merged
App kit#11
Conversation
…e-deps craft-cloud-api (crates/api) holds the IDs, names, limits, the agent's local API and the error codes and device bodies, with no postcard. craft-cloud-proto depends on it and re-exports it under the same paths, so the server and the agent barely change. craft-cloud-app now depends on craft-cloud-api alone: cargo tree -p craft-cloud-app -e normal lists no postcard. The live limits apps see (update, snapshot, presence) and a new blob limit move into craft_cloud_api::limits. The local API gains the /v1/blobs paths, their answer type and codes (blob_not_found, and agent_too_old, which the client reports when an older agent doesn't know a call), and the app crate a blocking blobs client. cargo xtask suite-deps <lock files...> lists the crates craft-cloud-kit, craft-cloud-egui and craft-cloud-app pull in that none of the given lock files has, split into Loro's own and the rest, and fails on the rest. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RC1oqfy9kZznj94RjMojyz
craft-cloud-kit (crates/kit) ties an app's model to a Loro document and the agent. An app implements LiveDocument (mappings, apply_remote, normalise, refresh_derived); Session::open opens a file by path, importing it from the app's model when it was never live; Session::local_change(old, new) writes the difference as commits with this machine's origin, one undo step, cut before a commit's estimated export passes 512 KiB (and each export cut by counter range if it is still over); Session::poll takes what came, never blocks, and never applies while the app holds remote changes back (a drag, an IME composition). The kit always runs normalise (writing back what it repaired) and refresh_derived before handing the model over. A newer schema opens read-only. A dropped agent leaves the model editable: changes queue in memory up to 64 MiB, then the session is stalled; it reopens by itself off the UI thread with a growing pause and sends what the agent's copy lacks. Presence wraps the app's bytes with the peer. craft-cloud-app's Document gains a waker (egui's request_repaint) and a save call; the local API a Saved request (appended). The agent serves /v1/blobs: a put is kept on its disk under the manifest's name with an upload mark, and goes up through the chunk API now or later; a get comes from the disk (checked) or is downloaded and kept. craft_cloud_app::fake (feature fake) serves /v1/status, /v1/docs and /v1/blobs in-process from memory; fakes made from one Hub are machines of one account, with online/offline, restarts and an old-agent mode. Tests: the session on two fakes (import and join, read-only, a 5 MB change arriving whole and undone in one step, reconnect, a restart offline with unsent edits, the 64 MiB cap, a remote change during a gesture, presence, blobs and agent_too_old), the split at its edge, the fake itself, and the agent's blobs between two real agents (with an offline put). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RC1oqfy9kZznj94RjMojyz
Tree (LoroTree with a meta map per node, the element ID under $id, children ordered by fractional index; unchanged subtrees skipped by key, only what is out of order moved), rich text (one LoroText, character attributes as marks expanding after, paragraph attributes as marks on each break that never expand, the last paragraph's beside it; common prefix and suffix, then run by run), table (movable row and column lists, a cell map by row:column, the app's changed-cell hint when it has one), timeline (a movable track list, items and keyframes in maps by ID, child maps mergeable), opaque values (last writer wins). Each reports what a remote change touched, by part, and reads back through a view. Borrowed sources are sources (impls for &T). Tests, over a model with every shape and random edits made the way an app makes them (copy-on-write, shared by Arc): every shape round-trips (model, Loro, model) and merges (two replicas with random edits end equal and valid), and unchanged parts write nothing (equal content in new allocations writes nothing; a keystroke, a rename, a cell, a setting each write one op). Then the specific cases: marks across a paragraph split and a join made while the other machine formats the same words, typing at one spot at once, rows inserted at once (and a row deleted while its cell is edited), moves that would make a cycle, keyframes added to one item at once (and an item trimmed while moved to another track). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RC1oqfy9kZznj94RjMojyz
Session::undo and redo go through Loro's UndoManager (one per session, the kit's own repairs excluded): only this machine's changes are taken back, the result comes back to the model like a remote change and is sent like a local one. Each local_change is one step; group(), or begin_group and end_group, make several one step (typing). A change split into several commits is one step. Two Loro slow paths found on the way: a batch import of much text, and an undo or revert over one big run of text, both take time growing with the square of the text (1 MB: about 6 s to import as a batch, about 10 s to undo, in release). The kit now imports remote updates one by one (fast for appends), and the agent imports a catch-up batch in groups of at most 16 KiB (a 2.5 MB paste in pieces: milliseconds, not minutes). The undo cost stays: it is in the report's concerns. Tests on two sessions over the fake agent: undo takes back only this machine's word while the other's edit to the same text stays, redo puts it back, the other machine's undo takes back only its own; a layer renamed here and moved there keeps the move when the rename is undone; a group and a 1.25 MB change split into commits are one step each, on both machines. Agent: a big paste in a batch is imported in small groups. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RC1oqfy9kZznj94RjMojyz
When an app saves a live document's file it calls session.saved(bytes): the kit notes the save in the document (so the file isn't taken for one changed outside Craft) and tells the agent the content's hash and the document's version (frontiers and version vector) through a new, appended local-API request. The agent keeps the newest records in live/<space>/saves.json. A version the sync engine uploads with content an app saved carries the record (NewVersion.save, appended: an older agent sends none, an older server ignores it). The server stores it with the version (migration 0006: versions.save, the record's JSON, never read beyond that) and lists it in FileState.save. Batches stay under the body limit when records make operations bigger. When a file changed here and on the server and both carry records of the same live document, there is no conflict copy: the server's version wins if its record includes ours, otherwise ours goes up over it (the newer upload; the live document has both sides' edits). Without a record on either side it is a conflict copy as before. The kit reports changed_outside on open when the file's bytes are no save or import of the document. Tests: the engine with two machines saving at once (one including the other, concurrent, autosaving round after round), a file edited outside Craft and saves of two documents (conflict copies), the record limit, the server storing and listing records (both backends) and the migration from before them, older JSON both ways, the agent's save book, an app's save going up with its record end to end, and the kit's changed_outside. The network simulation gains Craft saves of a live-linked file on every machine, with its live document's knowledge of each machine's edits: never a conflict copy of a Craft save (300 seeds while building). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RC1oqfy9kZznj94RjMojyz
craft-cloud-egui (crates/egui, egui 0.36): the account and status line, the app's shared recents (a click opens one), and a live document's badge (live, waiting N, read-only, offline), with CloudWatch keeping them current off the UI thread and waking the window. Kittest tests for each. craft-cloud-kit-demo (apps/craft-cloud-kit-demo, not packaged): one window with a rich-text story (bold, headings, the other machines' cursors marked) and a layer tree beside it (add, rename, up, down, indent, outdent, delete), live through the kit: its adapter, the hook at the end of each command, a poll each frame, undo through the session (its own snapshot undo while not live), an IME composition holding remote changes back, saving to a .kitdemo file (JSON, atomically) with the save record, autosave, and bringing in a file changed outside Craft. Opening runs off the UI thread and tries again while the file isn't synced yet. The kit's TextSource now lends its runs and paragraphs (Cow), so a keystroke in a big story copies none of them. Tests: the demo's edits and file; two demo windows on one fake hub driven through their widgets (a layer added, typing, a rename coming back, the other's cursor, undo of only this window's edit, redo, saving with the record). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RC1oqfy9kZznj94RjMojyz
The kit simulation: four app sessions on three machines of one fake hub (two apps on the third) make random edits of every shape, undo and redo, and save, while machines go offline, drop their apps' connections and restart. Healed, every session ends with the same model, valid, and equal to what its document reads back whole. A remote change to a rich text is now patched into the model's old text from Loro's deltas (characters counted from the old text, the result checked against Loro's length, read whole when it doesn't fit), instead of reading 1 MB back on every remote keystroke. Session::reconnect_now skips the reconnect pause. The speed test (release, the demo with a 1 MB story and 5,000 layers): opening on the other machine about 100 ms, a keystroke's local_change about 0.6 ms median, a remote keystroke's poll about 0.9 ms median. The demo derives its paragraph count rather than its word count (counting words in 1 MB after every remote keystroke cost about a millisecond). docs/kit.md: how an app adds live documents (the adapter, opening, the hook, each frame, undo, saving, presence, the window's piece, testing with the fake, costs), with the demo's code and the git dependency on v0.1.0. docs/design/live.md: blobs, saved, save records and the sync rule, the kit's layout in Loro. The workspace version is 0.1.0, ready for the tag. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RC1oqfy9kZznj94RjMojyz
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RC1oqfy9kZznj94RjMojyz
A refusal for now (the agent couldn't write it to disk, or any code but invalid and too_large) keeps the change queued: it and everything after it go again, in order, after a pause growing from 200 ms to 8 s. A refusal for good (invalid, too_large) stops the session: nothing more is sent into the hole every later change would depend on, status says stopped with a clear error, and the model stays editable so the app can save its work to the file. The writer no longer turns an over-cap operation into an error after its commit was made; it goes, and the agent's refusal stops the session. craft-cloud-app's Event::Refused carries the refusal's code, and the fake agent can refuse the next updates with a given code (refuse_next). Tests: a change refused for now arrives with the ones after it, in order; a change refused for good stops the session, keeps both changes queued and sends nothing after it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RC1oqfy9kZznj94RjMojyz
Undo and redo went through apply_incoming even while the app held remote changes back, so an undo pressed mid-drag or mid-composition swapped the model and the gesture's own change then failed as stale. Now, while held, undo and redo answer false at once and are kept; the first poll after the gesture ends applies what was held, then runs them in order, and says the model changed. Test (the reviewer's probe, turned around): the undo asked mid-gesture changes nothing, the gesture's change is taken, and after the gesture the held remote change and the undo both apply, on both machines. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RC1oqfy9kZznj94RjMojyz
When a rebuild fails (the app's apply_remote, or a repair's write), the live document has moved on and the model hasn't. The session now marks the model stale: local_change answers KitError::ModelStale, so no diff is ever written from a model that lacks the other machine's work (a tree diff from it deleted the remote layers), and each poll tries a whole rebuild until one succeeds. Undos asked for wait for it too. Test: a model whose apply_remote fails refuses a local change while stale; once it reads again, the poll rebuilds it and both machines end with both layers. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RC1oqfy9kZznj94RjMojyz
Reconnecting reopened by path and attached whatever document was there. If the file was renamed away while the agent was down and another took its place, the session merged two unrelated documents, or imported itself over a never-live file. The session now keeps its document's ID from the first open; a reopen that finds another document, or a never-live file, doesn't attach: the session stops with document_replaced (Status::replaced), keeps its model editable and tries no more, so the app can save the model as a new file. The fake hub can put another file in a path's place (Hub::replace). Test: for a live and a never-live replacement, the session ends replaced and stopped with its model, nothing is sent into the other document, and the never-live file isn't imported over. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RC1oqfy9kZznj94RjMojyz
An agent from before /v1/blobs answers an unknown call from its fallback without reading the body, so a big put to it ended in "Broken pipe" rather than agent_too_old. The blobs client now asks first, once per client, with a GET of a name no blob has: an old agent answers not_found (too old); a new one answers blob_not_found, offline or not_signed_in. A put or get to an old agent then answers agent_too_old before a byte is sent. The fake, set too old, answers without reading the body, as the old agent does. Tests: the review's probe as an agent test (an axum agent with a 404 fallback and the 1 MiB limit: puts of 10 B, 512 KiB, 5 MiB and 32 MiB, and a get, all agent_too_old; it failed before), and an 8 MiB put to a fake that is too old. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RC1oqfy9kZznj94RjMojyz
…w M1) When the server's save record includes ours, the engine downloads its version over the file. It stat'ed the file for that download after hashing it, so a save landing in between was replaced unseen. live_winner now stats the file before it hashes it and hands that stat to the download, the same guard the other paths use: a save that lands since keeps the old base, and the next pass decides again (by its own record, or as a conflict, with its copy). Test: the laptop's save is included by the desktop's; another program saves the laptop's file during each of the engine's reads in turn; that save always ends as a file on both machines (it failed before, at the read the decision hashes). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RC1oqfy9kZznj94RjMojyz
…ew M4)
Loro's undo of a run of text takes time growing with its square (about
35 ms at 64 KiB, 2.4 s at 512 KiB, 10 s at 1 MB), so undoing a big paste
froze the app. The writer now counts the text each change inserts and
deletes; each undo step is keyed by where it starts (the undo manager's
items carry that start through on_push), so the session knows the size of
the step at the top of either stack. A step over 64 KiB of text
(MAX_UNDO_TEXT_BYTES) is refused at once with UndoTooLarge ("too large to
undo while others edit"), can_undo says false, undo_blocked says why, and
the step stays on the stack as a barrier. The demo shows the reason on its
disabled Undo.
The kit's own inverse (delete the inserted range by cursors, put a deleted
run back) was not done: Loro's undo manager can't pop or replace a single
item, so the big step would stay on its stack and a later undo would take
it back a second time; clearing the stack instead would lose every other
step.
Test: a 64 KiB step undoes and redoes within bounds; a step one byte over
and a 1 MB paste are refused within 200 ms, twice, and nothing under them
is undone.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RC1oqfy9kZznj94RjMojyz
… M7) Every session wrote with a fresh random Loro peer, so a document's version vector, and every save record with it, grew by one entry per session opened, without end; past 4096 entries saves lost their record. The agent now lends each open a peer from a pool of PEER_POOL (8) per document, app and machine: slot i is always the same peer (from the device and the app), two opens at once get distinct slots, a close frees its slot at once, and a connection gone without closing keeps its slots for LEASE_GRACE (60 s) for the same app to ask for again (Open's new `peer` hint), so a new window never writes over changes the old one hadn't sent. `opened` and `needs_import` say the peer; the kit writes the import and the session with it, asks for it again on a reconnect, and if another window took it meanwhile it moves to the new peer (undo history starts again) or, with changes still unsent under the old one, stops (peer_taken) for the app to save the model. The fake agent lends peers the same way (craft_cloud_api::local::peers::Leases is shared). A reused peer must see all of its earlier changes before it writes: `opened` now says how many `update` frames follow the state as part of it (DocState::updates), the client hands them over with the state (Document::state_updates), and the kit imports them before it writes. The first version of the kit test lost edits exactly there. Records stay bounded: MAX_SAVE_PEERS is 256 (32 machine-app pairs at the pool's size, about 9 KiB of JSON), and a page of the tree or change feed ends early once its save records pass MAX_PAGE_SAVE_BYTES (4 MiB), so no listing comes near the 16 MiB an agent reads. Tests: the lease pool (distinct, reused after close, kept for the hint after a crash, free after the grace, none past the pool, only the pool's peers); the client waits for its state's updates; with the fake, 30 sessions opened, edited and closed on two machines leave at most 4 peers in the version vector, and a reconnect keeps its peer; with a real agent, opens get pooled peers; listings of 900 files with the biggest records come in small pages, every file once. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RC1oqfy9kZznj94RjMojyz
…ew H2, M8)
Session::saved(bytes) recorded the live document's version at the moment
it was called, whatever model the bytes were made from (a model behind a
remote change it couldn't take, a read-only document's), and it was
called after the write, so the agent's pass could upload the content
before knowing it; a save while the agent was away lost its record.
Saving is split now:
- `save_point()` gives the model to write and the frontiers and version
it matches, or refuses (KitError::NoRecord) when the document is
read-only here, the session is stalled or stopped, or the model is
behind the live document; the app then saves its own model without a
record (the file syncs as any file, conflict copy included);
- `record(&point, &bytes)` tells the agent before the bytes hit disk.
While the agent is away the session keeps the record (the last 16) and
tells it on the reconnect.
The demo and docs/kit.md show the exact order.
The agent keeps records by document and content hash as before; one that
comes after its version went up is added to it late: a pass now sends
`POST /api/v1/saves {node, version, save}` for a file whose server
version is the one it agreed on, with the record's content and none yet
(Meta::attach_save: appended, the same call again changes nothing, the
node goes into the change feed again). An agent hearing a record asks for
a pass. An older server answers not_found and is let be. The save book
is read again only when its file changed (the engine asks for every
file of every pass).
Tests: a save point is refused for a read-only document (the reviewer's
probe: no record reaches the agent) and a stopped one, and matches the
session's model otherwise; a record made while the agent is away goes
once it is back; the engine adds a late record to its version once, the
other machine learns it, a version keeps its first record; meta appends
once and bumps the feed; with a real agent and server, a record told
after its upload lands on that version.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RC1oqfy9kZznj94RjMojyz
…nt (review H3) The kit kept the newest 32 save marks in the document and checked the file's bytes against them whenever the marks changed: a reader that opened an unchanged Craft save was told, after 33 saves on another machine, that its file was edited outside Craft, and offered to bring it in. Now the open names the hash of the bytes the app read (OpenOptions:: file_hash, `open`'s new `file_hash`), and the agent answers once, in `opened` (`changed_outside`): no if an app on this machine saved exactly that content (its save book), or if it is the content the sync agreed on and the server's version of it carries this document's record; yes if that version has no record, or the bytes are an edit the sync hasn't taken; unknown while a newer version is on its way. No window of recent saves. The document keeps only the hash of the bytes it was imported from (root `import`), which is no outside edit either. The save marks are gone (a save writes nothing to the document), and nothing changes the answer after the open except the app's own save. Tests: the reviewer's probe as a test (a reader through 40 saves on another machine is never told; the first of them opens as a Craft save; other bytes are outside, and stay so while others save, until saved; fails before the fix); the agent's rule on its own; with a real agent, an app's save is no outside edit and a text editor's is. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RC1oqfy9kZznj94RjMojyz
…ew H1) When two machines' Craft saves of one file met and neither included the other, the newer upload won with no conflict copy (kept as ruled), and the other machine's edits were in the live document but in no file: nothing made either app save again, so the file stayed without them for good if nobody edited on. The agent now tells each open document the record of its file as it is on disk (a new `file` event: after `opened`, and whenever it changes; it looks at open documents' files every second, hashing a file only when its length or time changed): this machine's save record of that content, or the record of the server's version when the file is that version, or none. The session keeps it (and its own last save until the agent says otherwise) and offers `file_behind()`: true when that record doesn't include the live document's version. The demo, and docs/kit.md's recipe, autosave when dirty or the file is behind, so the losing machine saves again with everything, and once every file has the live document's version nobody saves again. With no record known (an edit outside Craft, an older agent) it is never behind, so an outside edit is never written over by itself. Documented: the losing version stays in the server's history, and a document no app has open keeps its file behind until the next open. The fake agent tells the same (a save goes up at once, or when the machine is back online; the newer upload wins; edit_outside). Tests: with the fake, two machines autosaving after concurrent saves, and after a save made offline that goes up over the other's, end with the file holding both documents' version within 4 saves each, then save no more; an outside edit isn't written over. With running agents and a real server, both windows autosaving through a time the server is away end with both files holding all 25 lines, alike, no conflict copy, no save for 3 s after, at most 36 saves; without file_behind the same test never settles. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RC1oqfy9kZznj94RjMojyz
- L2: the queue-cap test meets the edge within one small change: big changes up to a MiB below the cap, then smaller ones; the last change taken is under 1 KiB below the cap, and the first refused under 1 KiB past it (was: within one 200 KiB block). - L7: docs/kit.md says normalise must be deterministic (every machine writes its repair of the same merge; new IDs and values derived from what is repaired, never ElemId::new() or the clock). - L8: the badge says Stalled (new changes not kept until the agent is back) and Stopped (nothing goes out, with the session's error on hover, save to keep the work), ahead of Offline. - L10: SaveRecord::includes looks peers up by binary search in a sorted version (every record is sorted when made), not a scan per peer. (L5's cache, L9's real-agent session test and L11 came with the save fixes: the book is read again only when its file changed, the agent tests drive kit sessions against running agents, and a save writes nothing to the document any more.) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RC1oqfy9kZznj94RjMojyz
The agent's pending opens get a type of their own (type_complexity), and a test drops a needless borrow. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RC1oqfy9kZznj94RjMojyz
Once healed, one window saves and each then saves while its file is behind, as an app's autosave does: within one save per window none is, and the file's record is the version every session has. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RC1oqfy9kZznj94RjMojyz
… (re-review N1-N5, M6, M7) Peer reuse goes entirely (N1, N2, M7). Two writers under one Loro peer lose data silently, and no lease survives crashes, restarts and cloned disks. Every session now writes with a fresh random peer, Loro's default; the pool, the leases, the `peer` hints, `peer_taken` and the 256-peer refusal in save_point are gone. Records stop growing with peers another way: a save record names the live document's frontiers (its heads, one or two operation IDs), never a version vector. Whether record A includes record B the agent answers from its copy of the document (Loro compares the frontiers against its oplog): the sync engine asks through `SaveRecords::includes`, the save book passes the question to the running live hub (`Orders`). When the copy lacks the operations the answer is unknown: the file waits (neither side taken, the push leaves it too) and the next pass asks again; still unknown after 10 minutes (`Config::save_order_wait_ms`) it is a conflict copy as today. A winner is never guessed. MAX_SAVE_HEADS (256) is a guard on a hostile record; the listing cap stays. file_behind() is true only when the session is strictly ahead (N3): the file's record is included in the live document and isn't it. A record with anything the session lacks (concurrent, ahead, unknown) leaves it false, so the side behind waits for live updates, a file ahead of the live document is never written over, and two machines with live down but files syncing no longer save back and forth (566 saves in 3 s before). A backstop: after a save only the file being behind called for (no edit since the last), file_behind stays false for 5 s (CATCH_UP_EVERY). A record goes on the same ordered stream after every change it covers (N4): the session keeps it with its counter and sends it once those changes are sent (again on every reconnect). The agent keeps it in the book only once all its frontier operations are in its copy; one that comes first waits, and is dropped if its connection closes the document first (that version then has no record; its edits are in the file). On keeping one the agent tells the file's record again (L7). "Bring it in" (N5) is the user's choice: Session::replace_with_file applies the file as one ordinary undoable change named "Replace with the file's content" (step_name for the Undo label). The demo's prompt and docs/kit.md say it replaces live edits made since the file was written, and that Undo brings them back. model_stale (M6) is cleared only by a successful whole rebuild, and a partial rebuild while stale writes no repair back (the reviewer's probe: the title's rebuild wrote the stale tree over the document and deleted the other machine's layer). Lows: attach_save checks the record's document and that its heads are sorted and distinct (L3); the save book keeps the newest 32 of each document, 4096 in all (L4), shares one cache per file in the process and looks at its file at most every 500 ms (L2); a document the agent is reading back answers `internal`, not `invalid` (L5); a reopen that finds a never-live file says Craft Cloud may have lost its copy (L6). Tests, each failing (or not compiling) before: the reviewer's reuse probe with sessions (no change lost, records of one or two heads after 20 more sessions); the live-blocked probe (at most one catch-up save per side); a file ahead of the live document isn't written over; the M6 probe (the layer survives, nothing written while stale); bringing the file in is one named undoable change; the agent's copy orders saves (included, apart, unknown until the operations come); unknown order waits, then resolves, and past the wait is a conflict copy; a record waits for its changes and is dropped with its session (real agent); meta refuses a stranger's or unsorted record. The fake agent can hold live updates back while files sync (Hub::set_live), and the kit simulation now does so at random; the network simulation answers "unknown" the first time a pair is asked. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RC1oqfy9kZznj94RjMojyz
…eview 2, R5) A file state or new version whose save record was in round 1's form (a version vector) failed to read whole: an agent's state.json holding one failed to load as damaged, and a new agent couldn't read an older server's change pages. Now `save` reads through SaveRecord::lenient: a record that doesn't read (an earlier form, a damaged or unknown one) or isn't well formed is no record, and what holds it reads. The agent's save book likewise leaves out an entry that doesn't read and keeps the rest (it lost the whole book before). Tests: the reviewer's probe as a test (a file state and a new version with an old, a junk and an unsorted record read, with no record); the book with an old entry among good ones keeps the good ones. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RC1oqfy9kZznj94RjMojyz
…, R3) When replace_with_file failed (stale, stalled, read-only), the demo still saved, writing the live model over the outside-edited file and saying "saved". Now Demo::bring_in returns before the save: the file keeps the outside edit, the status line says why, and the banner stays to try again. Test: a document a newer demo made live opens read-only and changed outside; bringing it in fails, the file is unchanged and the banner stays (with the old order the test fails: it saved). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RC1oqfy9kZznj94RjMojyz
replace_with_file named the step at the counter it started from even when the change wrote nothing (the file was the live content already), so the next ordinary edit, starting at that counter, was called "Replace with the file's content". The name is now given only when the counter moved. Test: bringing in the live content names nothing, and the next edit's undo has no name (fails before: it had the replace's name). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RC1oqfy9kZznj94RjMojyz
…ake time (re-review 2, R1, R2) The hub answered "which save is newer" only from a loaded, followed copy. After any agent restart only documents with something to send are loaded, and parked ones aren't followed, so the answer stayed unknown until the 10-minute fallback made a conflict copy, though disk and a follow could tell. Now a question about a document the copy can't answer loads it (within the replica's usual LRU caps) and follows it again, and asks again from what is on disk; still unknown, the hub notes it and asks for a sync pass as soon as the document gains operations, so the engine retries soon, not after 10 minutes. The fallback is the last resort. The engine measures the wait on std's monotonic clock: on Linux and macOS that stops while the machine sleeps, so a laptop that sleeps through the wait doesn't end it early; on Windows std's clock may count sleep (it would need the OS's unbiased time, outside std), as the config's doc says. A pair that stops being two Craft saves (no record on a side, another document) ends a wait under way, so a later unknown pair on the same file starts its own. Test: the real path (save book, hub, copy) orders two saves and can't tell unknown operations; after a restart with no window open the question is answered within seconds (the reviewer's probe answered unknown for good; fails before). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RC1oqfy9kZznj94RjMojyz
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RC1oqfy9kZznj94RjMojyz
…cted (autosave race)
The cause: under load the agent's sync pass saw a Craft save's content
before its record reached the save book (the record waits in the kit
behind changes still to go, or in the hub until its operations are in),
so it took the content for an edit outside Craft: it went up without a
record, or met the other machine's save as a conflict copy. Reproduced
every time with each agent holding records back 1.5 s.
Now `Session::record` announces the save at once, before the write
(`saving {doc, hash}` on the docs socket, not waiting for the changes),
and the hub notes it in memory (by save book, 10 minutes at most; let go
when the record comes, or when the app closes the document first).
`SaveRecords::pending` tells the engine: such content isn't committed
without its record (the push skips it, "a Craft save waits for its
record"), and a both-changed pass on it waits (no conflict copy) under
the same 10-minute fallback as an unknown order. Once the record lands
the hub asks for a pass and the saves resolve as any two Craft saves.
Tests: live_autosave now runs both agents with a 1.5 s record delay
(the run-loop's test-only `record_delay`; fails 3 of 3 before, passes
20 of 20 since with eight CPU burners, CRAFT_CLOUD_TEST_LOAD); the engine
test of a late record (no upload without it, no conflict copy with the
other's save meanwhile, resolved once it comes; fails before); the
network simulation delays half the Craft records by 1-6 steps (fails
before: a conflict copy of Craft saves at seed 10).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RC1oqfy9kZznj94RjMojyz
…nts bounded (re-review 4, M1, M2)
M1. A save made while the app had no agent connection (reconnect
backoff, a connection the hub dropped, an agent restart) couldn't be
announced, so the agent's pass took it for an edit outside Craft and a
concurrent Craft save elsewhere made a conflict copy. Now the agent
remembers, per live document, when an app session last had it open on
this machine (kept in `sessions.json` next to the save book, so it
survives a restart: a document open at the restart counts as closed
then). A change to the file while a session has it open, or within the
grace after (SESSION_GRACE, 60 s), counts as a Craft save possibly on
its way, as in round 4, but for at most the grace from its first wait;
no record by then and it counts as made outside Craft, as before. The
kit announces and records the saves it made while disconnected first
thing on the reconnect (it did; it no longer announces while refused
for good, re-review L4). A pass that leaves a Craft save waiting asks
for another in 2 s (a wait that runs out asks for nothing by itself).
M2. Announcements are bounded (`Pending`, replacing round 4's map): only
of a document the connection has open (else refused), at most 64 per
connection (the oldest go), dropped on its close, held a grace after
the connection went, and each content waits 10 minutes in all from its
first wait, never renewed by announcing again. A record ends the wait.
The engine test's first check ("nothing went up yet") compared the
record, which a record-less upload also lacks; it now compares the
version, and fails with the check off.
Tests: the bounds one by one (unit, Pending); with running agents, an
app that saves while its agent restarts, and the laptop's Craft save
going up meanwhile, ends with no conflict copy and the pc's version
carrying its record (3 of 3 conflict copies before; 5 of 5 under load
since). Run loops in tests take a 10 s grace (`RunOptions::session_grace`).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RC1oqfy9kZznj94RjMojyz
A window thread that panics (a failed write, say) left the test to time out as "never settled". The settle loop now fails at once, pointing at the window's own panic. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RC1oqfy9kZznj94RjMojyz
…te (Windows CI) The Windows CI failure on 162511d (conflict copy "n (conflict, laptop, …)", never settled) is the path of an announcement the hub reads after the sync pass has seen the file: the announcement isn't acknowledged, and the tests' stability window is 200 ms, so a slow runner's busy hub missed it; the save went up without its record, and the other machine's Craft save met it as an outside edit. (The 10053 lines are the test's own server outage; the os error 5/2 lines on status.json come at teardown.) Round 5's session rule covers it: while an app has the document open, the change waits for its record whether or not the announcement was read. The test now also delays every announcement 1 s in the hub (the run loop's test-only `announce_delay`). With the session rule turned off it fails 3 of 3 with the CI's very signature; with it, it passes. The test's saves retry a replace Windows refuses for a moment (the agent reading the file then), as an app tries its save again. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RC1oqfy9kZznj94RjMojyz
Owner
Author
|
Tried by hand at 3724d45 with the demo on BEAST (Windows) and lptp1 (Linux), against this branch's server on cloud01.
Not tried yet: the "changed outside Craft" prompt (an edit made in a text editor). |
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RC1oqfy9kZznj94RjMojyz
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The App kit: what a Craft app needs so that adding live documents comes down to one adapter. The app maps its model onto shared mappings, calls
local_changeat the end of each command andpollonce per frame. Everything else lives here. A demo app in this repo proves it end to end.What's in it
craft-cloud-api: the app-facing protocol types, split out ofprotoso that apps link no postcard.cargo xtask suite-deps <lock files…>lists any crate the kit pulls in that no Craft app already locks. Today there are none, apart from Loro's own crates.craft-cloud-kit:poll, reconnects, and shows a document from a newer app version read-only.UndoManager, so it undoes only this machine's changes. Steps over 64 KiB use the kit's own inverse, because Loro's undo of a big text run is slow./v1/blobs.file_behind()tells the app when the file lacks edits the session holds, so the file catches up and the saving then stops.POST /api/v1/savesattach records that arrive after the upload.craft_cloud_app::fake: an in-process agent for app tests. Two of them can share a hub.craft-cloud-egui: the account-and-recents piece, and a document badge (Live, Offline, waiting N, read-only).craft-cloud-kit-demo: a rich-text story with a layer tree beside it, live, with undo, presence and autosave. It isn't packaged.docs/kit.mdshows how an app adds live documents, with the exact order of a save.v0.1.0tag that the Craft apps can depend on.Review
A whole-branch review, then four fix rounds, each re-reviewed. The main fixes:
Tests
cargo xtask ciwith Postgres: 1043 tests pass.local_changemedian 0.6 ms; remotepollmedian 1 ms.Known gaps
🤖 Generated with Claude Code
https://claude.ai/code/session_01RC1oqfy9kZznj94RjMojyz