Repository navigation
Conversation
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>
…-subscriptions-desktop
This was referenced Nov 14, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.
uri:reloadToken:unavailable) is split from its content version.useInPlaceObjectUrl, which revokes each URL exactly once, StrictMode and unmount mid-download included).createGfsWatchClient): one client per process forGET/PUT /api/v1/me/gfs/watch.closeLinkreleases state before running any effect, and every effect is best-effort.requestJsonwith a timeout longer than the PUT deadline.x-watch-error; additive jitter that never fires before a serverRetry-After; capped PUT retries per episode./mecheck on session expiry, and 409/426 treated as "unsupported until a newer token".AppServicefeeds the client every session, team-hop and environment event. IPCgfsWatch:setSubscriptions/gfsWatch:stopand push channelgfsWatch:eventaccept trusted senders only and identify documents by the Electron main-frame token. The preload installs the event listener before the first invoke.accessChangedrefreshes roots and sharing dialogs and marks listings stale;sessionExpiredpurges previews, the GFS cache and every Files breadcrumb.authClientstream and parser,AppServicestate, IPC, preload bridge, types and the renderer registry.docs/architecture/live-entity-change-consumer-semantics.mddescribes 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
accessChangedcaused by areauthorizeclose, watched keys are not re-resolved immediately; they converge with theresyncof the nextready. Re-resolving every key on every reopen would repeat reads that theresyncalready does.userIdcomes fromauth.me.id.Testing
watchClient.model.test.ts(fast-check commands, invariants at rest against an independent reference model; fixed seed, overridable withGFS_WATCH_MODEL_SEED/GFS_WATCH_MODEL_RUNS).watchClient.scenarios.test.ts: scenarios against a real HTTP server with the real fetch andrequestJsonon a virtual clock.appService.gfsWatch,ipc.gfsWatch,preload.gfsWatch: AppService through its real producers, real IPC and preload handlers.npm run test:native:gfs-watch-ipc): frame tokens across reload, subframes, renderer crash and window close.App.gfsWatch, in-place reload suites for each preview body,FilePreviewPage, plugin overlay,useGfsWatchSubscription,gfsWatchKeys,gfsWatchReaction,keyedSingleFlight.npm run verify:electronpassing:tsc --noEmitclean;vitest run485 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 withCannot find module.Risks / follow-ups
unsupported: no live updates, no pushed roots or dialogs, no revocation or session notice. V2 sessions cannot be issued today; this is a prerequisite before enabling them. Follow-up: GFS watch: support V2 sessions before enabling V2 issuance #1504.Stack
Live GFS subscriptions ship as a stack. The server half and the desktop half must reach
devtogether: the server PRs retire the route the current desktop uses, and the desktop PR needs the new watch route.devprincipal_deniedreasondevdevGET/PUT /external/gfs/watchGET/PUT /api/v1/me/gfs/watch1 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:
gfs_resource_changedNOTIFY trigger, which nothing listens to yet.changedevents of a stream that is idle (no op in flight). Do not ship 5/6 without 7./api/v1/entity-changes/streamand control-api/external/entity-changes/stream), which the current desktop still uses. Do not ship 7 without 8.