Skip to content

Experiment/store - #132

Draft
adrianliechti wants to merge 32 commits into
mainfrom
experiment/store
Draft

Experiment/store#132
adrianliechti wants to merge 32 commits into
mainfrom
experiment/store

Conversation

@adrianliechti

Copy link
Copy Markdown
Owner

No description provided.

adrianliechti and others added 30 commits May 20, 2026 21:44
Conflict resolution:
- keep chatstore wiring (server.go) alongside main's new skills/notebook library handlers
- keep session bootstrap + PIN-unlock reload in useChats
- take main's versions of package.json, index.css, and the text-[10px] styling tweaks superseded by main's refactor (drops the experimental mobile root-font scaling)
- dedupe wingman proxy consts in vite.config.ts (both sides added identical code)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…n tab focus

- applyRemoteEvents: an init snapshot may legitimately arrive with a seq
  gap after another device compacted the chat (server dropped the log
  prefix). Decrypt before the gap check, accept init frames as resets,
  and rewrite the local log instead of appending. Previously the device
  was permanently wedged: every pull hit the gap and bailed, every post
  409'd into PostConflictAfterRetry.
- deleteChat: delete the chat's blobs on the server too (ids are unique
  per attachment, never shared across chats) instead of leaking them.
- useChats: re-pull on tab visibility (throttled 30s, skipped while a
  save is pending) so a second device sees new chats without a reload.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The old flag-based check refused to upload local chats if the server had
any — stranding a second device's local-only chats — and didn't even
achieve that: hydrateFromOpfs persists legacy chats as pending targets,
so the next boot's flushPending uploaded them anyway.

Now: union by chat id (ids are UUIDs, so unknown ids upload safely);
on id collision the newer side wins by updated timestamp, matching the
sync engine's conflict policy. Blobs of hydrated targets are pushed
before their events — previously only saveChat uploaded blobs, so
migrated chats lost attachments on other devices.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
vp test is broken at the current version pairing (vite-plus 0.2.1
expects a vitest bin that @voidzero-dev/vite-plus-test 0.1.24 does not
register), so "npm test" invokes the package's CLI entry directly.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
vitest resolves the vite config with a placeholder outDir; the plugin's
closeBundle hook copied pdfjs assets into it on every test run.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Covers auth (401, stable hashed identity), keystore CAS lifecycle
(create-only, stale/current etag), event append/read (NDJSON, fromSeq,
409 on stale seq, 400s), idempotent dedup, compaction (prefix drop,
head preserved), chat/blob lifecycle, cross-user isolation, and the
1 MiB frame cap.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
New frames are written as 0x02 || kid || nonce || ct so a future DEK
rotation can route decryption by key id instead of guess-decrypting.
Legacy headerless frames still decrypt (GCM auth makes the fallback
parse safe); version-1 keystores imply kid 1. Runtime is still
single-key — rotation becomes a data-format non-event later.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
ChatSync now publishes activity (syncing, pending count, last sync,
last error) and emits the fresh chat list when a pull applies remote
changes; useChats adopts that list so manual/focus syncs update the
sidebar. SyncSection shows the activity line with a Sync now button
and surfaces sync errors that previously only hit the console.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Chats keep the fine-grained event-log model; everything else in OPFS
(agents, skills, notebooks, repositories, images, profile.json) now
syncs as encrypted files through a new FileSync engine.

Server: GET /v1/files listing plus GET/PUT/DELETE /v1/files/{id} with
ETag CAS, backed by a per-user manifest in the file provider. Files are
addressed by sha256(path); the real path travels only inside the
encrypted envelope, so the server never learns the tree structure.

Client: opfs-core now emits mutation events for every write/delete;
FileSync tracks them (excluding chats/ and _sync/), debounces pushes,
and does a full reconcile at session start and on manual sync. Conflicts
resolve last-write-wins by file mtime, including the fresh-device case
where both sides have unsynced copies of the same path. Files over
64 MiB are skipped with a warning.

SyncSection merges chat and file activity into one status row; the
crypto layer gains a file envelope (header + raw bytes in one AES-GCM
message) and blob encryption drops its base64 round-trip.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Collection indexes ({collection}/index.json) merge by entry id instead
of last-write-wins — two devices creating agents/notebooks in parallel
no longer clobber each other's index entries. After a clean full
reconcile, entries whose backing item vanished are pruned, so deletions
converge too instead of resurrecting through the merge.

The session now owns the polling cadence (online-first): chats pull
every minute while the tab is visible and immediately on focus; the
file tree reconciles every five minutes. useChats drops its own
visibility handler and just renders what the engine delivers, skipping
mid-stream swaps.

New always-visible sync indicator in the top bar (cloud check / upload
/ spinner / amber alert or lock) backed by a shared useSyncStatus hook
that folds session state and both engines' activity into one health
value; clicking it opens the sync settings. SyncSection reuses the
same hook.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
It stores the whole encrypted workspace (chats, files, keystore), not
just chats. pkg/chatstore → pkg/store, pkg/server/chatstore →
pkg/server/store, CHATSTORE_* env vars → STORE_* (default path
./data/store), config key "chatstore" → "store",
chatstoreClient.ts → storeClient.ts, chatSession.ts → storeSession.ts.
The chat-specific engine keeps its chatSync name.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
subscribeSession invokes the callback synchronously with the current
state; if the session was already in error/disabled, the callback hit
unsub before its const initializer ran. Surfaced when the store API
404'd (server running without STORE_ENABLED) and every whenReady call
crashed instead of rejecting cleanly.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A WINGMAN_URL exported in the shell pointed the vite proxy at a remote
inference server, bypassing task serve and its store routes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… env

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A first-sync snapshot of a long chat (or one message with large tool
results) exceeded the server's 1 MiB per-frame cap and died in the
line scanner as '400: token too long'. The client now splits deltas
into hash-chained frames of ~4 MiB plaintext each, the server cap is
8 MiB (scanner overflows now answer 413 instead of 400), and
compaction keeps the whole snapshot by compacting at the batch's first
seq rather than the head.

Workspace file sync cap raised 64 → 256 MiB for large office documents
and notebook media; chat-attachment blobs were never capped.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Chunking cannot split a single log entry, so one message with megabytes
of pasted text or tool results needs the headroom; normal frames stay
at ~4 MiB plaintext.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The mirror (log.jsonl, target.json) is plaintext locally — the PIN only
protects the server copies — but loadAll blocked on whenReady, so a
locked session showed an empty chat list until unlock. Server-mode
loading is now session-driven: mirror while locked or errored, pull on
ready. Also coalesce concurrent pull() calls; the old flow ran two
racing pulls at unlock (initial load resuming plus the ready
subscription), which could double-apply events.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Nothing deployed uses the old formats, so: frames must carry the
versioned header (no headerless fallback), keystores are version 2
with a required kid, legacy chat.json promotion is gone, and the
migrateToServer reconciliation module is deleted — crash-recovered
pending targets flush through the normal save path, which storeSession
now kicks directly on ready.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
chat.json is what today's deployments write when the store is off; a
deployment that turns the store on must sweep that data up. Promotion
lives in hydrateFromOpfs as before, but blob upload now happens inline
on a chat's first flush instead of via the deleted migration module.
The truly never-deployed compat (headerless frames, keystore v1) stays
removed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Also self-heal when the head claims synced but the local history file
is missing (cleared cache, lost file): forget the head so the next
pull refetches from seq 0 instead of leaving the chat invisible.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
adrianliechti and others added 2 commits July 2, 2026 00:43
Data loss / corruption:
- flushChat clears the pending target only if a newer save didn't
  replace it mid-flight; flushPending re-runs when work arrived during
  the flush and no longer aborts the whole loop on one failing chat
- saveChat persists locally before any network I/O (blob uploads moved
  into flushChat), so offline saves with attachments survive
- replayLog's init entry now resets state — compaction snapshots
  applied over a baseline no longer duplicate every message
- per-chat operation lock serializes pull-applies, flush posts, and
  compaction on the same history; the 409 recovery path uses the
  in-lock variant; already-applied events are skipped instead of
  misread as gaps; concurrent maybeCompact can no longer clobber
  freshly appended entries when rewriting the local history

Server:
- the head file is now the append commit point (seq + committed size):
  uncommitted orphan lines from a failed batch are invisible to readers
  and truncated before the next append, instead of permanently forking
  the seq/hash chain for every device
- storage identity uses the same normalized value as /me (trim + email
  fallback) — a padded or whitespace-only X-Forwarded-User no longer
  splits one user across directories or funnels several users into one

Resilience:
- deletions are journaled in sync state and retried until the server
  confirms; pulls skip journaled deletions instead of resurrecting them
- session init errors retry every 15s instead of disabling sync for
  the tab's lifetime; saves during locked/error sessions stash a local
  pending target instead of hanging or rejecting
- a single log entry too large for any frame fails fast with a clear
  persistent error instead of hammering the server with 413s

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@adrianliechti
adrianliechti force-pushed the main branch 2 times, most recently from d13d26e to ff36f5e Compare July 8, 2026 22:05
@adrianliechti
adrianliechti force-pushed the main branch 3 times, most recently from eb3f1de to b8556ac Compare July 16, 2026 21:16
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