Skip to content

feat: desktop sync, the app server mirrors every CodeWork machine - #140

Merged
sanchitrk merged 33 commits into
canaryfrom
feat/sync
Nov 6, 2026
Merged

sanchitrk merged 33 commits into
canaryfrom
feat/sync

Conversation

@sanchitrk

Copy link
Copy Markdown
Contributor

Summary

Desktop sync: apps/server keeps its own copy of CodeWork (.notes/SYNC.md). The harness becomes a versioned store, the CodeWork RPC streams rows by revision, apps/server mirrors every machine into app.db, and the web app reads the mirror.

Harness. Store identity and a store-wide revision on project, space, session, thread, entry and inbox, enforced by triggers; session tombstones; a doorbell after each commit (Store.write / rev / head / wake). Durable execution.* and retry.scheduled events projected onto thread.run_*, with a startup sweep before the server listens; an abandoned run ends with session.run.failed only. Text and thinking deltas carry offset.

CodeWork RPC. server.info returns storeId and rev. New streams index.sync({ after }) and thread.sync({ sessionId, threadId, after?, budget }) send rows with rev > after then Synced, then the same live; thread.page reads older pages of a thread's line. HistoryEntry.size, QueuedPrompt.admittedSeq, extended SessionInfo / ThreadInfo, origin.cut.

apps/server. The app.db schema of SYNC.md section 5 (machine replaces servers; organization, grouping, copied project, space, session, thread, entry, part, inbox with desktop columns). A Sync service per machine: full sync on a new or restored store, otherwise resume from cursors; batches applied with deferred foreign keys; soft deletes; opened-thread detail with sync.open / sync.close / sync.page; deltas folded by offset; bus events in the same transaction. User-side tracking: status, priority, groups, ordering, color. Reads over app.db only: sidebar.list, threads.line, dashboard.list.

apps/web. Sidebar and session widget read the synced tables; the working set, kv snapshots and projects.list are gone; commands route to the session's machine; fork from a message; a Linear-like dashboard with drag reordering; pending / stopping display states.

Verification

  • E2E artifacts under packages/harness/test/__artifacts__/, packages/codework/test/__artifacts__/ and apps/server/test/__artifacts__/ (store revisions, run lifecycle across a kill and restart, delta offsets, the two streams and paging, the schema, sync cases, tracking, reads, dashboard).
  • apps/server/test/sync.machines.live.test.ts: ten cross-machine cases and a 3-to-5 machine shuffle on real OrbStack Linux machines with partitions (iptables), throttling (tc netem), crashes and restored or replaced stores; skipped without orb.
  • The web app was checked in the real Electron app with real model calls: sidebar from app.db at launch, streaming replies, a second client following, forks, restart mid-reply, the dashboard and the display states.
  • vp check and vp run typecheck pass; harness 617 tests, codework and apps/server suites green. packages/codework/test/recovery.test.ts has a pre-existing socket-open flake on restarts, present on canary.

Notes

  • No legacy: an existing dev app.db must be deleted; harness databases from before this change are not upgraded.
  • Later (SYNC.md section 9): tunnel and relay, heartbeats, retry ids for session.create / thread.fork, reconnect jitter, a bounded app bus, purging soft-deleted sessions, tombstones for stray project and space deletes, a second Electron window.

🤖 Generated with Claude Code

sanchitrk and others added 30 commits November 5, 2026 18:38
session.llm.text.delta and session.llm.thinking.delta gain `offset`, the
part's text length (JS string length) before the delta, so a client can
splice a delta onto its draft or notice a gap.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The execution lifecycle events and retry.scheduled are durable, and the
session projector writes the thread's run state (run_state, its times,
outcome and error) from each event's own timestamp. At startup, before the
server listens, the harness ends every run a dead process left open with
execution.interrupted (crash, or shutdown when the run was closed); a run
recovery resumes says so with execution.started { resumed: true }.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A single-row store table holds the database's id and its last revision.
Project, space, session, thread, entry and inbox rows carry the rev of the
write transaction that last touched them, enforced by insert and update
triggers. Store.write runs a write transaction (or joins the enclosing one),
Store.rev takes its revision on first use, and a commit that took one rings
a store-wide doorbell (Store.wake). Every write path goes through it: event
projectors, plain-SQL thread, session, project and space writes. Part writes
stamp their entry, document writes stamp the threads they configure, and
deleting a session leaves a removed row.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A run the loop ends itself (crashloop, interrupted, truncated) publishes
session.run.failed and returns normally, so the coordinator also published
session.execution.succeeded and the thread row lost the failure reason.
Runner.run and abandon now return an outcome; the execution's settled hook
publishes no terminal after a run that already ended itself.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ssion and thread rows

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Entries carry their size, the bytes of their parts as stored, computed in
SQL. Thread.linePage reads the newest page of a thread's line within a byte
budget and a row count, past an optional seq. Sync.open reads a thread's
first page and pending inbox; Sync.thread reads its own entries and inbox
rows by revision.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A thread's newest page within a budget and its pending inbox on first open,
or its rows changed since a revision on resume, then the same live after
each commit, with the thread's text and thinking deltas merged in.
HistoryEntry carries size.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…table

app.db gets the desktop sync's schema: machine (replacing servers),
organization (seeded "personal"), grouping, project, space, session,
thread, entry, part and inbox, with foreign keys so removing a machine
cascades to everything copied from it. The migrations collapse into one
that creates the schema directly and seeds the built-in local machine.

servers.* read and write the machine table and accept remote machines:
a server is local or remote, and a remote one is added and updated with
its WebSocket URL. Only a local server can be the default or run
codework service.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
server.info gains storeId and rev; index.sync, thread.sync and thread.page
join the mirror with the project, space, session, thread summary, run and
live delta shapes they carry. HistoryEntry gains size, so the session
widget's stand-in entries carry one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A Sync service keeps app.db a copy of each machine's CodeWork over its own
connection: index.sync from machine.cursor, a full sync for a new or
restored store, and thread.sync for opened threads from thread.cursor.
Each batch applies in one transaction with deferred foreign keys; new
threads get their first-seen status, priority and ordering, and run
changes move status with a sync/status event in the same transaction.
Removed sessions are soft-deleted with their detail kept. Live deltas
that continue a draft are forwarded on the app bus, never stored.

sync.open, sync.close and sync.page join the app contract, and the
Event service gains transact so rows and their bus events commit
together.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…rder and color

App RPCs set a thread's status, priority, color, group and order, a session's
color, and create, update, move and delete groups. Each write lands with its bus
event in one transaction: status edits on sync/status, the rest on sync/tracking
with a tagged payload. Moves write one row at the midpoint of the neighbours, or
the lowest minus one / highest plus one. Out-of-range values and missing rows are
typed TrackingErrors.

The E2E helpers move out of the sync suite into a shared fixture.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…back

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Runs the CodeWork fixture server (scripted model) on real OrbStack Linux
machines, syncs them all into apps/server on the Mac and misbehaves the
network: first sync under concurrent writes, resume after an iptables
partition, deletes while away, crash sweep and recovery, restored and
replaced stores, a netem-throttled live reply, a fork over a busy source,
one repository on two machines with removal cascade, and revision ordering
with a refused write. The machine set grows from 3 to 5 and shrinks again;
each case and the shuffle leave an artifact under __artifacts__.

The machine scripts (setup, start, stop, net, drive) live in
test/machines; the fixture server gains a bind host, long replies and a
sweep-only mode. The suite is skipped where orb is not installed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The machine's boot (orb start until its server answers from the Mac) ran
from under a second to three minutes and pushed the step's bucket past
its edge, so the shuffle artifact drifted. A step can now time its sync
apart from what it waits on: the artifact buckets that sync time, the
printed timings and the measured file keep both. The wait for the
rebooted machine is raised to 8 minutes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The app server serves two reads over app.db alone: `sidebar.list` (an
organization's active projects with their sessions and main threads, on
the default machine or every machine) and `threads.line` (one keyset page
of a thread's line, inherited entries included, with parts, the pending
inbox and an untitled fork's ancestor title). `readTopics` names the bus
topics each read refreshes from.

The sidebar and switcher read run state from the synced rows; the working
set, `projects.list` and its `thread.list` pass-through are gone. The
session backend opens a thread's sync on follow, pages from app.db
(fetching below its floor with `sync.page`), forwards the sync's notices
and deltas on the thread's topic, and sends commands to the machine the
session is on. It no longer keeps kv snapshots or subscribes to CodeWork's
global event feed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…st delta, end a follow on removal

The switcher reads the run state of the thread each frame shows. The view starts a part from a delta at offset 0 and holds deltas for a reply whose row has not landed, merging them once it does. A follow waiting for a thread ends as soon as its session is removed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
dashboard.list splits an organization's threads by status, priority or
group, each list in ordering and every list present, with the groups in
their ordering; a thread's color falls back to its session's. Clients
refresh it from sync/changed, sync/status and sync/tracking.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The dashboard lists threads by status, priority or group, with controls
for each, group management, and drag to reorder or move between lists.
A prompt not yet in app.db shows pending and a stop not yet idle shows
stopping, drawn after 200 ms and held 300 ms, in the session view, the
workspace tab and the sidebar row.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A thread missing from app.db is waited for only until its machine's index reaches the store head read at the follow (a row in flight, such as a session just created); still missing then, it is not found, as thread.info reported it. A deleted session or thread fails at once.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… off the dashboard

A session's place in the sidebar is the newest change to any of its threads,
forks included, not its main thread's alone. Subagent threads are not listed
on the dashboard.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…y display reads idle

The switcher reads each thread's run_state rather than a running flag, so a
starting run reads as starting. A stop no longer clears when the first display
reads the run idle: the session view hears it before the sidebar list refreshes,
so the tab drew the run again until its own copy caught up.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
sanchitrk and others added 2 commits November 6, 2026 00:50
…e list

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A committed message offers a fork. The session backend forks the thread on the
session's machine, waits for the machine's index to bring the new thread into
app.db, and answers with its id; the view then opens the fork in a new frame.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@sanchitrk
sanchitrk merged commit 2f3434c into canary Nov 6, 2026
1 check passed
@sanchitrk
sanchitrk deleted the feat/sync branch November 6, 2026 07:35
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