Skip to content

App kit - #11

Merged
itrycodinganditsbad merged 33 commits into
mainfrom
app-kit
Oct 20, 2026
Merged

itrycodinganditsbad merged 33 commits into
mainfrom
app-kit

Conversation

@itrycodinganditsbad

@itrycodinganditsbad itrycodinganditsbad commented Oct 19, 2026 •

Copy link
Copy Markdown
Owner

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_change at the end of each command and poll once 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 of proto so 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:
    • Session: opens a document by path, writes local changes as Loro commits, applies remote ones at poll, reconnects, and shows a document from a newer app version read-only.
    • Mappings: tree, rich text, table, timeline and opaque values.
    • Undo while live goes through Loro's 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.
    • Binary content goes in as chunk references through the agent's new /v1/blobs.
    • Large changes are split under the agent's 1 MiB cap.
  • Saving a live file. Two Craft apps that autosave one file no longer overwrite each other or make conflict copies:
    • The save record: an app announces each save, then sends a record of the version it saved. The agent attaches the record to the upload. When two Craft saves meet, the one that includes the other wins; if neither does, the newer one wins. The agent can't always tell which is newer, and then it waits, falling back to a conflict copy after 10 minutes.
    • Catching up: file_behind() tells the app when the file lacks edits the session holds, so the file catches up and the saving then stops.
    • Outside edits: a file edited outside Craft is handled as before, conflict copy included. The app is told on open, and can bring the file's content in as one undoable step.
    • The server: migration 0006 and POST /api/v1/saves attach 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: docs/kit.md shows how an app adds live documents, with the exact order of a save.
  • Workspace version 0.1.0, ready for a v0.1.0 tag that the Craft apps can depend on.

Review

A whole-branch review, then four fix rounds, each re-reviewed. The main fixes:

  • Every session uses a fresh Loro peer. An earlier version reused peers, which can make Loro drop edits silently.
  • Save records hold frontiers, so they stay small however many sessions a document sees.
  • An autosave loop when live updates are blocked but file sync works is gone.
  • A refused update is never dropped.
  • A stale model can't write over other machines' changes.
  • Two load races made conflict copies between two Craft saves: a sync pass that ran before the record arrived, and a save made while the app had no agent connection. Both are fixed. A save announced, or made while an app has the document open (or up to 60 s after), is waited for instead of counted as an outside edit. The autosave test delays records and announcements on purpose, and passes 20 of 20 runs under load on Linux and 10 of 10 on Windows.

Tests

  • cargo xtask ci with Postgres: 1043 tests pass.
  • Kit simulation, seeds 0–4999: random edits, undo, disconnects and saves across all mappings, with live updates cut at random.
  • Network simulation with Craft saves and late records, seeds 0–4999.
  • Documents simulation, seeds 0–1999.
  • Autosave under load: 20 of 20 runs pass with eight CPU-burning threads.
  • Release speed on a large document: open 93 ms; keystroke local_change median 0.6 ms; remote poll median 1 ms.
  • Windows: workspace clippy and the kit, sync, app, egui and demo tests. The agent's own Windows tests run here in CI.

Known gaps

  • Undo of a very large paste: Loro's undo of a big text run grows with the square of its size. Steps over 64 KiB therefore use the kit's own inverse, and a Loro fix would let that go.
  • Blob references: nothing references uploaded blobs yet. Retention and GC must count them.
  • The fallback on a sleeping Windows laptop: on Windows, the 10-minute fallback keeps counting while the machine sleeps, so it can end early after a sleep. The worst case is an unneeded conflict copy.
  • DeckCraft needs rich text inside tree nodes and table cells (one text body per shape and per cell). That's the first thing the DeckCraft milestone adds.

🤖 Generated with Claude Code

https://claude.ai/code/session_01RC1oqfy9kZznj94RjMojyz

itrycodinganditsbad and others added 30 commits October 19, 2026 18:52
…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
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
itrycodinganditsbad and others added 2 commits October 20, 2026 02:27
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
@itrycodinganditsbad

Copy link
Copy Markdown
Owner Author

Tried by hand at 3724d45 with the demo on BEAST (Windows) and lptp1 (Linux), against this branch's server on cloud01.

  • Live edits (typing, bold, layers added, renamed and moved) showed up on the other machine as they were made.
  • Undo on one machine took back only its own change; the other machine's rename stayed.
  • Both machines autosaving while editing at once: no conflict copy, the same file on both, every edit in it.
  • Saving while lptp1 was offline: both machines ended with every edit, no conflict copy.

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
@itrycodinganditsbad
itrycodinganditsbad merged commit 98bbab3 into main Oct 20, 2026
4 checks passed
@itrycodinganditsbad
itrycodinganditsbad deleted the app-kit branch October 21, 2026 00:25
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant