Skip to content

Local-first, dids, wasm + OPFS, flutter, iroh, dht#1148

Draft
joepio wants to merge 735 commits into
developfrom
did
Draft

Local-first, dids, wasm + OPFS, flutter, iroh, dht#1148
joepio wants to merge 735 commits into
developfrom
did

Conversation

@joepio

@joepio joepio commented Mar 3, 2026

Copy link
Copy Markdown
Member

@gitguardian

gitguardian Bot commented Mar 3, 2026

Copy link
Copy Markdown

⚠️ GitGuardian has uncovered 1 secret following the scan of your pull request.

Please consider investigating the findings and remediating the incidents. Failure to do so may lead to compromising the associated services or software components.

🔎 Detected hardcoded secret in your pull request
GitGuardian id GitGuardian status Secret Commit Filename
26549932 Triggered Generic High Entropy Secret dd771c2 lib/src/db/test.rs View secret
🛠 Guidelines to remediate hardcoded secrets
  1. Understand the implications of revoking this secret by investigating where it is used in your code.
  2. Replace and store your secret safely. Learn here the best practices.
  3. Revoke and rotate this secret.
  4. If possible, rewrite git history. Rewriting git history is not a trivial act. You might completely break other contributing developers' workflow and you risk accidentally deleting legitimate data.

To avoid such incidents in the future consider


🦉 GitGuardian detects secrets in your source code to help developers and security teams secure the modern development process. You are seeing this because you or someone else with access to this repository has authorized GitGuardian to scan your pull request.

@joepio joepio self-assigned this Apr 28, 2026
@joepio joepio changed the title Dids & invite refactor Local-first, dids, wasm + OPFS, flutter, iroh, dht Apr 29, 2026
joepio and others added 22 commits June 19, 2026 15:36
Per-write redb commits use `Durability::None` (no fsync) for throughput; redb
only persists them once a *subsequent* Immediate commit (`flush()`) lands. The
native server flushes on a periodic tick (serve.rs), but the browser worker
never called flush at all — so every local write was rolled back to the last
durable commit on the next page open.

Online this was invisible (the server re-fetches and re-caches), but offline it
was data loss: after disconnecting (or any reload while the WS is down), drives
and resources read "Offline: resource not available locally" because the OPFS
cache had silently reverted to empty.

- wasm: expose `ClientDb.flush()` → `Db::flush()`.
- worker: flush on a 1s tick whenever there have been writes since the last
  flush (mirrors the server; one fsync amortises a sync burst; idle tabs no-op).
- e2e: regression test — a cached drive resolves from the ClientDb after a
  reload while offline.

Claude-Session: https://claude.ai/code/session_01PfuLDVj966Lf5UGY497s2X
Guards the cold-load path: device 1 creates a folder, then a brand-new browser
context (empty local DB, same agent) opens the drive and must see the folder —
the drive sync populates the local index and/or the collection falls back to
the server `/query`. Locks in the scenario the OPFS durability fix restored, so
a future regression in cold-load/cross-device sync is caught.

Claude-Session: https://claude.ai/code/session_01PfuLDVj966Lf5UGY497s2X
The Sync page "Local DB" toggle writes atomic-disable-client-db=1; on the
next load App.tsx skips initClientDb, so every read goes to the server. That
path was previously masked by the OPFS cache, so a server-only regression
could hide until a user toggled Local DB off. Cover it for both a dev-drive
and a regular UI-created drive: create a child, disable Local DB, reload, and
assert the child still renders from the server's parent=<drive> query.

Both pass today — a normally-created drive's children are indexed clean (no
?drive= hint mismatch on the DID), so the server-only cold load surfaces
them. Locks that in against future query-path or DID-indexing regressions.

Claude-Session: https://claude.ai/code/session_01PfuLDVj966Lf5UGY497s2X
The chat input was `disabled={!isProviderAvailable(...)}`, which made the
whole editor read-only whenever the active model's provider was unreachable
— e.g. Ollama down at localhost:11434, or no OpenRouter key. Worse, this
only surfaced after `atomic.ai.setupComplete` was already true (the
AISetupPanel overlay had stopped showing), so the user got a silently
non-typeable input with no explanation and no way forward.

Now the input is always editable: you can compose a message even while a
provider is unreachable or still being set up. Only the SEND button is gated
on an available provider, and it re-enables automatically when the provider
comes back (the Ollama reachability check polls). A notice above the input
states the actual reason (Ollama unreachable / no OpenRouter key) with a
"Set up a model" button that re-opens the provider setup.

Claude-Session: https://claude.ai/code/session_01PfuLDVj966Lf5UGY497s2X
Follow-ups to the always-typeable input change:

- Ollama "Checking server…" no longer sticks forever. `useIsOllamaUrlValid`
  returned a bare boolean, so callers derived `checking` as `urlSet && !valid`
  — which stays true once a check FAILS, not just while it's in flight. The
  hook now returns `{ valid, checking }` with a real settled/in-flight
  distinction, so a failed Ollama check shows "Not responding".

- The provider-unavailable notice moves ABOVE the input box (it was rendering
  inside the bordered input, which looked odd), and its action is now a proper
  Button instead of a bare styled span.

- The model selector shows the provider in the subtitle ("OpenRouter • …",
  "Ollama (local) • …") and includes it in each option's search text, so
  typing "ollama" or "openrouter" surfaces all of that provider's models.

Claude-Session: https://claude.ai/code/session_01PfuLDVj966Lf5UGY497s2X
…WASM

Root cause: a server snapshot (a resource GET/SUB, or a `/query` collection
page) can arrive over the WS BEFORE the lazily-loaded Loro WASM has finished
initializing. `importLoroUpdate` then buffers the bytes instead of
materializing them — and nothing guaranteed the buffer was ever applied, so
properties read as undefined. Two visible symptoms, one cause:

  - A Drive/Folder rendered as a bare `did:ad:…` subject with no class, no
    title, `loading=false` AND `error=null` (a silently-broken resource), or
    stuck on a spinner sitting on top of already-materialized data.
  - The drive's children sidebar landed empty ~half the time:
    `fetchPageFromServer` read `totalMembers`/`members` off the still-buffered
    `/query` resource, got undefined, threw `total-members is not a number`,
    and the throw was swallowed by the caller's `.catch` — no query, no error,
    an empty collection.

Fixes:
  - resource.ts: when a snapshot is buffered (Loro not yet loaded), register a
    `LoroLoader.onReady` callback that materializes the doc and re-notifies
    THROUGH THE STORE (useResource/useValue only re-render on store.notify, not
    the Resource's own eventManager). The `loading` getter reports `true` while
    a renderable-less buffer is pending; `getLoroDoc` clears it once the
    materialized cache has a class.
  - collection.ts: `await enableLoro()` + force-materialize the `/query`
    resource before reading `members`/`totalMembers`, so a snapshot that beat
    Loro no longer yields an empty page.
  - store.ts: OPFS cold-load guard treats a server-managed-skeleton-only hit
    (parent/drive/createdAt/lastCommit, no class) as a miss; offline path waits
    briefly for the WS instead of instantly failing a reload.
  - websockets.ts: a SNAPSHOT-flagged UPDATE replaces seeded partial Loro state
    so the authoritative snapshot wins over an earlier partial SUB push.
  - SideBarDrive.tsx: gate the children list on the reactive `useChildren`
    state, not `driveResource.isReady()` (a proxy read the React Compiler
    memoizes on the stable ref, so the list stayed empty after ready flipped).
  - useChildren.ts: one child failing to load no longer rejects the whole
    Promise.all and blanks the sidebar.
  - vite.config.ts: exclude `@tomic/lib`/`@tomic/react` from optimizeDeps so a
    watch-rebuilt `dist` is served directly (the pre-bundle cache is keyed on
    the lockfile hash, not dist content, so changes were invisible until
    `--force`).

Tests: loro-load-race, opfs-incomplete-snapshot, snapshot-replace (unit);
did-folder-reload, local-db-off-did-render (e2e).

Claude-Session: https://claude.ai/code/session_01PfuLDVj966Lf5UGY497s2X
Configure fastembed and ONNX Runtime (ort) with explicit feature sets
(rustls-tls, download-binaries, …) instead of crate defaults. Adjust the
Dagger release pipeline accordingly: install protobuf-compiler, and build the
aarch64-unknown-linux-musl target with the portable `light` feature set since
the fastembed/ort vector-search stack ships no ONNX Runtime binaries for
aarch64 musl.

Claude-Session: https://claude.ai/code/session_01PfuLDVj966Lf5UGY497s2X
…tch-up

CodeBlock's copy button called `navigator.clipboard.writeText` directly, which
browsers reject on insecure origins (local HTTP hosts) — the copy failed
silently. Fall back to a hidden-textarea + `execCommand('copy')`, and if that
also fails, select the code block and show an actionable error toast.
Regenerate the wuchale locale catalogs (de/en/es/fr) to pick up the new string
plus the AI chat strings added earlier this session.

Claude-Session: https://claude.ai/code/session_01PfuLDVj966Lf5UGY497s2X
vite.config.ts: expand the react-compiler comment with why we stay on the
Babel plugin (oxc-transform's `reactCompiler` is a no-op; the archived
community oxc port emits duplicate helpers that break multi-component files).
planning: link the nextgraph-interop proposal.

Claude-Session: https://claude.ai/code/session_01PfuLDVj966Lf5UGY497s2X
Three committed files failed the data-browser `format-check` (pre-existing
drift, unrelated to any feature change). Reformat them so `pnpm lint` passes
cleanly. Whitespace/style only — no logic changes.

Claude-Session: https://claude.ai/code/session_01PfuLDVj966Lf5UGY497s2X
`docs/atomic.css` was an empty file removed in 547f6a8 as "unreferenced"
debris — but `book.toml`'s `additional-css` DID reference it, so once that
deletion landed on this branch the mdbook html backend fails with
"failed to open `atomic.css` for hashing" and the CI docs build exits 101.
The file was empty (no styling), so completing the cleanup by dropping the
reference is the correct fix; `mdbook build` passes again.

Claude-Session: https://claude.ai/code/session_01PfuLDVj966Lf5UGY497s2X
`migrate_from_sled` silently migrated ZERO user resources from any pre-v3 sled
store, then renamed the source dir to `.bak` and reported success. Validated
against a real v2 backup: 231 of 62,212 resources survived (only bootstrap
vocabulary). After these fixes all 61,804 user resources migrate intact.

Two independent bugs, each hidden behind the last (proof this path was never
run against real v2 data):

1. migrate_from_sled read Tree::Resources (`resources_v3`) directly WITHOUT
   first running `migrate_maybe`, so a v2 store's data — which lives in
   `resources_v2` — was never seen. Run the schema migrations (v0→v1→v2→v3)
   in place before reading.

2. `ValueV2` deserialized the JSON datatype as variant `Json`, but v2 data on
   disk was written when it was named `JSON` (the DID refactor #1139 renamed
   it). rmp_serde tags enum variants by name, so every resource holding a JSON
   value failed with `unknown variant 'JSON'`. Restore the historical wire name
   with `#[serde(rename = "JSON")]`.

Also tolerate the removed `YDoc` variant (the unreleased Yjs experiment) so a
dev store holding those values doesn't abort the migration.

Migration code is db-sled-gated; the default db-redb build is unaffected.

Claude-Session: https://claude.ai/code/session_01PfuLDVj966Lf5UGY497s2X
Two plumbing gaps meant the (now-correct) migration never fired for a real
user upgrading from a pre-redb server:

1. The migration is `#[cfg(all(db-redb, db-sled))]`, but the server only
   enabled `db-redb` — so `migrate_from_sled` was compiled out of every
   release. Add `db-sled` to the server's atomic_lib features. It's inert
   unless a legacy sled store is present, so fresh installs pay nothing.

2. The auto-migration looked for `<store>/sled/`, but pre-redb servers stored
   the sled DB directly at the store root (`store/db`, `store/conf`). Detect
   that legacy layout (db + conf at root, no `sled/` subdir) and relocate the
   sled files into `sled/` before migrating — otherwise the trigger never
   fires, and `migrate_from_sled`'s rename-to-`.bak` would rename the whole
   store dir and clobber the freshly-written redb.

Validated end-to-end with the DEFAULT binary against a real legacy-layout v2
backup: 62,212 resources migrate, leaving `atomic.redb` + a preserved
`sled.bak/`.

Claude-Session: https://claude.ai/code/session_01PfuLDVj966Lf5UGY497s2X
The /app/invite route built the invite subject and handed it to ResourcePage,
which picks the view component from the resource's `isA`. When the invite
resource loaded without its class materialising — a server snapshot arriving
before Loro WASM is ready, especially in an insecure context (plain HTTP on a
non-localhost origin, where the WASM is unstable) — `selectComponent` fell
back to `ResourcePageDefault`. The user then saw the raw, class-less resource
(an "agent"-looking blob with a `destination` but no Accept button and no
redirect) instead of the invite welcome screen.

The route already KNOWS the subject is an invite, so it shouldn't depend on
class detection at all. Render InvitePage directly. The accept flow only needs
the token (which is in the URL), not the resource's class, so this is robust
against the materialisation race. Verified live: InvitePage renders with a
working Accept button even when the resource is only partially loaded.

Claude-Session: https://claude.ai/code/session_01PfuLDVj966Lf5UGY497s2X
Reconstruction gaps from did/did-rebased2 divergence found via compile checks:
- lib/src/sync/mod.rs: add 'pub mod policy;' (policy.rs existed, decl didn't)
- browser/lib/src/index.ts: re-export schema.js + schema-lock.js
…ent + old scratch files)

These ~39 files were not in your pushed 'did' branch and predated your June-22 work.
They were stale did-rebased2 content my transcript replay wrongly merged in, and were
the sole source of all test/typecheck failures. Recovery now = did + your June 22-26 work.
…+ truncated fns)

Reconstructed from call-site contracts + atomic-saas backend (all inference,
marked [RECOVERY-RECONSTRUCTED]):
- helpers/cloud/{api,binding,session,enrollmentApi,index}.ts (never captured)
- completed helpers/cloud/recovery.ts:getRecoverySecret
- completed helpers/saasRecovery.ts:getSaasApiBase + createSaasEnrollment
- restored SettingsAgent.tsx sign-in state/handler + component imports
data-browser typecheck now clean.
- GettingStartedFlow: when arriving from the cloud portal (?from_saas=true),
  skip the generic Create/Sign-in choice, prefill the username from the
  verified account email (getCloudAccount), and auto-enroll the new drive in
  cloud sync (createCloudSyncEnrollment) after creation.
- NewIdentitySection: new defaultProfileName prop to prefill the profile field.
- helpers/cloud/api.ts: point getCloudApiBase at the real control-plane backend
  (:3030/api), not the portal (:49237). Verified against the running atomic-saas.
joepio added 30 commits July 14, 2026 16:48
… normal view

A fork copied the original's `read` / `write` grants, so forking a *published*
page produced a draft carrying that page's `read: [PUBLIC_AGENT]` — a draft that
was public the moment it was created, which is the one thing a draft must never
be. A fork now inherits its privacy from the folder it lands in, and a merge no
longer pushes the fork's ACL back onto the original: publishing is a move, not a
copied grant.

A draft carries its content class, so it already renders through that class's own
view. Send `Edit as draft` to the normal view rather than the edit form, and add
a DraftBar above it — otherwise nothing on screen says you are not looking at the
original. The bar links home and offers Merge / Discard.

Forking a Drive is no longer offered: a Drive is a container, not content.

Adds the SDK sugar this wants: `resource.fork(parent)`, `draft.mergeIntoOriginal()`
and `resource.isDraft`. Named `mergeIntoOriginal` because `Resource.merge` already
means something else — folding another Resource's propvals into this one.
Drop the rounded border so it spans the view it sits above rather than floating
in it.
… original

A plain squash merge wrote every draft property onto the original, so a property
the draft never touched still carried its fork-time value — merging silently
reverted any edit the original received since the fork. Two people using the flow
(one edits, the other's draft merges later) lose data with no warning.

A fork now records `forkBase`: the original's content properties at fork time, as
a self-contained JSON snapshot. Merging writes only the properties the draft
actually changed relative to that base, so an untouched property is left alone and
a concurrent edit survives. Where both sides changed the same property it is a
conflict — surfaced via `diffDraft`/`DraftChange.conflict`, counted in the
DraftBar, and `onConflict: 'throw'` aborts — rather than silently resolved.

Snapshot rather than replaying the forked-from commit: commit retention is optional
node policy, so a snapshot works on a pruned or offline node.

Tests add the two-agent concurrent-edit scenario that reproduced the bug, plus
conflict detection and the "untouched property is not a change" invariant.

The DraftBar shows the change/conflict counts and confirms before overwriting a
conflicting property. `resource.fork()` / `resource.mergeIntoOriginal(opts)` /
`diffDraft` are the SDK surface.

Design + remaining flow gaps (discovery, diff UI, suggest-for-non-writers,
reject-with-reason): planning/drafts-and-suggestions.md
Adds an end-to-end spec for the draft flow (fork → edit → merge, and discard)
against a real server — the first e2e coverage for drafts, and it exercises the
`drafts` ontology being seeded on the server and in the WASM client store.

Writing it surfaced a real bug the unit tests couldn't: the DraftBar computed
`diffDraft(resource, original)` directly in the render body, so the React Compiler
memoized the result on the two Resource proxy references. Those refs don't change
when a resource mutates internally, so the change/conflict counts froze at first
render — the bar always said "No changes yet" and the Merge button stayed disabled
no matter what you edited. Subscribe to each resource's latest commit and key a
useMemo on it so the diff recomputes when either side commits.

The spec uses a Folder (title is a plain `name` propval). A DocumentV2 keeps its
title/body in a Loro `doc` container that the propval diff can't see — forking one
loses the body and the propval merge can't carry it. That doc-container limitation
is already recorded in planning/drafts-and-suggestions.md as needing an oplog merge.
…m (no inbox)

A PendingDrafts bar on a resource's normal view runs a reverse query
(originalSubject == thisResource, drive-scoped) and lists the drafts proposing
changes to it, each a link to review. This is how a reviewer discovers a draft
without any inbox or push: a same-drive draft rides ordinary drive sync into the
reviewer's own replica, where a local query finds it. It only shows drafts the
reviewer can already read — a proposal on someone else's drive is invisible here
by design, and needs the cross-agent delivery primitive (phase 2).

Also fixes `useCollection` to pass `drive` through to the CollectionBuilder — the
type allowed it but it was dropped, so indexed queries couldn't be drive-scoped.

e2e covers the reviewer opening the original and seeing the pending draft.
A DocumentV2's text lives in a Loro `doc` container that the propval-based
fork/diff cannot see, so drafting a document previously copied no body and
offered no way to merge one. Fork now seeds the draft's Loro doc from the
original and records `forkVersion` (the version vector at fork time); merge
exports only the draft's ops since that version and imports the delta into the
original — a true op-level merge, so a concurrent body edit on each side both
survive. The body merge is kept independent of the three-way propval merge by
snapshotting and restoring the original's propvals around the delta import.

The draft bar offers Merge on the strength of a body edit alone (`hasBody`,
no changed property). `Edit as draft` is gated off Canvas, whose content lives
in stroke lists rather than the `doc` container the fork seeds, so forking one
would silently lose its body.

Covered by a new `drafts.test.ts` CRDT case and an `edit a document body as a
draft` e2e; existing drafts unit + e2e suites stay green.

Claude-Session: https://claude.ai/code/session_01VddwtxveM8Zqsf765aQSHY
…genda-minutes)

Brings the meeting agent's work into the did branch: one Meeting-only resource
carrying rich-text Agenda/Notes/Minutes plus child chat messages, a dedicated
Meeting page, an explicit prepare-then-start lifecycle, a follow-along panel,
and a clean cutover from the temporary Meeting+ChatRoom shape (no backwards
compatibility). Adds the `meeting.json` default ontology and registers it in
populate.rs; updates chatroom.json and the dataBrowser ontology accordingly.

Integration notes:
- The `meeting` action in the actions registry takes the meeting agent's newer
  `startMeeting()` -> `openMeetingPanel(meeting)` flow (over the did branch's
  earlier no-arg variant), matching their ActionContext wiring.
- Locale catalogs regenerated with a single-writer wuchale extraction so the
  union of draft and meeting strings is consistent (no merge-marker corruption).
- planning/README.md keeps both the meetings (Built) and drafts (Active) rows.

Verified: data-browser typecheck clean; drafts (10) + meetingLifecycle (3) unit
tests green; drafts (4) + meetings (3) e2e green against the reseeded server.

Claude-Session: https://claude.ai/code/session_01VddwtxveM8Zqsf765aQSHY
…blished content)

"Draft" was overloaded: the class *required* an originalSubject, so the only
thing you could make was a proposed change to something that already exists —
there was no way to draft brand-new content, and the word misdescribed the
feature. Split the two orthogonal capabilities the design already identified:

- Fork (renamed from Draft): a proposed change to an EXISTING resource. Carries
  the content's classes alongside the Fork class + originalSubject, and merges
  onto the original (three-way propvals + CRDT body). Class `Draft` -> `Fork`,
  ontology `drafts` -> `forks`, `isDraft` -> `isFork`, `diffDraft`/`mergeDraft`
  -> `diffFork`/`mergeFork`, DraftBar/PendingDrafts -> ForkBar/PendingForks,
  actions editAsDraft/mergeDraft -> editAsFork/mergeFork, forks live in a per-
  drive Forks folder. Behaviour is unchanged; only names and copy move.

- Draft is now new, unpublished content — defined by LOCATION, not a class: any
  resource in the drive's private Drafts folder. A new `New draft` action creates
  content there; publishing is just moving it somewhere publicly readable
  ("visibility is location"), no class to add or strip. Answers "how do I make a
  new draft that isn't based on an existing resource".

Discovery: forks proposing a change to a resource still surface as the
PendingForks bar on that resource; the Drafts/Forks folders appear in the drive
tree.

Ontology subjects change (classes/Draft -> classes/Fork, ontology/drafts ->
ontology/forks), so existing stores need --repopulate-defaults; pre-release, no
data migration.

Verified: lib fork tests (10) green; forks e2e (4) + new-draft e2e (1) green
against the reseeded server; typecheck + lint + format clean on the changed
files.

Claude-Session: https://claude.ai/code/session_01VddwtxveM8Zqsf765aQSHY
…gy split

Renames the fork mechanism from "Draft" to "Fork" throughout the design record,
and documents Draft as its now-separate meaning: unpublished new content defined
by location (a resource in a private Drafts folder), published by moving. Updates
the code identifiers referenced by the doc (diffFork/mergeFork/forks.ts/ForkBar/
PendingForks) to match the renamed symbols.

Claude-Session: https://claude.ai/code/session_01VddwtxveM8Zqsf765aQSHY
…ackage

Five specs had gone stale against @tomic/lib API changes, failing the e2e
package's typecheck (all still passed at runtime — the offending code was inside
page.evaluate, which runs as plain JS):

- clientdb-edit-persistence: `set`/`save` dropped their trailing `store` arg;
  `fetchResourceFromClientDb` is now private — reach it through a typed cast.
- multi-property-filter: assert the drive subject non-null at the evaluate arg
  sites (it is always set by the test).
- offline-create-then-online: `createDrive`'s 2nd arg is now `CreateDriveOpts`,
  so pass `{ description }` rather than a bare string.
- opfs-init-perf: drop a now-unused `@ts-expect-error` (OPFS `entries()` is typed).
- sync: the outbox is Loro-delta based now (no `commits` list) — surface the
  genesis/baseVersion fields in the failure diagnostic; pass the waitForFunction
  arg as an object so the typed overload resolves.

Verified: e2e `tsc --noEmit` clean; all 9 tests across these specs pass.

Claude-Session: https://claude.ai/code/session_01VddwtxveM8Zqsf765aQSHY
The data-browser seeds a fixed list of default ontologies into the WASM client
store at startup (`bootstrap.ts`) so property definitions resolve locally without
a network fetch. `meeting.json` and `forks.json` were added to `lib/defaults`
(and seeded server-side) but never added to this client list, so the client had
no local definition of e.g. `currentMeetings`.

The symptom: ending a meeting took ~10s. `endMeeting` sets `currentMeetings` on
the drive with validation on, which calls `store.getProperty(currentMeetings)`;
missing locally, that fell through to fetching the `https://atomicdata.dev/...`
subject, which times out (~10s) before validation is skipped. Same latent cost
for any validated write of a meeting/fork property (meeting start on a cold
client, fork edits).

Bootstrapping both ontologies makes those lookups resolve locally. Measured:
meeting end 10.4s -> 0.3s, with no change to the meeting code itself. CI already
copies the whole `lib/defaults` dir into `lib-defaults`, so the new imports need
no pipeline change.

Claude-Session: https://claude.ai/code/session_01VddwtxveM8Zqsf765aQSHY
Joining and leaving a meeting were pure local state (follow/unfollow), so the
meeting chat only ever showed "Started"/"ended" — you couldn't tell from the
minutes who actually attended. Now a joiner's client posts a follow-event
message to the meeting: "Joined the meeting." when they join, "Left the
meeting." when they explicitly leave.

- The signal is `followedSession` (the meeting of the leader a joiner follows);
  the leader is excluded (their arrival is the Start marker).
- A departure is recorded only while the meeting is still live —
  `postMeetingLeave` skips it once `meetingEndedAt` is set, so a leader's End
  doesn't make every follower's auto-unfollow spam "Left" on top of
  "The meeting has ended.".
- sessionStorage (per meeting, cleared on tab close) dedupes across reloads so
  a refresh mid-meeting doesn't re-announce, mirroring the leader's
  `meetingStorageKey`.

Covered by a new two-context e2e ("records join and leave in the meeting
chat"). Also reflows forks.spec.ts, which a path-quirk had skipped in the
earlier format check.

Claude-Session: https://claude.ai/code/session_01VddwtxveM8Zqsf765aQSHY
…r focus

Lands the remaining meeting UI work: the follow-session panel header shows a
button-styled "Notes" link (was the plain "Open notes" link) and a subtle
Leave/End button; the Meeting page focuses the editor after committing the
title; the drive-structure helper and resource sidebar account for the Meeting
class. Updates the follow-along e2e to click "Notes" so it matches the renamed
control.

Verified: data-browser typecheck clean; all four meetings.spec tests pass
(including the follow-along and the join/leave recorder).

Claude-Session: https://claude.ai/code/session_01VddwtxveM8Zqsf765aQSHY
`fetchPersonalDriveSubject` fetched the Agent from the server and then read it
back with `getResourceLoading`, which can return the resource still in its
loading state — so `personalDrive` / `drives` could read empty and the home
drive would wrongly fall back to `initialDrive` (breaking Favorites on a dev
drive). Await `store.getResource` instead: it returns the resolved resource
from the local-first store, fetching only when needed.

Claude-Session: https://claude.ai/code/session_01VddwtxveM8Zqsf765aQSHY
…havior

Brings the public docs in line with what the implementation now does:
- did.md: genesis is a Self-Verifying Genesis Certificate (v2) — identity is
  derived from the inline binary cert, verifiable offline without a commit fetch.
- authentication.md: WebSocket auth uses the v2 binary-first protocol (AUTH
  frame tag 0x01 carrying the serialized credentials).
- websockets.md: both subscription pushes and direct GET responses now carry
  the lastCommit identifier as commit_id; drops a stale planning-doc reference.
- roadmap.md / commits/concepts.md: prose refresh.

Claude-Session: https://claude.ai/code/session_01VddwtxveM8Zqsf765aQSHY
New planning note proposing Dashboards: composable views over data built on the
Table View filter pattern and the create_table LLM-authoring precedent. Adds it
to the planning index.

Claude-Session: https://claude.ai/code/session_01VddwtxveM8Zqsf765aQSHY
…network

Covers the offline signup path: with fetch disabled and a mock ClientDb, saving
a new Agent resource returns 'offline' and persists jsonAd + a Loro snapshot;
after clearing the in-memory store, the agent resolves back from local storage
(not a server fetch), with its name intact. Cleaned up debug logging and an
`any` cast from the original draft.

Claude-Session: https://claude.ai/code/session_01VddwtxveM8Zqsf765aQSHY
First slice of planning/virtual-drive.md, hosted in the Tauri desktop app (not
the headless server): a local NFS v3 server, bound to 127.0.0.1, that the OS
mounts so your Atomic drives appear as a filesystem. The mount root lists every
Drive the node can see; Folders are directories; Files stream their blob bytes.

- `desktop/src/vfs.rs`: `AtomicNfsFs` implementing `nfsserve::NFSFileSystem`
  against the embedded `atomic_lib` store — `readdir`/`lookup`/`getattr` over the
  `parent`-query hierarchy, `read` slicing blob bytes from `Tree::Blobs` lazily
  (never on readdir/getattr), an in-memory fileid<->subject map, and filename
  sanitization at the materialization boundary.
- Started from the embedded server's `on_ready` hook in `lib.rs`, desktop-gated
  off Android/iOS (neither can mount NFS — they use provider APIs). Logs the
  exact `mount` command; auto-mounting is a follow-up.

Read-only for now (`capabilities()` is ReadOnly; mutating methods return ROFS),
but built read-write-ready per the request: the id map allocates ids, names are
already sanitized, and the fs carries a commit `source_id` for echo suppression —
so the write path is filling in stubs, not a reshape.

Covered by unit tests (store hierarchy → dirs/files, full + partial blob reads,
name sanitization). Desktop crate builds; nfsserve/tokio/hex/async-trait added
as desktop-only deps.

Claude-Session: https://claude.ai/code/session_01VddwtxveM8Zqsf765aQSHY
Gives the app ownership of the mount lifecycle instead of auto-starting the NFS
server at boot. The desktop backend exposes three Tauri commands —
`virtual_drive_start` / `virtual_drive_stop` / `virtual_drive_status` — backed by
a `VfsController` that runs the NFS server on its own thread and stops it by
dropping a shutdown channel. A new "Virtual drive" section in App Settings
(shown only in the desktop Tauri app) toggles it and shows the exact `mount`
command to copy.

- `vfs.rs`: `VfsController` (start/stop/status) + `mount_command()`; `serve`
  now selects on a shutdown receiver. Auto-start removed from the boot hook.
- `lib.rs`: manage the controller and register the commands, desktop-gated.
- Frontend: `helpers/virtualDrive.ts` (invoke wrappers, availability gate) and
  `Settings/VirtualDriveSettings.tsx`.

Rust vfs tests still green; data-browser typecheck + lint clean.

Claude-Session: https://claude.ai/code/session_01VddwtxveM8Zqsf765aQSHY
The copy-paste `mount` command was bad UX. On macOS a normal user can mount NFS
to a folder they own without sudo (verified: an unprivileged mount attempt fails
with "connection refused", not a permission error), so the app does it directly.

- `VfsController::start` now starts the NFS server, waits for it to listen, and
  runs `mount` to `~/AtomicDrive`; `stop` unmounts and stops the server. Added
  `open_mount()` (Finder/Explorer) and a `virtual_drive_open` command. Status is
  now { running, mounted, mount_path } — the mount command string is gone.
- Settings UI: a single Turn on / Turn off toggle, the mounted path, and an
  "Open" button. No terminal, no command to copy.

Linux/Windows: `mount` there usually needs privilege (Linux) or a different
invocation (Windows); the error surfaces in the UI for now — auto-mount on those
platforms (or native File Provider APIs) is a follow-up.

Rust vfs tests green; data-browser typecheck + lint clean.

Claude-Session: https://claude.ai/code/session_01VddwtxveM8Zqsf765aQSHY
Makes the NFS mount read-write. `create`/`write`/`mkdir`/`remove`/`rename`/
`setattr(truncate)` map onto signed commits, so editing a file in Finder writes
it back as an Atomic commit that syncs with the user's rights.

- Writes stage bytes in RAM keyed by fileid; a background flusher commits a file
  once its writes have been quiet for 500ms (commit coalescing — an editor's
  write burst becomes one commit, not one per syscall). read/getattr consult the
  staging buffer so write-then-read is consistent before the flush lands.
- Committing hashes the bytes (BLAKE3) → blob, then updates the File resource's
  internalId/blob/filesize/downloadURL and saves. Files are created with the full
  valid shape a server upload produces (File requires downloadURL).
- Commits are signed by the store's default agent, which the desktop app points
  at the signed-in user via `adopt_agent` — no separate server agent, and the
  writes carry the user's identity so peers accept them.
- Admission caps (max file size, name length) + filename sanitization applied on
  the write boundary. Dropped the `ro` mount flag.

Deferred (per planning/virtual-drive.md): content-defined chunking (write
amplification), disk-backed fileid map, readdir cursor, conflicted-copy naming.

Covered by an integration test: create → write → staged read → flush → persisted
commit, plus mkdir, rename, truncate, remove. All 4 vfs tests green.

Claude-Session: https://claude.ai/code/session_01VddwtxveM8Zqsf765aQSHY
…s remounts)

NFS clients and the OS cache a file's numeric id and assume it never changes, but
the map was in-memory and rebuilt each mount, so a file could get a different id
after turning the drive off and on. Persist the `fileid <-> subject` map in the
store's `PluginMeta` tree (namespaced keys + a counter), with the in-memory
HashMaps kept as a cache in front so getattr/read don't hit the kv store on every
call. `remove` reclaims the entry so the map doesn't accumulate dead ids.

Covered by a new test: the same subject keeps its fileid across a fresh
`AtomicNfsFs` with an empty cache. All 5 vfs tests green.

Deferred #2 of 4 done (planning/virtual-drive.md).

Claude-Session: https://claude.ai/code/session_01VddwtxveM8Zqsf765aQSHY
`readdir` re-fetched and re-sorted a folder's entire child set on every page, so
listing a large folder was O(N²). Build the listing once per enumeration (a
fresh `ls` starts at cookie 0), cache it per directory, and page from the cache;
create/mkdir/remove/rename invalidate the affected directory so changes show
immediately, and a short TTL bounds staleness within a single enumeration.

This removes the per-page re-scan (O(N) per enumeration). The store-level cursor
the doc describes (O(log N) per page, also useful for browser pagination) stays
deferred — it needs a query-engine change, not a VFS-only one.

New test pages a 25-entry directory in chunks of 10 and gets every entry once.
All 6 vfs tests green. Deferred #3 of 4 addressed at the VFS layer.

Claude-Session: https://claude.ai/code/session_01VddwtxveM8Zqsf765aQSHY
…unchanged

Content-addressed no-op skip in the flush path: if the staged bytes hash to the
File's current internalId, there's nothing to commit, so skip the blob write and
the commit. Avoids redundant churn when an editor saves a file it didn't change.

This is a partial mitigation for write amplification; the core large-file case
(a 1-byte edit rewriting a whole-file blob) still needs content-defined chunking,
which is a File-model change, not a VFS-local one. All 6 vfs tests green.

Claude-Session: https://claude.ai/code/session_01VddwtxveM8Zqsf765aQSHY
Large files (> chunk-max) are now split with FastCDC and stored as an
ordered list of `did:ad:blob:` chunk refs on a new `chunks` File property.
Small edits re-hash only the changed chunks; unchanged chunks dedupe by
hash. Small files keep the single whole-file blob path.

The server's download handler reconstructs chunked files by concatenating
their chunk blobs, both for the File-resource path and the
content-addressed `/download/files/{hash}` path (which finds the chunked
File by its whole-file internalId, since no whole-file blob is stored).

Claude-Session: https://claude.ai/code/session_01VddwtxveM8Zqsf765aQSHY
Opening the mount root on a real store (2578 drives, 2.1 GB db) took ~762s
and the mount appeared to hang. Four stacked costs, each O(store) or
O(entries²) on the hot readdir/lookup/getattr path, fixed:

- Directory-listing queries (`drives`, `children`) ran nested, invoking
  each resource's class extenders + a durable redb flush per entry. Make
  them non-nested — a listing only needs subjects, names come from a cheap
  per-entry read.
- Minting a stable fileid wrote 3 keys as 3 separate redb commits per
  entry (~hundreds per listing, contending with the live writer). Mint in
  memory and persist all new ids in one batched transaction off the hot
  path; `forget` drops any not-yet-persisted entry.
- Reading each entry's name went through the full `get_resource`, which
  re-decodes and decompresses the resource's entire Loro snapshot. Add
  `Db::get_resource_shallow` (materialized propvals only) and use it for
  all listing reads and `getattr`.
- `lookup` re-enumerated the whole directory on every call; the OS issues
  one per entry after a readdir, making a folder open O(entries²). Resolve
  from the cached listing instead (shared `ensure_dir_cached` helper).

Root listing of 2578 drives: ~762s -> ~0.01s cold, instant warm.

Claude-Session: https://claude.ai/code/session_01VddwtxveM8Zqsf765aQSHY
…the user

Refs #1154 (two-way filesystem binding). Building on the file/folder mount:

- Mirror the whole hierarchy: Folders are directories, Files are files, and
  every other resource (documents, tables, chatrooms, …) becomes a read-only
  macOS `.inetloc` link. Opening one hands off to Atomic desktop via an
  `atomic://open?subject=…` deep link, which the frontend now routes to
  `navigate(constructOpenURL(subject))` instead of the pairing flow.
- Scope the mount root to the signed-in agent's own drives (personalDrive +
  the agent's `drives` list) rather than every Drive in the store. A desktop
  node syncs drives from other peers, so Sudo listed thousands the user has no
  rights to — and opening one bounced the auth-gated UI to the welcome page.
  Signed out, the root is empty.
- Self-heal the OS mount: force-unmount any stale mount (left by a hard-killed
  run) before mounting, so toggling the drive on can't fail with EPERM.

Claude-Session: https://claude.ai/code/session_01VddwtxveM8Zqsf765aQSHY
`read` rebuilt the whole file from its blobs on every NFS read window, so a
sequential read-through of an N-byte file was O(N²) (and Loro-decoded the
resource per window). Add a single-entry read cache keyed by fileid + the
file's whole-file hash: a read-through reconstructs once, every window slices
the cache, and an edit (new hash) self-invalidates. `read`/`getattr` now use
the shallow (propvals-only) resource read, dropping the per-window Loro decode.
Sequential-read bench: 137 -> 387 MiB/s at 32 MiB, and it no longer degrades
with file size.

Adds opt-in `#[ignore]` benches (`mod benches`) for sequential read, save
latency, per-edit DB growth (documents the missing blob GC), and listing
latency — run with `--ignored --nocapture`. Planning doc updated with
implementation status, the numbers, and the remaining perf gaps (blob GC,
write amplification, true range reads).

Claude-Session: https://claude.ai/code/session_01VddwtxveM8Zqsf765aQSHY
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.

Consider switching ureq for something async (reqwest, hyper) Add metrics / Prometheus support

2 participants