Skip to content

feat(desktop-app): refresh GFS previews and folders live over the watch stream - #1503

Open
zach88 wants to merge 6 commits into
feat/gfs-event-subscriptions-authzfrom
feat/gfs-event-subscriptions-desktop
Open

zach88 wants to merge 6 commits into
feat/gfs-event-subscriptions-authzfrom
feat/gfs-event-subscriptions-desktop

Conversation

@zach88

@zach88 zach88 commented Nov 14, 2026 •

Copy link
Copy Markdown
Contributor

What

The desktop side of live GFS subscriptions: previews and folders refresh in place when the resource changes on the server, through the GFS watch stream, and the per-user entity-change stream is removed from the app.

  1. In-place preview reload: the preview body's remount key (uri:reloadToken:unavailable) is split from its content version.
    • Markdown keeps its source and scroll position while the new version downloads.
    • Image and video keep the visible object URL until the new one is committed, then revoke the old one (shared useInPlaceObjectUrl, which revokes each URL exactly once, StrictMode and unmount mid-download included).
    • A failed in-place download keeps the last version and shows a "could not update" banner. A 403/404 does not mark the file unavailable on its own; the authoritative resolve decides. A session-authority failure (401 or a session lifecycle code) drops the content.
    • Each body only renders content for the source it downloaded, so a URI swap without a remount never shows file A under file B. The plugin preview overlay gets the same split.
  2. Watch client in main (createGfsWatchClient): one client per process for GET/PUT /api/v1/me/gfs/watch.
    • One link at a time (Idle / Backoff / Opening / Ready) with a per-attempt epoch, so a late result of an old attempt is dropped. closeLink releases state before running any effect, and every effect is best-effort.
    • The GET uses raw fetch with only the open deadline and refuses redirects; the PUT goes through requestJson with a timeout longer than the PUT deadline.
    • Total outcome tables for the open and the PUT, keyed on status plus x-watch-error; additive jitter that never fires before a server Retry-After; capped PUT retries per episode.
    • Session handling: its own token generation, binding decoded from the token claims (never used to authorize), terminal latch deferred during a team hop, /me check on session expiry, and 409/426 treated as "unsupported until a newer token".
    • Owner registry merged by rank and registration order, a 64-key cap, frame-token gating for renderer IPC. Server keys are re-validated before they reach renderers.
  3. Wiring in main, IPC and preload: AppService feeds the client every session, team-hop and environment event. IPC gfsWatch:setSubscriptions / gfsWatch:stop and push channel gfsWatch:event accept trusted senders only and identify documents by the Electron main-frame token. The preload installs the event listener before the first invoke.
  4. Renderer:
    • Each window derives the set of GFS resources it shows (active preview, plugin preview overlay, the Files location, expanded sidebar folders), caps it at 64 keys by priority and sends it to main with a 150 ms debounce.
    • Events are handled with a per-key single-flight plus one pending pass. File keys refresh the open preview; dir keys refetch the listing without cancelling an in-flight page; accessChanged refreshes roots and sharing dialogs and marks listings stale; sessionExpired purges previews, the GFS cache and every Files breadcrumb.
    • The entity-change stream is removed end to end: authClient stream and parser, AppService state, IPC, preload bridge, types and the renderer registry.
  5. docs/architecture/live-entity-change-consumer-semantics.md describes the new consumer.

Why

Today the per-user stream sends a generic invalidation every 4 seconds by default, and each one makes the app re-resolve open previews and refetch active GFS queries, whether or not anything changed. Each reload remounts the body, so scroll and video position are lost and "Loading preview" flashes. With the watch, the desktop subscribes only to what is on screen and reacts only to what changed, and the preview updates without a remount.

How

  • After an accessChanged caused by a reauthorize close, watched keys are not re-resolved immediately; they converge with the resync of the next ready. Re-resolving every key on every reopen would repeat reads that the resync already does.
  • The renderer resets its dedupe on any session, identity, team or environment change, so main gets the list again after it cleared the owner lists. userId comes from auth.me.id.
  • Leaving the desired set never bumps a preview's generation while its tab stays open.

Testing

  • Model-based: watchClient.model.test.ts (fast-check commands, invariants at rest against an independent reference model; fixed seed, overridable with GFS_WATCH_MODEL_SEED / GFS_WATCH_MODEL_RUNS).
  • watchClient.scenarios.test.ts: scenarios against a real HTTP server with the real fetch and requestJson on a virtual clock.
  • appService.gfsWatch, ipc.gfsWatch, preload.gfsWatch: AppService through its real producers, real IPC and preload handlers.
  • Native Electron fixture (npm run test:native:gfs-watch-ipc): frame tokens across reload, subframes, renderer crash and window close.
  • Renderer: App.gfsWatch, in-place reload suites for each preview body, FilePreviewPage, plugin overlay, useGfsWatchSubscription, gfsWatchKeys, gfsWatchReaction, keyedSingleFlight.
  • A vitest setup file gives watch clients an inert transport, so unrelated AppService suites no longer send requests to a real local port.
  • At this head, on Node 24 with npm run verify:electron passing: tsc --noEmit clean; vitest run 485 of 488 files, 6011 of 6020 tests passed. The 9 failures are in 3 image-challenge suites this PR does not touch; they import @napi-rs/canvas, which desktop-app does not declare (only mcp-host does), and fail with Cannot find module.

Risks / follow-ups

Stack

Live GFS subscriptions ship as a stack. The server half and the desktop half must reach dev together: the server PRs retire the route the current desktop uses, and the desktop PR needs the new watch route.

# PR Base Scope
1 #1496 dev gfs-controller: drive name validation, principal_denied reason
2 #1497 dev host-context-controller: drive name validation (merge after 1)
3 #1498 dev control-api: shared building blocks, per-resource change trigger
4 #1499 3 control-api: watch registry, listener, process runtime
5 #1500 4 control-api: GET/PUT /external/gfs/watch
6 #1501 5 external-rest-api: GET/PUT /api/v1/me/gfs/watch
7 #1502 6 control-api + ERA: authorization and session push, retire the per-user entity-change stream
8 #1503 7 desktop-app: watch client, live previews and folders

1 and 2 are independent of the chain and safe to merge on their own (1 before 2, since the controller check in 2 mirrors the one in 1).

3 to 8 are one unit and should not be deployed partially:

  • 3 and 4 add groundwork; the only new runtime behavior is the gfs_resource_changed NOTIFY trigger, which nothing listens to yet.
  • 5 and 6 expose the route, but until 7 lands an authorization or session change does not cut the changed events of a stream that is idle (no op in flight). Do not ship 5/6 without 7.
  • 7 removes the per-user entity-change stream (ERA /api/v1/entity-changes/stream and control-api /external/entity-changes/stream), which the current desktop still uses. Do not ship 7 without 8.

zach88 and others added 6 commits November 13, 2026 08:29
Split the preview body's remount key from its content version. The body
key is now `uri:reloadToken:unavailable`, so a manual refresh, a
revocation or an unavailable file still remount the body; `reloadVersion`
reaches each body as a `contentVersion` prop that only re-runs the
download effect.

- Markdown keeps its source and scroll position while the new version
  downloads, and clears a previous error once a download succeeds.
- Image and video keep the visible object URL until the new one is
  committed, then revoke the old one. The lifecycle lives in the shared
  `useInPlaceObjectUrl` hook, which revokes each URL exactly once,
  including under StrictMode and on unmount mid-download.
- A failed in-place download keeps the last version and shows a
  "could not update" status banner. A 403/404 does not mark the file
  unavailable on its own: the authoritative resolve decides that. A
  session-authority failure (401 or session lifecycle code) drops the
  content.
- Every body only renders content for the source it downloaded, so a
  caller that swaps the URI without remounting never shows file A
  under file B.
- The plugin preview overlay gets the same split: its key is the URI
  and the version is a prop.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Add `createGfsWatchClient`, a single per-process client for the GFS
watch stream (`GET/PUT /api/v1/me/gfs/watch`), not yet wired into
AppService or IPC.

- One link at a time (Idle/Backoff/Opening/Ready) with a per-attempt
  epoch, so a late result of an old attempt is always dropped.
  `closeLink` releases state before running any effect, and every
  effect is best-effort, so a throwing abort or cancel never leaves the
  link half-closed.
- The GET uses raw fetch with only the open deadline (no request
  timeout that would kill a healthy stream) and refuses redirects. The
  PUT goes through `requestJson` with a timeout longer than the PUT
  deadline, so the client's own timer always wins.
- Total outcome tables for the open and the PUT, keyed on status plus
  the `x-watch-error` header; additive jitter that never fires before a
  server `Retry-After`; capped PUT retries per episode; deferred keys
  re-armed whenever a send is skipped.
- Session handling: own token generation, binding decoded from the
  token claims (never used to authorize), terminal latch deferred during
  a team hop, `/me` verdict hook on session expiry, and 409/426
  treated as "unsupported until a newer token" unless a newer session
  already exists. A changed environment drops the old session before
  reopening.
- Owner registry with merge by rank and registration order, a 64-key
  cap, frame-token gating for renderer IPC, and fan-out that survives
  destroyed or throwing owners.
- Server keys are re-validated against the canonical key format before
  they reach renderers.

Tests: scenario suite against a real HTTP server with the real fetch
and requestJson on a virtual clock, plus a fast-check model test that
checks the invariants at rest against an independent reference model
(fixed seed, overridable via GFS_WATCH_MODEL_SEED/GFS_WATCH_MODEL_RUNS).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Give AppService a single GFS watch client, built in its constructor,
and feed it every session, team-hop and environment event. The legacy
entity-change stream stays alive alongside it until the renderer moves
over.

- Session token: published next to every existing entity-change token
  update (login, restore, refresh, team switch, hop restore or failed
  restore, session clear). The dead depth-0 branch of
  `commitSessionToken` publishes nothing.
- Team hop: enter on increment, exit inside the idempotent release, so
  a double release yields one exit. On the last release the client
  adopts the app's current token, which covers hops that never restore.
- Environment switch: start closes the link; the end runs first in the
  `finally`, with the same "environment unchanged" predicate that
  clears the app session, so a later throw cannot leave the watch
  switching forever. The base URL is read per request.
- `stopAllStreams` notifies the client; a session-expired verdict asks
  `/me` through `confirmCommittedSessionWithMe`.
- IPC `gfsWatch:setSubscriptions` / `gfsWatch:stop` and push channel
  `gfsWatch:event`: trusted sender only, owner from `event.sender.id`,
  documents identified by the Electron main-frame token, lifecycle
  listeners installed once per webContents and removed on `destroyed`,
  and `render-process-gone` never reads the disposed main frame. Events
  skip owners without a live document.
- Preload `clerum.gfsWatch.open` installs the event listener before the
  first invoke, so no event emitted during the first subscribe is lost.

Tests: AppService driven through its real producers against the fake
watch server, real ipc.ts and preload handlers, and a native Electron
fixture (`npm run test:native:gfs-watch-ipc`) for frame tokens across
reload, subframes, renderer crash and window close. A vitest setup file
gives watch clients an inert transport and disposes them, so unrelated
AppService suites no longer send requests to a real local port.
`esbuild` is declared for the native fixture bundler.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…tire the entity-change stream

The renderer now derives the set of GFS resources each window shows (active
preview, plugin preview overlay, the Files location with its real kind, and
the expanded sidebar folders), canonicalises it, caps it at 64 keys by
priority and sends it to main through the gfsWatch preload API with a
150 ms debounce. The first send on mount is never deduplicated, and the
dedupe is reset on any session, identity, team or environment change so main
gets the list again after it cleared the owner lists. userId is auth.me.id.

Watch events are handled with a per-key single-flight plus one pending pass:
file keys refresh the open preview, dir keys refetch the folder listing
without cancelling an in-flight pagination and run a final pass,
accessChanged refreshes roots and sharing dialogs and marks folder listings
stale, and sessionExpired purges previews, the GFS cache and every Files
view's breadcrumb regardless of what is open. Leaving the desired set never
bumps a preview's generation while its tab stays open.

The per-user entity-change stream is removed end to end: authClient stream
and parser, AppService entityChange* state, entityChanges IPC, preload
bridge, types and the renderer EntityChangeRegistry. Its session guards are
already enforced by the watch client.

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

Brings in the synced -authz head (origin/dev @ 22dd421 plus the control-api
T-M2 test commit 59d0595). No textual conflicts; desktop-app/package.json,
package-lock.json, src/appService.ts, src/ipc.ts, src/preload.ts and
src/renderer.d.ts auto-merged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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