experiments in a post-browser web
10
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat(tiles): wire tile-lifecycle FSM + dispatch drift check (Phase F)

Phase F wires the FSM scaffolded in Phase A to real lifecycle events
and adds a dev-mode drift check at the dispatch site. The FSM was
inert until now — Phases B-E built up the infrastructure around it;
this commit flips it live.

- `feature-startup.ts` — every registry mutation now fires the
matching FSM trigger for each declared tile entry. INSTALL on
registerFeature (via the listener AND via the boot-time seed walk,
mirroring the manifest-cache wiring), UNINSTALL on unregister. The
seed pass happens before `syncBuiltinFeatures` so the FSM's
REGISTERED state is the baseline dispatch-drift checks see. Transition
failures are caught and warned — a malformed manifest can't crash
startup.

- `tile-launcher.ts` — `createTileBrowserWindow` now fires LOAD
(REGISTERED → LOADING). The render-process-gone handler fires
RENDER_GONE (→ CRASHED). The close handler walks SHUTDOWN →
CLEANUP_KEEP (→ REGISTERED) so repeat launches start from a valid
state. `signalTileReady` fires TILE_READY (LOADING → READY).
Transitions are best-effort: for trustedBuiltin callers (hud-glue,
cmd-glue, etc.) whose entries never went through the feature
registry, the entry is UNREGISTERED, INSTALL/LOAD get rejected by
the FSM, and we silently continue. This is intentional — the FSM
is authoritative for v2 feature tiles only for now; extending it
to trustedBuiltin helpers is a later phase.

- `tile-lazy.ts` — `assertDispatchFsmState` runs at dispatch entry
for both `cmd:execute:*` and lazyEvent paths. Under `isDevProfile()`
it logs loudly when the target's FSM state isn't one of
unregistered/registered/loading/ready/visible (i.e. UNLOADING or
CRASHED). Non-blocking: the hook still proceeds with load/deliver
so a drift warning never masks itself as a functional failure.
Prod profile: silent.

Why non-blocking: the drift check exists to catch *wiring bugs* —
places in the codebase where a tile-lifecycle event happens without
notifying the FSM. A loud warning in dev is enough; escalating to a
thrown error would turn a diagnostic into a landmine because the FSM's
view of the world is only as complete as the wiring. Once every
lifecycle event goes through the FSM (Phase G / future), we can
tighten this.

Per-policy notes (from the FSM design doc):
- Crashed tiles DO NOT auto-recover. The FSM has a RELAUNCH trigger
from CRASHED → LOADING, but no caller fires it automatically.
Recovery is user-initiated (a future "Reload feature" action).
- SHOW/HIDE transitions (READY ↔ VISIBLE) aren't wired yet — we
don't have a clean observable for "tile just became visible"
separate from window show/hide. Deferred; drift checks accept
both READY and VISIBLE today.

Tests: 2249/2249 unit + 238/238 Playwright (desktop + desktop-serial).

+125 -2
+49 -1
backend/electron/feature-startup.ts
··· 17 17 import { loadFeaturesFromRegistry, type TileLoaderConfig, type FeatureLoadResult } from './tile-loader.js'; 18 18 import { AtprotoSource } from './atproto-source.js'; 19 19 import * as manifestCache from './manifest-cache.js'; 20 + import * as tileLifecycle from './tile-lifecycle.js'; 20 21 import { isEphemeralProfile } from './config.js'; 21 22 22 23 // ─── Types ────────────────────────────────────────────────────────── ··· 90 91 } 91 92 } 92 93 94 + // ─── tile-lifecycle bridge ────────────────────────────────────────── 95 + 96 + /** 97 + * Drive the tile-lifecycle FSM for every entry declared by a feature. 98 + * Each entry gets its own transition — the FSM is per-entry (a feature 99 + * with a background + a window entry has two independent lifecycles). 100 + * Called from the registry listener on install and from the seed pass 101 + * at boot so the FSM state map matches on-disk reality. 102 + * 103 + * `trigger` should be whatever the caller intends — `INSTALL` when a 104 + * feature arrives, a cleanup trigger on uninstall. If a transition is 105 + * invalid from the current state (e.g. INSTALL when the entry is 106 + * already REGISTERED), the call is a silent no-op; the FSM rejects it. 107 + */ 108 + function fireFsmTriggerForFeature(entry: FeatureRegistryEntry, trigger: string): void { 109 + const row = manifestCache.get(entry.id); 110 + if (!row || !row.manifest || !Array.isArray(row.manifest.tiles)) return; 111 + for (const tileEntry of row.manifest.tiles) { 112 + try { 113 + tileLifecycle.transition(entry.id, tileEntry.id, trigger, { source: 'feature-startup' }); 114 + } catch (err) { 115 + console.warn(`[feature-startup] FSM ${trigger} failed for ${entry.id}:${tileEntry.id}:`, err); 116 + } 117 + } 118 + } 119 + 93 120 // ─── Startup ──────────────────────────────────────────────────────── 94 121 95 122 /** ··· 114 141 // from the Features pane — keeps the cache in step without each 115 142 // caller having to know the cache exists. 116 143 registry.addListener({ 117 - onRegister: upsertManifestCacheForEntry, 144 + onRegister: (entry) => { 145 + // Cache must land first — the FSM transition reads the cached 146 + // manifest to enumerate tile entries. 147 + upsertManifestCacheForEntry(entry); 148 + fireFsmTriggerForFeature(entry, tileLifecycle.TRIGGERS.INSTALL); 149 + }, 118 150 onUnregister: (id) => { 151 + // Transition first while the cache row still exists (the FSM 152 + // callback enumerates tile entries from it), then remove. 153 + const cached = manifestCache.get(id); 154 + if (cached && cached.manifest && Array.isArray(cached.manifest.tiles)) { 155 + for (const tileEntry of cached.manifest.tiles) { 156 + try { 157 + tileLifecycle.transition(id, tileEntry.id, tileLifecycle.TRIGGERS.UNINSTALL, 158 + { source: 'feature-startup' }); 159 + } catch (err) { 160 + console.warn(`[feature-startup] FSM UNINSTALL failed for ${id}:${tileEntry.id}:`, err); 161 + } 162 + } 163 + } 119 164 try { manifestCache.remove(id); } 120 165 catch (err) { console.warn(`[feature-startup] manifest-cache remove failed for ${id}:`, err); } 121 166 }, ··· 143 188 // entries never fired the onRegister listener — we have to walk them 144 189 // manually. Runs before sync so the sync's own registerFeature calls 145 190 // (for updated paths / removed builtins) simply refresh on top. 191 + // Also drives the tile-lifecycle FSM to the initial REGISTERED state 192 + // so dispatch-drift checks see a correct baseline. 146 193 for (const entry of registry.listFeatures()) { 147 194 upsertManifestCacheForEntry(entry); 148 195 if (entry.disabled) { ··· 151 198 // Already warned by the upsert path if anything went wrong. 152 199 } 153 200 } 201 + fireFsmTriggerForFeature(entry, tileLifecycle.TRIGGERS.INSTALL); 154 202 } 155 203 156 204 // 2. Sync builtins
+29
backend/electron/tile-launcher.ts
··· 48 48 import { DEBUG, getTilePreloadPath } from './config.js'; 49 49 import { loadSchemaDefaults } from './tile-settings-defaults.js'; 50 50 import { getExtensionPath } from './protocol.js'; 51 + import * as tileLifecycle from './tile-lifecycle.js'; 51 52 52 53 // Config hooks for values sourced from electron-heavy modules 53 54 // (session-partition, windows) that we can't safely import at top ··· 189 190 } = spec; 190 191 const publishCrashEvent = spec.publishCrashEvent ?? (manifest !== undefined); 191 192 193 + // FSM: REGISTERED → LOADING. The transition is best-effort — for 194 + // trustedBuiltin callers that never ran through the feature 195 + // registry (hud-glue, cmd-glue, etc.) the entry starts in 196 + // UNREGISTERED and this call is rejected. That's fine; the FSM is 197 + // only authoritative for v2 feature tiles today, and a silent 198 + // rejection on trustedBuiltin entries doesn't harm drift detection. 199 + tileLifecycle.transition(tileId, entryId, tileLifecycle.TRIGGERS.LOAD, 200 + { source: 'createTileBrowserWindow' }); 201 + 192 202 // Hints from the manifest entry (windowHints + flat fields on the 193 203 // entry itself, which we treat as shorthand for the same thing). 194 204 let hints: Partial<TileEntry['windowHints']> & Partial<TileEntry> = {}; ··· 281 291 console.error( 282 292 `[tile:${tileId}:${entryId}] Render process gone: ${details.reason} (exitCode=${details.exitCode})` 283 293 ); 294 + tileLifecycle.transition(tileId, entryId, tileLifecycle.TRIGGERS.RENDER_GONE, 295 + { reason: details.reason, exitCode: details.exitCode }); 284 296 tileWindows.delete(key); 285 297 readyTiles.delete(key); 286 298 revokeToken(token); ··· 303 315 }); 304 316 305 317 // Normal close: same cleanup as crash, minus the crash broadcast. 318 + // 319 + // FSM: drive the entry back toward `registered`. From a ready/visible/ 320 + // loading state the correct sequence is SHUTDOWN → UNLOADING → 321 + // CLEANUP_KEEP → REGISTERED. Each step is best-effort; if it's already 322 + // UNREGISTERED (trustedBuiltin callers, uninstall races), the FSM 323 + // silently rejects and we continue with the resource cleanup below. 306 324 win.on('closed', () => { 325 + const curState = tileLifecycle.getState(tileId, entryId); 326 + if (curState !== tileLifecycle.STATES.UNREGISTERED && curState !== tileLifecycle.STATES.CRASHED) { 327 + tileLifecycle.transition(tileId, entryId, tileLifecycle.TRIGGERS.SHUTDOWN, { source: 'window-closed' }); 328 + tileLifecycle.transition(tileId, entryId, tileLifecycle.TRIGGERS.CLEANUP_KEEP, { source: 'window-closed' }); 329 + } 307 330 tileWindows.delete(key); 308 331 readyTiles.delete(key); 309 332 revokeToken(token); ··· 395 418 export function signalTileReady(tileId: string, entryId: string): void { 396 419 const key = `${tileId}:${entryId}`; 397 420 readyTiles.add(key); 421 + 422 + // FSM: LOADING → READY. Silent no-op for entries that aren't in 423 + // LOADING (e.g. trustedBuiltin callers that never transitioned 424 + // through the feature registry). 425 + tileLifecycle.transition(tileId, entryId, tileLifecycle.TRIGGERS.TILE_READY, 426 + { source: 'signalTileReady' }); 398 427 399 428 // Resolve any pending callbacks 400 429 const callbacks = readyCallbacks.get(key);
+47 -1
backend/electron/tile-lazy.ts
··· 39 39 type TileLaunchResult, 40 40 } from './tile-launcher.js'; 41 41 import { type TileManifestV2, type TileCommand } from './tile-manifest.js'; 42 - import { DEBUG } from './config.js'; 42 + import { DEBUG, isDevProfile } from './config.js'; 43 + import * as tileLifecycle from './tile-lifecycle.js'; 43 44 44 45 // ─── Launcher Injection (for testability) ─────────────────────────── 45 46 ··· 228 229 229 230 const { tileId, cmd } = match; 230 231 232 + // Drift check: the owning tile's background entry should be in a 233 + // predictable state at dispatch time — either REGISTERED (we're 234 + // about to load it), LOADING (someone else already is), READY / 235 + // VISIBLE (handler is live), or UNREGISTERED (for trustedBuiltin 236 + // tiles whose FSM we don't drive). Anything else (UNLOADING, 237 + // CRASHED) means something upstream got out of sync with policy. 238 + // Dev profile surfaces loudly; prod stays quiet (the hook still 239 + // proceeds — it's a diagnostic, not a blocker). 240 + const bgEntry = getBackgroundEntry(tileId); 241 + if (bgEntry) { 242 + assertDispatchFsmState(tileId, bgEntry, `cmd:execute:${name}`); 243 + } 244 + 231 245 // Declarative `type: 'window'` commands: the main process opens the 232 246 // window directly. No handler on the tile side; the cmd:execute 233 247 // publish would go nowhere. Short-circuit: publish window-open, ··· 272 286 // hook run). Just proceed. 273 287 return 'continue'; 274 288 } 289 + const bgEntry = getBackgroundEntry(owner.tileId); 290 + if (bgEntry) { 291 + assertDispatchFsmState(owner.tileId, bgEntry, `lazyEvent:${topic}`); 292 + } 275 293 try { 276 294 await ensureTileLoaded(owner.tileId); 277 295 } catch (err) { 278 296 console.error(`[tile-lazy] Failed to load tile ${owner.tileId} for lazyEvent ${topic}:`, err); 279 297 } 280 298 return 'continue'; 299 + } 300 + 301 + /** 302 + * Dev-mode drift check at dispatch entry. Logs loudly under 303 + * `isDevProfile()` when a tile is in a state the FSM says shouldn't 304 + * accept dispatch (UNLOADING / CRASHED). Never blocks delivery — this 305 + * is a diagnostic to catch wiring bugs, not a policy enforcer. 306 + * 307 + * Accepted states: UNREGISTERED (trustedBuiltin callers skip the FSM), 308 + * REGISTERED (normal lazy case), LOADING (concurrent dispatch), 309 + * READY / VISIBLE (hot path). 310 + */ 311 + function assertDispatchFsmState(tileId: string, entryId: string, label: string): void { 312 + if (!isDevProfile()) return; 313 + const state = tileLifecycle.getState(tileId, entryId); 314 + const ok = 315 + state === tileLifecycle.STATES.UNREGISTERED || 316 + state === tileLifecycle.STATES.REGISTERED || 317 + state === tileLifecycle.STATES.LOADING || 318 + state === tileLifecycle.STATES.READY || 319 + state === tileLifecycle.STATES.VISIBLE; 320 + if (!ok) { 321 + console.warn( 322 + `[tile-lazy] DRIFT: dispatch to ${tileId}:${entryId} (${label}) while FSM is "${state}" — ` + 323 + 'expected unregistered/registered/loading/ready/visible. ' + 324 + 'Something bypassed the lifecycle wiring.', 325 + ); 326 + } 281 327 } 282 328 283 329 // ─── Load orchestration ─────────────────────────────────────────────