experiments in a post-browser web
10
fork

Configure Feed

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

feat(tiles): tile-lifecycle FSM + manifest-cache scaffold (Phase A)

Policy + scaffold for the tile lifecycle rewrite laid out in
docs/tile-lifecycle-fsm.md. No callers wired yet — later phases will
route existing tile-launcher / window-open / cmd-dispatch paths
through this.

- `backend/electron/tile-fsm.ts` — pure transition table. No
electron/node imports. `validateTransition(from, trigger)` returns
`{ok, to | error}`. Source of truth for the 7 states (unregistered,
registered, loading, ready, visible, unloading, crashed) and 11
triggers. Policy helpers `isDispatchable` / `acceptsDynamicRegistration`
encode the doc's "dispatch requires ready" and "dynamic registration
only in ready/visible" invariants.

- `backend/electron/tile-lifecycle.ts` — side-effectful enforcement
engine. Owns the authoritative `tileId:entryId → state` map.
`transition(tileId, entryId, trigger)` is the ONLY entry point for
state mutation; no back-door setter (`setStateUnchecked` was
considered and removed — tests walk the FSM like production would).
Transitions to `unregistered` delete the record so uninstall/install
cycles don't leak. Keys are JSON-encoded `[tileId, entryId]` so
featureIds containing colons (atproto handles) round-trip cleanly.
`resetForTests()` throws outside `NODE_ENV=test`.

- `backend/electron/manifest-cache.ts` — SQLite cache for parsed
manifest declarations (commands, nouns, shortcuts, lazyEvents,
resolved capability grant). Stubbed install + boot-discovery write
paths land in Phase B; read APIs (`listAllCommands` /
`listAllShortcuts` / `listAllLazyEvents` / `list` / `get`) are
ready for Phase C to consume. Named `manifest-cache` (not
`feature-registry`) to keep the boundary with the existing
install-metadata module clean.

- `manifest_cache` table DDL added to `datastore.ts`. Idempotent
CREATE IF NOT EXISTS, includes `disabledAt` so the Features pane
can toggle without loss of cache. Desktop-local only (not in the
synced v1.json schema).

- 38 unit tests in `tests/unit/tile-fsm.test.js` (every allowed
transition + every disallowed combination + reachability) and
`tests/unit/tile-lifecycle.test.js` (happy path, crash recovery,
dispatch gating, map-leak prevention, colon-tolerant keys,
production guard). Test script sets `NODE_ENV=test` so
`resetForTests()` passes its guard.

No runtime behavior change. Phase B wires manifest_cache writes on
install / boot discovery; Phase C swaps cmd panel & shortcut registry
from pubsub subscriptions to cache reads; Phase D unifies window
creation; Phase E adds load-on-dispatch.

+1360 -1
+22
backend/electron/datastore.ts
··· 319 319 ); 320 320 CREATE INDEX IF NOT EXISTS idx_features_history_feature ON features_history(featureId); 321 321 CREATE INDEX IF NOT EXISTS idx_features_history_time ON features_history(timestamp DESC); 322 + 323 + -- manifest_cache: parsed manifest declarations per installed feature. 324 + -- Authoritative source for static declarations (commands, nouns, 325 + -- shortcuts, lazyEvents, capability grant) that cmd panel / shortcut 326 + -- registry / lazyEvents interceptor read at boot. Writes happen only 327 + -- at install/update time + boot-time manifest discovery. Distinct 328 + -- from registry.json (install metadata) — see 329 + -- backend/electron/feature-registry.ts for that. 330 + -- See docs/tile-lifecycle-fsm.md. 331 + CREATE TABLE IF NOT EXISTS manifest_cache ( 332 + featureId TEXT PRIMARY KEY, 333 + version TEXT NOT NULL, 334 + manifest TEXT NOT NULL, -- parsed manifest JSON 335 + commands TEXT, -- JSON array of declared commands 336 + nouns TEXT, -- JSON array of declared nouns (metadata only; handlers live in tile) 337 + shortcuts TEXT, -- JSON array of shortcut bindings 338 + capabilities TEXT, -- resolved capability grant JSON 339 + lazyEvents TEXT, -- JSON array of {topic, command} entries 340 + updatedAt INTEGER NOT NULL, 341 + disabledAt INTEGER -- null = enabled 342 + ); 343 + CREATE INDEX IF NOT EXISTS idx_manifest_cache_disabled ON manifest_cache(disabledAt); 322 344 `; 323 345 324 346 // Module state
+392
backend/electron/manifest-cache.ts
··· 1 + /** 2 + * manifest_cache — SQLite cache of parsed feature manifests. 3 + * 4 + * Writes: only two call-sites. 5 + * - `upsertFromManifest(featureId, manifestPath)` invoked by the feature 6 + * installer when a feature lands or updates, and by boot-time 7 + * builtin discovery when seeding ephemeral/dev profiles. 8 + * - `setDisabled(featureId, true|false)` toggled by the Features pane. 9 + * 10 + * Reads: consumed by cmd panel (static command surface), shortcut 11 + * registry, lazyEvents interceptor, Features pane listing. 12 + * 13 + * Not yet wired — this module ships as Phase A scaffolding. Phase B 14 + * routes the installer + boot-discovery through here. Phase C switches 15 + * the cmd panel / shortcut registrar / lazyEvents to read from here 16 + * instead of listening for tile publishes. 17 + * 18 + * Desktop-only (better-sqlite3 is main-process). Authoritative state 19 + * lives here; renderer-side consumers get snapshots via strict 20 + * `tile:features:*` IPC. 21 + * 22 + * Distinct from `backend/electron/feature-registry.ts`, which stores 23 + * install metadata (source, publisher, capability grants, update 24 + * policy) in `registry.json`. This file is the **manifest declaration 25 + * cache** — what commands/nouns/shortcuts each feature declares. 26 + * 27 + * See docs/tile-lifecycle-fsm.md. 28 + */ 29 + 30 + import fs from 'node:fs'; 31 + import path from 'node:path'; 32 + 33 + import { getDb } from './datastore.js'; 34 + import { parseManifestFile, resolveCapabilities } from './tile-manifest.js'; 35 + import type { TileManifestV2, CapabilityGrant } from './tile-manifest.js'; 36 + 37 + // --------------------------------------------------------------------------- 38 + // Row shape 39 + // --------------------------------------------------------------------------- 40 + 41 + export interface ManifestCacheRow { 42 + featureId: string; 43 + version: string; 44 + manifest: TileManifestV2; 45 + commands: ManifestCommandDecl[]; 46 + nouns: ManifestNounDecl[]; 47 + shortcuts: ManifestShortcutDecl[]; 48 + capabilities: CapabilityGrant; 49 + lazyEvents: Array<{ topic: string; command: string }>; 50 + updatedAt: number; 51 + disabledAt: number | null; 52 + } 53 + 54 + export interface ManifestCommandDecl { 55 + name: string; 56 + description?: string; 57 + action?: unknown; // declarative action shape (execute | window | publish) 58 + scope?: string; 59 + modes?: string[]; 60 + accepts?: string[]; 61 + produces?: string[]; 62 + params?: unknown[]; 63 + } 64 + 65 + export interface ManifestNounDecl { 66 + name: string; // plural 67 + singular: string; 68 + description?: string; 69 + hasQuery?: boolean; 70 + hasBrowse?: boolean; 71 + hasOpen?: boolean; 72 + hasCreate?: boolean; 73 + produces?: string | null; 74 + params?: unknown[]; 75 + modes?: string[]; 76 + skipBare?: boolean; 77 + } 78 + 79 + export interface ManifestShortcutDecl { 80 + keys: string; 81 + command: string; 82 + global?: boolean; 83 + } 84 + 85 + // --------------------------------------------------------------------------- 86 + // Write path 87 + // --------------------------------------------------------------------------- 88 + 89 + /** 90 + * Parse a manifest file and upsert its cached declarations into the 91 + * `manifest_cache` table. Idempotent — safe to call on every boot for 92 + * builtins and on every install/update. 93 + * 94 + * The manifest's declared `id` must match the passed `featureId`. 95 + * 96 + * Throws on parse error (caller decides whether to skip or surface). 97 + */ 98 + export function upsertFromManifest(featureId: string, manifestPath: string): ManifestCacheRow { 99 + const parseResult = parseManifestFile(manifestPath); 100 + if (!parseResult) { 101 + throw new Error(`[manifest-cache] ${featureId}: manifest missing or invalid at ${manifestPath}`); 102 + } 103 + if (parseResult.version !== 'v2' || !parseResult.v2) { 104 + throw new Error(`[manifest-cache] ${featureId}: v1 manifests not cached`); 105 + } 106 + const manifest = parseResult.v2; 107 + if (manifest.id !== featureId) { 108 + throw new Error(`[manifest-cache] manifest id mismatch: file=${featureId} manifest=${manifest.id}`); 109 + } 110 + 111 + // Resolve capabilities the same way the tile launcher does so the 112 + // cached grant matches what the tile would receive at load time. 113 + const capabilities = resolveCapabilities(manifest.id, manifest.capabilities ?? {}, manifest.builtin === true); 114 + 115 + const commands = extractCommands(manifest); 116 + const nouns = extractNouns(manifest); 117 + const shortcuts = extractShortcuts(manifest); 118 + const lazyEvents = extractLazyEvents(manifest); 119 + 120 + const row: ManifestCacheRow = { 121 + featureId, 122 + version: manifest.version ?? '0.0.0', 123 + manifest, 124 + commands, 125 + nouns, 126 + shortcuts, 127 + capabilities, 128 + lazyEvents, 129 + updatedAt: Date.now(), 130 + disabledAt: null, 131 + }; 132 + 133 + writeRow(row); 134 + return row; 135 + } 136 + 137 + function writeRow(row: ManifestCacheRow): void { 138 + const db = getDb(); 139 + db.prepare(` 140 + INSERT INTO manifest_cache ( 141 + featureId, version, manifest, commands, nouns, shortcuts, 142 + capabilities, lazyEvents, updatedAt, disabledAt 143 + ) 144 + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) 145 + ON CONFLICT(featureId) DO UPDATE SET 146 + version = excluded.version, 147 + manifest = excluded.manifest, 148 + commands = excluded.commands, 149 + nouns = excluded.nouns, 150 + shortcuts = excluded.shortcuts, 151 + capabilities = excluded.capabilities, 152 + lazyEvents = excluded.lazyEvents, 153 + updatedAt = excluded.updatedAt, 154 + disabledAt = COALESCE(manifest_cache.disabledAt, excluded.disabledAt) 155 + `).run( 156 + row.featureId, 157 + row.version, 158 + JSON.stringify(row.manifest), 159 + JSON.stringify(row.commands), 160 + JSON.stringify(row.nouns), 161 + JSON.stringify(row.shortcuts), 162 + JSON.stringify(row.capabilities), 163 + JSON.stringify(row.lazyEvents), 164 + row.updatedAt, 165 + row.disabledAt, 166 + ); 167 + } 168 + 169 + /** 170 + * Remove a feature's cached declarations. Call on uninstall. 171 + */ 172 + export function remove(featureId: string): void { 173 + getDb().prepare('DELETE FROM manifest_cache WHERE featureId = ?').run(featureId); 174 + } 175 + 176 + /** 177 + * Toggle the disabled flag. `true` stamps `disabledAt = now`; `false` 178 + * clears it. 179 + */ 180 + export function setDisabled(featureId: string, disabled: boolean): void { 181 + const disabledAt = disabled ? Date.now() : null; 182 + getDb() 183 + .prepare('UPDATE manifest_cache SET disabledAt = ? WHERE featureId = ?') 184 + .run(disabledAt, featureId); 185 + } 186 + 187 + /** 188 + * Clear the entire table. Used when booting an ephemeral profile so 189 + * fresh manifest discovery repopulates from disk. 190 + */ 191 + export function clearAll(): void { 192 + getDb().prepare('DELETE FROM manifest_cache').run(); 193 + } 194 + 195 + // --------------------------------------------------------------------------- 196 + // Read path 197 + // --------------------------------------------------------------------------- 198 + 199 + /** 200 + * Fetch a single feature's cached row, or null if not present. 201 + */ 202 + export function get(featureId: string): ManifestCacheRow | null { 203 + const raw = getDb() 204 + .prepare('SELECT * FROM manifest_cache WHERE featureId = ?') 205 + .get(featureId) as RawRow | undefined; 206 + return raw ? parseRow(raw) : null; 207 + } 208 + 209 + /** 210 + * List every cached row, optionally including disabled features. 211 + * Default returns enabled only — matches what cmd panel / shortcut 212 + * registry want. 213 + */ 214 + export function list(options: { includeDisabled?: boolean } = {}): ManifestCacheRow[] { 215 + const where = options.includeDisabled ? '' : 'WHERE disabledAt IS NULL'; 216 + const rows = getDb().prepare(`SELECT * FROM manifest_cache ${where}`).all() as RawRow[]; 217 + return rows.map(parseRow); 218 + } 219 + 220 + /** 221 + * Flat list of every declared command across every enabled feature. 222 + * Each entry tagged with its source `featureId` so the cmd panel can 223 + * route execution to the right tile. Used as the cold-start command 224 + * set for the cmd panel. 225 + */ 226 + export function listAllCommands(): Array<ManifestCommandDecl & { featureId: string }> { 227 + const rows = list(); 228 + const out: Array<ManifestCommandDecl & { featureId: string }> = []; 229 + for (const row of rows) { 230 + for (const cmd of row.commands) { 231 + out.push({ ...cmd, featureId: row.featureId }); 232 + } 233 + } 234 + return out; 235 + } 236 + 237 + /** 238 + * Flat list of every declared noun across every enabled feature. 239 + */ 240 + export function listAllNouns(): Array<ManifestNounDecl & { featureId: string }> { 241 + const rows = list(); 242 + const out: Array<ManifestNounDecl & { featureId: string }> = []; 243 + for (const row of rows) { 244 + for (const noun of row.nouns) { 245 + out.push({ ...noun, featureId: row.featureId }); 246 + } 247 + } 248 + return out; 249 + } 250 + 251 + /** 252 + * Flat list of every declared shortcut across every enabled feature. 253 + */ 254 + export function listAllShortcuts(): Array<ManifestShortcutDecl & { featureId: string }> { 255 + const rows = list(); 256 + const out: Array<ManifestShortcutDecl & { featureId: string }> = []; 257 + for (const row of rows) { 258 + for (const sc of row.shortcuts) { 259 + out.push({ ...sc, featureId: row.featureId }); 260 + } 261 + } 262 + return out; 263 + } 264 + 265 + /** 266 + * Flat list of every declared lazyEvent interceptor across every 267 + * enabled feature. Used by the lazy-event interceptor registrar at 268 + * boot to subscribe without loading any tiles. 269 + */ 270 + export function listAllLazyEvents(): Array<{ topic: string; command: string; featureId: string }> { 271 + const rows = list(); 272 + const out: Array<{ topic: string; command: string; featureId: string }> = []; 273 + for (const row of rows) { 274 + for (const le of row.lazyEvents) { 275 + out.push({ ...le, featureId: row.featureId }); 276 + } 277 + } 278 + return out; 279 + } 280 + 281 + // --------------------------------------------------------------------------- 282 + // Internals 283 + // --------------------------------------------------------------------------- 284 + 285 + interface RawRow { 286 + featureId: string; 287 + version: string; 288 + manifest: string; 289 + commands: string | null; 290 + nouns: string | null; 291 + shortcuts: string | null; 292 + capabilities: string | null; 293 + lazyEvents: string | null; 294 + updatedAt: number; 295 + disabledAt: number | null; 296 + } 297 + 298 + function parseRow(raw: RawRow): ManifestCacheRow { 299 + return { 300 + featureId: raw.featureId, 301 + version: raw.version, 302 + manifest: safeJson<TileManifestV2>(raw.manifest, null as unknown as TileManifestV2), 303 + commands: safeJson<ManifestCommandDecl[]>(raw.commands ?? '[]', []), 304 + nouns: safeJson<ManifestNounDecl[]>(raw.nouns ?? '[]', []), 305 + shortcuts: safeJson<ManifestShortcutDecl[]>(raw.shortcuts ?? '[]', []), 306 + capabilities: safeJson<CapabilityGrant>(raw.capabilities ?? 'null', null as unknown as CapabilityGrant), 307 + lazyEvents: safeJson<Array<{ topic: string; command: string }>>(raw.lazyEvents ?? '[]', []), 308 + updatedAt: raw.updatedAt, 309 + disabledAt: raw.disabledAt, 310 + }; 311 + } 312 + 313 + function safeJson<T>(str: string, fallback: T): T { 314 + try { 315 + return JSON.parse(str) as T; 316 + } catch { 317 + return fallback; 318 + } 319 + } 320 + 321 + function extractCommands(manifest: TileManifestV2): ManifestCommandDecl[] { 322 + if (!Array.isArray(manifest.commands)) return []; 323 + return manifest.commands.map(cmd => ({ 324 + name: cmd.name, 325 + description: cmd.description, 326 + action: (cmd as { action?: unknown }).action, 327 + scope: (cmd as { scope?: string }).scope, 328 + modes: (cmd as { modes?: string[] }).modes, 329 + accepts: (cmd as { accepts?: string[] }).accepts, 330 + produces: (cmd as { produces?: string[] }).produces, 331 + params: (cmd as { params?: unknown[] }).params, 332 + })); 333 + } 334 + 335 + function extractNouns(manifest: TileManifestV2): ManifestNounDecl[] { 336 + // Manifests don't yet declare nouns statically — nouns come from 337 + // runtime `registerNoun()` calls. Phase C will extend the manifest 338 + // schema to allow static noun declarations so browse/list commands 339 + // can surface without loading the tile; for now this returns [] and 340 + // nouns still register dynamically. 341 + const nouns = (manifest as { nouns?: ManifestNounDecl[] }).nouns; 342 + return Array.isArray(nouns) ? nouns : []; 343 + } 344 + 345 + function extractShortcuts(manifest: TileManifestV2): ManifestShortcutDecl[] { 346 + if (!Array.isArray(manifest.shortcuts)) return []; 347 + return manifest.shortcuts.map(sc => ({ 348 + keys: sc.keys, 349 + command: sc.command, 350 + global: (sc as { global?: boolean }).global, 351 + })); 352 + } 353 + 354 + function extractLazyEvents(manifest: TileManifestV2): Array<{ topic: string; command: string }> { 355 + const out: Array<{ topic: string; command: string }> = []; 356 + if (!Array.isArray(manifest.tiles)) return out; 357 + for (const tile of manifest.tiles) { 358 + const lazyEvents = (tile as { lazyEvents?: Array<{ topic: string; command: string }> }).lazyEvents; 359 + if (Array.isArray(lazyEvents)) { 360 + for (const e of lazyEvents) out.push(e); 361 + } 362 + } 363 + return out; 364 + } 365 + 366 + // --------------------------------------------------------------------------- 367 + // Boot-time discovery (stub — wiring lands in Phase B) 368 + // --------------------------------------------------------------------------- 369 + 370 + /** 371 + * Discover builtin feature manifests on disk and upsert each into the 372 + * cache. Call once at startup after `initDatabase()`. Idempotent. 373 + * 374 + * Stub body for Phase A — returns the list of featureIds it would 375 + * upsert without actually writing. Phase B replaces the no-op with a 376 + * real upsert and handles parse errors (skip + log, don't abort). 377 + */ 378 + export function discoverAndUpsertBuiltins(builtinFeaturesDir: string): string[] { 379 + const seen: string[] = []; 380 + if (!fs.existsSync(builtinFeaturesDir)) return seen; 381 + 382 + for (const entry of fs.readdirSync(builtinFeaturesDir, { withFileTypes: true })) { 383 + if (!entry.isDirectory()) continue; 384 + const manifestPath = path.join(builtinFeaturesDir, entry.name, 'manifest.json'); 385 + if (!fs.existsSync(manifestPath)) continue; 386 + seen.push(entry.name); 387 + // Intentionally not writing in Phase A — scaffold only. 388 + // Phase B replaces this with: upsertFromManifest(entry.name, manifestPath); 389 + } 390 + 391 + return seen; 392 + }
+161
backend/electron/tile-fsm.ts
··· 1 + /** 2 + * Tile Lifecycle FSM — pure transition table. 3 + * 4 + * Single source of truth for what state transitions are allowed and 5 + * what event triggers each one. No electron / node imports; pure TS 6 + * that's safe to import from tests or any other isolated context. 7 + * 8 + * The enforcement engine in `tile-lifecycle.ts` imports this and wires 9 + * state changes to BrowserWindow events, token lifecycle, and the 10 + * `tileWindows` broadcaster registration. 11 + * 12 + * If a renderer ever needs policy introspection (e.g. cmd panel drift 13 + * detection surface), we'll expose a snapshot via strict IPC rather 14 + * than importing this file cross-process. 15 + * 16 + * See docs/tile-lifecycle-fsm.md for the policy this enforces. 17 + */ 18 + 19 + // --------------------------------------------------------------------------- 20 + // States 21 + // --------------------------------------------------------------------------- 22 + 23 + export const STATES = { 24 + UNREGISTERED: 'unregistered', 25 + REGISTERED: 'registered', 26 + LOADING: 'loading', 27 + READY: 'ready', 28 + VISIBLE: 'visible', 29 + UNLOADING: 'unloading', 30 + CRASHED: 'crashed', 31 + } as const; 32 + 33 + export type TileState = typeof STATES[keyof typeof STATES]; 34 + 35 + // --------------------------------------------------------------------------- 36 + // Triggers 37 + // --------------------------------------------------------------------------- 38 + 39 + export const TRIGGERS = { 40 + INSTALL: 'install', 41 + UNINSTALL: 'uninstall', 42 + LOAD: 'load', 43 + TILE_READY: 'tile-ready', 44 + SHOW: 'show', 45 + HIDE: 'hide', 46 + SHUTDOWN: 'shutdown', 47 + CLEANUP_KEEP: 'cleanup-keep', // unloading → registered 48 + CLEANUP_PURGE: 'cleanup-purge', // unloading → unregistered 49 + RENDER_GONE: 'render-gone', 50 + RELAUNCH: 'relaunch', // crashed → loading (manual, never auto per policy) 51 + } as const; 52 + 53 + export type TileTrigger = typeof TRIGGERS[keyof typeof TRIGGERS]; 54 + 55 + // --------------------------------------------------------------------------- 56 + // Transition table 57 + // --------------------------------------------------------------------------- 58 + // 59 + // Read as: from[state][trigger] = toState. Missing = disallowed. 60 + 61 + type TransitionTable = Partial<Record<TileState, Partial<Record<TileTrigger, TileState>>>>; 62 + 63 + const TRANSITIONS: TransitionTable = { 64 + [STATES.UNREGISTERED]: { 65 + [TRIGGERS.INSTALL]: STATES.REGISTERED, 66 + }, 67 + [STATES.REGISTERED]: { 68 + [TRIGGERS.LOAD]: STATES.LOADING, 69 + [TRIGGERS.UNINSTALL]: STATES.UNREGISTERED, 70 + }, 71 + [STATES.LOADING]: { 72 + [TRIGGERS.TILE_READY]: STATES.READY, 73 + [TRIGGERS.RENDER_GONE]: STATES.CRASHED, 74 + // Shutdown mid-load (e.g. disable during boot) → bypass ready. 75 + [TRIGGERS.SHUTDOWN]: STATES.UNLOADING, 76 + }, 77 + [STATES.READY]: { 78 + [TRIGGERS.SHOW]: STATES.VISIBLE, 79 + [TRIGGERS.SHUTDOWN]: STATES.UNLOADING, 80 + [TRIGGERS.RENDER_GONE]: STATES.CRASHED, 81 + }, 82 + [STATES.VISIBLE]: { 83 + [TRIGGERS.HIDE]: STATES.READY, 84 + [TRIGGERS.SHUTDOWN]: STATES.UNLOADING, 85 + [TRIGGERS.RENDER_GONE]: STATES.CRASHED, 86 + }, 87 + [STATES.UNLOADING]: { 88 + [TRIGGERS.CLEANUP_KEEP]: STATES.REGISTERED, 89 + [TRIGGERS.CLEANUP_PURGE]: STATES.UNREGISTERED, 90 + }, 91 + [STATES.CRASHED]: { 92 + [TRIGGERS.CLEANUP_KEEP]: STATES.REGISTERED, 93 + [TRIGGERS.RELAUNCH]: STATES.LOADING, // policy: only user-initiated 94 + [TRIGGERS.UNINSTALL]: STATES.UNREGISTERED, 95 + }, 96 + }; 97 + 98 + // --------------------------------------------------------------------------- 99 + // Validation 100 + // --------------------------------------------------------------------------- 101 + 102 + export type ValidateTransitionResult = 103 + | { ok: true; to: TileState } 104 + | { ok: false; error: string }; 105 + 106 + /** 107 + * Validate a transition. Returns `{ ok: true, to }` on success or 108 + * `{ ok: false, error }` on disallowed transition. Never throws. 109 + */ 110 + export function validateTransition(from: string, trigger: string): ValidateTransitionResult { 111 + const fromTable = TRANSITIONS[from as TileState]; 112 + if (!fromTable) { 113 + return { ok: false, error: `unknown state: ${from}` }; 114 + } 115 + const to = fromTable[trigger as TileTrigger]; 116 + if (to === undefined) { 117 + return { ok: false, error: `disallowed transition: ${from} —${trigger}→ ?` }; 118 + } 119 + return { ok: true, to }; 120 + } 121 + 122 + /** 123 + * Returns an array of valid triggers from the given state. Useful for 124 + * dev assertions and debugging. 125 + */ 126 + export function allowedTriggers(state: string): string[] { 127 + const t = TRANSITIONS[state as TileState]; 128 + return t ? Object.keys(t) : []; 129 + } 130 + 131 + // --------------------------------------------------------------------------- 132 + // Policy helpers (read-only — describe invariants from the doc) 133 + // --------------------------------------------------------------------------- 134 + 135 + /** 136 + * States in which dynamic registrations (api.commands.register, 137 + * registerNoun) are accepted. Publishes arriving when the source tile 138 + * is NOT in one of these states should be treated as drift violations. 139 + */ 140 + export const DYNAMIC_REGISTRATION_STATES: readonly TileState[] = Object.freeze([ 141 + STATES.READY, 142 + STATES.VISIBLE, 143 + ]); 144 + 145 + /** 146 + * States in which command / noun dispatch CAN complete without a 147 + * prior load. When the target is not in one of these, the dispatcher 148 + * MUST trigger LOAD and wait for TILE_READY before dispatching. 149 + */ 150 + export const DISPATCHABLE_STATES: readonly TileState[] = Object.freeze([ 151 + STATES.READY, 152 + STATES.VISIBLE, 153 + ]); 154 + 155 + export function isDispatchable(state: string): boolean { 156 + return (DISPATCHABLE_STATES as readonly string[]).includes(state); 157 + } 158 + 159 + export function acceptsDynamicRegistration(state: string): boolean { 160 + return (DYNAMIC_REGISTRATION_STATES as readonly string[]).includes(state); 161 + }
+181
backend/electron/tile-lifecycle.ts
··· 1 + /** 2 + * tile-lifecycle — Main-process enforcement engine for the tile FSM. 3 + * 4 + * Owns the authoritative `tileId → state` map. Wires renderer events 5 + * (`tile:ready` pubsub, `render-process-gone` on BrowserWindow 6 + * webContents, window close) to state transitions. All callers that 7 + * mutate tile lifecycle go through this module rather than poking 8 + * BrowserWindow / tileWindows / token registries directly. 9 + * 10 + * The transition table lives in `./tile-fsm.ts` as a pure module 11 + * (no electron / node imports). This file is the side-effectful 12 + * engine that applies those transitions against concrete electron 13 + * primitives. 14 + * 15 + * Phase A (this commit): exports + state store + transition helper. 16 + * No callers are wired yet. Phase D replaces the direct uses of 17 + * `launchTile` / `registerTrustedBuiltinWindow` / ad-hoc token revoke 18 + * calls with `requestLoad()` / `requestShow()` / `requestShutdown()`. 19 + * 20 + * See docs/tile-lifecycle-fsm.md. 21 + */ 22 + 23 + import { 24 + STATES, 25 + TRIGGERS, 26 + validateTransition, 27 + isDispatchable, 28 + acceptsDynamicRegistration, 29 + } from './tile-fsm.js'; 30 + 31 + export { STATES, TRIGGERS, isDispatchable, acceptsDynamicRegistration }; 32 + 33 + // --------------------------------------------------------------------------- 34 + // State store 35 + // --------------------------------------------------------------------------- 36 + 37 + /** 38 + * Stored per tile entry. We keep `tileId` and `entryId` as separate 39 + * fields rather than encoding them into a compound key string: feature 40 + * ids may contain colons (e.g. atproto handles like `at://did:plc:…`), 41 + * so a `${tileId}:${entryId}` key can't be split back reliably. 42 + */ 43 + interface TileRecord { 44 + tileId: string; 45 + entryId: string; 46 + state: string; 47 + } 48 + const records = new Map<string, TileRecord>(); 49 + 50 + /** Last-transition metadata for drift diagnostics. */ 51 + interface TransitionRecord { 52 + from: string; 53 + to: string; 54 + trigger: string; 55 + timestamp: number; 56 + context?: Record<string, unknown>; 57 + } 58 + const lastTransition = new Map<string, TransitionRecord>(); 59 + 60 + /** 61 + * Key builder — JSON so any characters in tileId / entryId (including 62 + * colons, slashes) round-trip losslessly without parsing. 63 + */ 64 + function key(tileId: string, entryId: string): string { 65 + return JSON.stringify([tileId, entryId]); 66 + } 67 + 68 + // --------------------------------------------------------------------------- 69 + // Transition — the only way state changes 70 + // --------------------------------------------------------------------------- 71 + 72 + export interface TransitionResult { 73 + ok: boolean; 74 + from: string; 75 + to?: string; 76 + error?: string; 77 + } 78 + 79 + /** 80 + * Request a transition. Returns a result object. Never throws. 81 + * 82 + * On success, updates the internal state map and records the 83 + * transition. On failure, leaves the state map unchanged and returns 84 + * an error — caller decides whether to log, surface, or ignore. 85 + * 86 + * Rationale for never throwing: the enforcement engine is called from 87 + * IPC handlers, event listeners, and renderer-driven requests. Throws 88 + * across those boundaries are harder to reason about than a result 89 + * check. 90 + */ 91 + export function transition( 92 + tileId: string, 93 + entryId: string, 94 + trigger: string, 95 + context?: Record<string, unknown>, 96 + ): TransitionResult { 97 + const k = key(tileId, entryId); 98 + const existing = records.get(k); 99 + const from = existing?.state ?? STATES.UNREGISTERED; 100 + 101 + const result = validateTransition(from, trigger); 102 + if (!result.ok) { 103 + return { ok: false, from, error: result.error }; 104 + } 105 + 106 + // Transitions back to `unregistered` drop the record entirely — the 107 + // tile is gone, so dangling state-map entries for uninstalled tiles 108 + // would leak over install/uninstall cycles. The record is recreated 109 + // on the next INSTALL. 110 + if (result.to === STATES.UNREGISTERED) { 111 + records.delete(k); 112 + } else { 113 + records.set(k, { tileId, entryId, state: result.to }); 114 + } 115 + 116 + lastTransition.set(k, { 117 + from, 118 + to: result.to, 119 + trigger, 120 + timestamp: Date.now(), 121 + context, 122 + }); 123 + 124 + return { ok: true, from, to: result.to }; 125 + } 126 + 127 + // Deliberately no `setStateUnchecked()` — every state change MUST go 128 + // through `transition()` so the FSM is the only source of truth. Tests 129 + // that need a specific state walk the transition graph like production 130 + // code would. 131 + 132 + // --------------------------------------------------------------------------- 133 + // Queries 134 + // --------------------------------------------------------------------------- 135 + 136 + /** Current state of a tile entry, or `unregistered` if unknown. */ 137 + export function getState(tileId: string, entryId: string): string { 138 + return records.get(key(tileId, entryId))?.state ?? STATES.UNREGISTERED; 139 + } 140 + 141 + /** All tracked tile entries and their states. Read-only snapshot. */ 142 + export function listStates(): Array<{ tileId: string; entryId: string; state: string }> { 143 + return Array.from(records.values()).map(r => ({ ...r })); 144 + } 145 + 146 + /** Last recorded transition for a tile entry, or null. Used by drift diagnostics. */ 147 + export function getLastTransition(tileId: string, entryId: string): TransitionRecord | null { 148 + return lastTransition.get(key(tileId, entryId)) ?? null; 149 + } 150 + 151 + /** True iff the tile is in a state that accepts command dispatch. */ 152 + export function isDispatchReady(tileId: string, entryId: string): boolean { 153 + return isDispatchable(getState(tileId, entryId)); 154 + } 155 + 156 + /** 157 + * True iff dynamic registrations (api.commands.register etc.) from 158 + * this tile should be accepted. Dev mode drift detection uses this 159 + * to reject out-of-state publishes. 160 + */ 161 + export function acceptsRegistration(tileId: string, entryId: string): boolean { 162 + return acceptsDynamicRegistration(getState(tileId, entryId)); 163 + } 164 + 165 + // --------------------------------------------------------------------------- 166 + // Test hooks 167 + // --------------------------------------------------------------------------- 168 + 169 + /** 170 + * Wipe all tracked state. Only callable when `NODE_ENV === 'test'` — 171 + * production code that hits this throws immediately so we notice the 172 + * misuse in review rather than silently corrupting state. Tests must 173 + * set `NODE_ENV=test` in their runner invocation. 174 + */ 175 + export function resetForTests(): void { 176 + if (process.env.NODE_ENV !== 'test') { 177 + throw new Error('[tile-lifecycle] resetForTests() called outside NODE_ENV=test'); 178 + } 179 + records.clear(); 180 + lastTransition.clear(); 181 + }
+240
docs/tile-lifecycle-fsm.md
··· 1 + # Tile Lifecycle FSM + Manifest Cache 2 + 3 + Status: design, pre-implementation. Tracks the policy hammered out in the 4 + 2026-04-22 session. All prior `launchTile` vs `window-open` divergence, 5 + silent `noun:register-batch` allowlist drops, and spinner-hang-on- 6 + unloaded-tile bugs are instances of drift from this policy. 7 + 8 + ## Goals 9 + 10 + 1. A single code path for creating any tile window, regardless of whether 11 + it's loaded eagerly at boot, lazily on demand, or re-loaded after a 12 + crash. Timing controls *when*, never *how*. 13 + 2. Static manifest declarations (commands, nouns, shortcuts, 14 + capabilities, lazyEvents) surface without the owning tile running. 15 + They're cached at install/update and read from the cache by the 16 + cmd panel, shortcut registry, and event interceptors. 17 + 3. Dynamic registrations require a live tile (state ≥ `ready`) and are 18 + scoped to its lifetime. 19 + 4. Cmd/noun dispatch to a tile ensures the tile is loaded, or surfaces 20 + an error — never a silent hang. 21 + 5. Lifecycle drift is detectable at runtime (dev assertions) rather 22 + than presenting as silent bugs downstream. 23 + 24 + ## States 25 + 26 + | State | Meaning | 27 + |---|---| 28 + | `unregistered` | Never installed, or explicitly removed. No entry in `manifest_cache`. | 29 + | `registered` | Manifest parsed + cached. Static declarations wired into main-process registries. Tile renderer NOT loaded. | 30 + | `loading` | `BrowserWindow` created, tile URL loading, awaiting the renderer's `tile:ready` publish. | 31 + | `ready` | Renderer initialised, capability token validated, dynamic registrations accepted. Window not shown. | 32 + | `visible` | `ready` + window currently on screen. | 33 + | `unloading` | Shutdown sequence in progress (`ext:{id}:shutdown` grace window → token revoke → window close → subscription cleanup). | 34 + | `crashed` | Render process exited unexpectedly. Awaiting cleanup or auto-relaunch. | 35 + 36 + ## Transitions 37 + 38 + ``` 39 + ┌──────────────────────────────┐ 40 + install │ │ 41 + ┌───────▶ unregistered ─────uninstall────────────┤ 42 + │ │ │ 43 + │ │ parse-manifest │ 44 + │ ▼ │ 45 + │ registered ◀───────── unloading ◀────┤ 46 + │ │ │ 47 + │ load trigger (eager OR dispatch) │ 48 + │ ▼ │ 49 + │ loading ───render-gone───▶ crashed ──┤ 50 + │ │ │ │ 51 + │ tile:ready │ │ 52 + │ ▼ │ │ 53 + │ ready ◀───hide────── visible │ │ 54 + │ │ │ ▲ │ │ │ 55 + │ │ └──show────────┘ │ │ │ 56 + │ │ │ │ │ 57 + │ ├───────────────────┤ │ │ 58 + │ │ disable/quit │ │ 59 + │ ▼ │ │ 60 + └───────── unloading ◀────────────────────┘ │ 61 + │ │ 62 + └───────────────────────────────┘ 63 + ``` 64 + 65 + Allowed transitions (explicit table): 66 + 67 + | From | Trigger | To | 68 + |---|---|---| 69 + | `unregistered` | install / discover manifest | `registered` | 70 + | `registered` | eager boot (resident) OR lazy dispatch | `loading` | 71 + | `registered` | uninstall | `unregistered` | 72 + | `loading` | renderer `tile:ready` | `ready` | 73 + | `loading` | render-process-gone | `crashed` | 74 + | `ready` | show / showSelf | `visible` | 75 + | `visible` | hide / hideSelf | `ready` | 76 + | `ready` \| `visible` | shutdown (disable, quit, manual unload) | `unloading` | 77 + | `ready` \| `visible` | render-process-gone | `crashed` | 78 + | `unloading` | cleanup done, still installed | `registered` | 79 + | `unloading` | cleanup done, being uninstalled | `unregistered` | 80 + | `crashed` | cleanup done (no auto-relaunch) | `registered` | 81 + | `crashed` | auto-relaunch policy fires | `loading` | 82 + 83 + ## Invariants 84 + 85 + 1. **Static declarations are wired only on `unregistered → registered`.** 86 + Cmd panel / shortcut registry / lazyEvents read them from the 87 + manifest cache. Tiles need not be loaded for their declared commands 88 + to appear. 89 + 2. **Dynamic registrations (via `api.commands.register()` etc.) are 90 + accepted only in `ready` or `visible`.** Publishes arriving 91 + outside those states → rejected with a violation event (not silent 92 + drop). 93 + 3. **Window creation occurs only on `registered → loading`.** No tile 94 + URL is ever loaded by a raw `new BrowserWindow` bypassing the FSM. 95 + Peek chrome (frameless where applicable), role resolution, session- 96 + registry entry, tracking, and custom menu are all applied in this 97 + transition — same code regardless of trigger. 98 + 4. **`tileWindows` registry is populated on `loading → ready` and 99 + cleared on `→ registered` / `→ unregistered` / `→ crashed`.** 100 + 5. **Command dispatch requires target state `≥ ready`.** If the target 101 + is in `registered`, the dispatcher forces `registered → loading → 102 + ready`, then dispatches. If the load fails (timeout, error), the 103 + dispatch rejects with an error — not a pending spinner. 104 + 6. **Manifest cache is the single source of truth for static decls.** 105 + Writes happen only at install / update. Reads happen from many 106 + places (cmd panel, shortcut registry, lazyEvents, Features pane). 107 + 7. **Manifest cache is empty in ephemeral profiles (`tmp-*`) on boot.** 108 + First-boot behavior: discover manifests, parse, populate. No cross- 109 + session state. 110 + 111 + ## Drift detectors (dev-mode assertions) 112 + 113 + These fire in dev (DEBUG=1) and in test runs. In production they 114 + degrade to a log entry and a `tile:drift` GLOBAL pubsub so a 115 + monitoring widget can surface them. 116 + 117 + - **Pubsub-without-ready.** A `cmd:register` / `noun:register-batch` 118 + publish whose source tile is not in `ready`/`visible` → violation. 119 + Catches the case where a tile publishes before `tile:ready` or after 120 + `unloading` starts. 121 + - **Off-path window creation.** A `BrowserWindow` whose URL is 122 + `peek://{id}/...` created outside a `registered → loading` 123 + transition → violation. Catches code that bypasses the unified 124 + create path. 125 + - **Dispatch-without-ready.** A `cmd:execute:{name}` publish to a 126 + tile whose target is in `registered` without a pending 127 + load-on-dispatch promise → violation. Catches handlers that send 128 + into the void. 129 + - **Cache/code mismatch.** At `loading → ready`, the tile renderer 130 + sends its set of dynamically-registered command names. The FSM 131 + compares against the manifest cache. If the cache claims a command 132 + the tile hasn't implemented (or vice versa), violation. 133 + Rationale: reload + install/update are the only ways manifests and 134 + code can diverge, and both should invalidate the cache. 135 + 136 + ## Manifest cache (manifest_cache table) 137 + 138 + New table. Distinct from `features_history` (which is an immutable 139 + audit log). This table is the current installed / discovered state. 140 + 141 + ```sql 142 + CREATE TABLE IF NOT EXISTS manifest_cache ( 143 + featureId TEXT PRIMARY KEY, 144 + version TEXT NOT NULL, 145 + manifest TEXT NOT NULL, -- parsed manifest as JSON 146 + commands TEXT, -- array of command declarations 147 + nouns TEXT, -- array of static noun declarations 148 + shortcuts TEXT, -- array of shortcut bindings 149 + capabilities TEXT, -- resolved capability grant shape 150 + lazyEvents TEXT, -- array of {topic, command} 151 + updatedAt INTEGER NOT NULL, 152 + disabledAt INTEGER -- null = enabled 153 + ); 154 + ``` 155 + 156 + Write path (single): `featureInstallOrUpdate(manifestPath)` — parses, 157 + validates, writes row. Only call-site is the feature-installer and the 158 + boot-time manifest discovery pass. 159 + 160 + Read paths: 161 + - Cmd panel / cmd registry: `listCommandsForPanel()` → merges 162 + declared commands from every enabled row with any dynamic 163 + registrations from currently-ready tiles. 164 + - Shortcut registry: `registerDeclaredShortcuts()` at boot. 165 + - LazyEvents interceptor: `registerDeclaredLazyEvents()` at boot. 166 + - Features pane: `getAllRegisteredFeatures()` lists every row. 167 + 168 + ## Ephemeral profile behavior 169 + 170 + Profiles whose name starts with `tmp-` or `test-` treat the table as 171 + ephemeral: 172 + - On boot, if the table exists but the profile has never been booted 173 + before, it's truncated. 174 + - Manifests are rediscovered from the filesystem. 175 + - Dev profile (`dev`) also re-parses manifests on every boot (cache 176 + write is unconditional, invalidating any stale entries). 177 + 178 + ## Module layout 179 + 180 + Policy lives where its consumers do. The pure FSM is a pure TS module 181 + that could port anywhere; today only the main process imports it. If 182 + a renderer ever needs policy introspection (e.g. cmd panel drift 183 + detection surface), we'll expose a snapshot via strict IPC rather 184 + than import the FSM file cross-process. 185 + 186 + - `backend/electron/tile-fsm.ts` — **pure** transition table + `validateTransition(from, trigger) → to | error`. No electron / node imports. Testable in isolation. 187 + - `backend/electron/tile-lifecycle.ts` — state store + enforcement engine. Wires the pure FSM to BrowserWindow events, token lifecycle, and broadcaster registration. Authoritative `tileId → state` map lives here. 188 + - `backend/electron/manifest-cache.ts` — SQLite writer + reader for `manifest_cache`. Only write call-sites: feature install path + boot-time manifest discovery. Read API exposed via strict tile IPC for cmd panel / features pane. Distinct from `backend/electron/feature-registry.ts`, which stores install metadata (source, publisher, capability grants, update policy) in `registry.json`. 189 + - `app/cmd/background.js` — already in the core renderer; stops subscribing to `noun:register-batch` / `cmd:register` for *declared* commands (those come from the cache via IPC). Keeps the subscriptions for *dynamic* registrations from live tiles. 190 + 191 + ## Phased plan 192 + 193 + 1. **Phase A (this commit).** Design doc + pure FSM module + manifest_cache 194 + table + schema migration + stub enforcement engine. No behavior change; 195 + scaffold only. 196 + 2. **Phase B.** Parse-on-install path. Feature installer writes to 197 + manifest_cache. Boot-time manifest discovery does the same for 198 + builtins. No reads yet. 199 + 3. **Phase C.** Cmd panel / shortcut registry / lazyEvents read from 200 + manifest_cache. Tile publishes of declarative commands become 201 + no-ops (or advisory). This kills the allowlist-blocked 202 + `noun:register-batch` pain for declared nouns. 203 + 4. **Phase D.** Unify window creation. `launchTile` → shared window- 204 + open pipeline. Entities-style single-tile UIs get peek chrome. 205 + FSM transitions wire up to existing lifecycle events. 206 + 5. **Phase E.** Load-on-dispatch. Dispatcher ensures target state 207 + before sending `cmd:execute`. Timeout → reject. Spinner hangs 208 + become explicit errors. 209 + 6. **Phase F.** Drift detectors in dev + `tile:drift` pubsub. 210 + 7. **Phase G (stretch).** Migrate declared `open {noun}` duplicates: 211 + features that declare both a manifest command AND register a noun 212 + with the same name drop the declarative duplicate; noun registration 213 + is authoritative (tracked separately in 214 + [task #11](../docs/tasks.md)). 215 + 216 + ## Non-goals 217 + 218 + - Tauri / iOS path. This is Electron-only for now. The FSM semantics 219 + port to other backends later; the SQLite cache is Electron-specific 220 + until we unify backends. 221 + - Changing the tile-preload API surface. `api.commands.register()` 222 + still exists for dynamic registrations. 223 + - Schema migration for existing users — on the first boot after this 224 + lands, an empty `manifest_cache` gets populated from manifest 225 + files. No user-visible data loss. 226 + 227 + ## Test plan 228 + 229 + - Unit: FSM transitions (pure function). Each allowed transition + 230 + every disallowed transition returns an error. 231 + - Unit: manifest_cache round-trip (write manifest → read back 232 + matches). 233 + - Integration: boot with no cache, assert cmd panel shows declared 234 + commands for every enabled feature before any tile is loaded. 235 + - Integration: dispatch `open entities` from cold cmd panel when 236 + entities tile is `registered` (not loaded), assert tile loads and 237 + view opens. 238 + - Integration: cmd panel dispatch to an uninstalled tile → explicit 239 + error (no spinner hang). 240 + - Regression: current green desktop suite stays green.
+1 -1
package.json
··· 138 138 "test:packaged": "yarn kill:packaged; HEADLESS=1 PACKAGED=1 npx playwright test tests/desktop/", 139 139 "test:packaged:debug": "yarn kill:packaged; HEADLESS=1 PACKAGED=1 DEBUG=1 npx playwright test tests/desktop/", 140 140 "//-- Testing --//": "", 141 - "test:unit": "./scripts/timed.sh sh -c 'yarn build && ELECTRON_RUN_AS_NODE=1 npx electron --test dist/backend/electron/*.test.js && node --test tests/unit/*.test.js'", 141 + "test:unit": "./scripts/timed.sh sh -c 'yarn build && NODE_ENV=test ELECTRON_RUN_AS_NODE=1 npx electron --test dist/backend/electron/*.test.js && NODE_ENV=test node --test tests/unit/*.test.js'", 142 142 "test:unit:modes": "./scripts/timed.sh sh -c 'yarn build && ELECTRON_RUN_AS_NODE=1 npx electron --test dist/backend/electron/modes.test.js'", 143 143 "test:unit:shortcuts": "./scripts/timed.sh sh -c 'yarn build && ELECTRON_RUN_AS_NODE=1 npx electron --test dist/backend/electron/shortcuts.test.js'", 144 144 "test:unit:datastore": "./scripts/timed.sh sh -c 'yarn build && ELECTRON_RUN_AS_NODE=1 npx electron --test dist/backend/electron/datastore.test.js'",
+170
tests/unit/tile-fsm.test.js
··· 1 + /** 2 + * Tile Lifecycle FSM — pure transition tests. 3 + * 4 + * These tests exercise `validateTransition` against every state×trigger 5 + * combination, asserting the full table from docs/tile-lifecycle-fsm.md. 6 + * Any drift from the documented policy fails the test. 7 + */ 8 + 9 + import { test } from 'node:test'; 10 + import assert from 'node:assert/strict'; 11 + 12 + import { 13 + STATES, 14 + TRIGGERS, 15 + validateTransition, 16 + allowedTriggers, 17 + isDispatchable, 18 + acceptsDynamicRegistration, 19 + DISPATCHABLE_STATES, 20 + DYNAMIC_REGISTRATION_STATES, 21 + } from '../../dist/backend/electron/tile-fsm.js'; 22 + 23 + // --------------------------------------------------------------------------- 24 + // Allowed transitions (doc source of truth — keep in sync with FSM table) 25 + // --------------------------------------------------------------------------- 26 + 27 + const ALLOWED = [ 28 + // unregistered 29 + { from: STATES.UNREGISTERED, trigger: TRIGGERS.INSTALL, to: STATES.REGISTERED }, 30 + 31 + // registered 32 + { from: STATES.REGISTERED, trigger: TRIGGERS.LOAD, to: STATES.LOADING }, 33 + { from: STATES.REGISTERED, trigger: TRIGGERS.UNINSTALL, to: STATES.UNREGISTERED }, 34 + 35 + // loading 36 + { from: STATES.LOADING, trigger: TRIGGERS.TILE_READY, to: STATES.READY }, 37 + { from: STATES.LOADING, trigger: TRIGGERS.RENDER_GONE, to: STATES.CRASHED }, 38 + { from: STATES.LOADING, trigger: TRIGGERS.SHUTDOWN, to: STATES.UNLOADING }, 39 + 40 + // ready 41 + { from: STATES.READY, trigger: TRIGGERS.SHOW, to: STATES.VISIBLE }, 42 + { from: STATES.READY, trigger: TRIGGERS.SHUTDOWN, to: STATES.UNLOADING }, 43 + { from: STATES.READY, trigger: TRIGGERS.RENDER_GONE, to: STATES.CRASHED }, 44 + 45 + // visible 46 + { from: STATES.VISIBLE, trigger: TRIGGERS.HIDE, to: STATES.READY }, 47 + { from: STATES.VISIBLE, trigger: TRIGGERS.SHUTDOWN, to: STATES.UNLOADING }, 48 + { from: STATES.VISIBLE, trigger: TRIGGERS.RENDER_GONE, to: STATES.CRASHED }, 49 + 50 + // unloading 51 + { from: STATES.UNLOADING, trigger: TRIGGERS.CLEANUP_KEEP, to: STATES.REGISTERED }, 52 + { from: STATES.UNLOADING, trigger: TRIGGERS.CLEANUP_PURGE, to: STATES.UNREGISTERED }, 53 + 54 + // crashed 55 + { from: STATES.CRASHED, trigger: TRIGGERS.CLEANUP_KEEP, to: STATES.REGISTERED }, 56 + { from: STATES.CRASHED, trigger: TRIGGERS.RELAUNCH, to: STATES.LOADING }, 57 + { from: STATES.CRASHED, trigger: TRIGGERS.UNINSTALL, to: STATES.UNREGISTERED }, 58 + ]; 59 + 60 + test('every documented transition is accepted by validateTransition', () => { 61 + for (const { from, trigger, to } of ALLOWED) { 62 + const result = validateTransition(from, trigger); 63 + assert.deepEqual(result, { ok: true, to }, `expected ${from} —${trigger}→ ${to}`); 64 + } 65 + }); 66 + 67 + test('every undocumented transition is rejected', () => { 68 + const allStates = Object.values(STATES); 69 + const allTriggers = Object.values(TRIGGERS); 70 + const allowedSet = new Set(ALLOWED.map(t => `${t.from}|${t.trigger}`)); 71 + 72 + for (const from of allStates) { 73 + for (const trigger of allTriggers) { 74 + const key = `${from}|${trigger}`; 75 + if (allowedSet.has(key)) continue; 76 + const result = validateTransition(from, trigger); 77 + assert.equal(result.ok, false, `should reject ${from} —${trigger}→ ?`); 78 + assert.match(result.error, /disallowed|unknown/); 79 + } 80 + } 81 + }); 82 + 83 + test('validateTransition from an unknown state returns an error, does not throw', () => { 84 + const result = validateTransition('totally-invented', TRIGGERS.LOAD); 85 + assert.equal(result.ok, false); 86 + assert.match(result.error, /unknown state/); 87 + }); 88 + 89 + test('allowedTriggers returns the documented set for each state', () => { 90 + const expected = { 91 + [STATES.UNREGISTERED]: [TRIGGERS.INSTALL], 92 + [STATES.REGISTERED]: [TRIGGERS.LOAD, TRIGGERS.UNINSTALL], 93 + [STATES.LOADING]: [TRIGGERS.TILE_READY, TRIGGERS.RENDER_GONE, TRIGGERS.SHUTDOWN], 94 + [STATES.READY]: [TRIGGERS.SHOW, TRIGGERS.SHUTDOWN, TRIGGERS.RENDER_GONE], 95 + [STATES.VISIBLE]: [TRIGGERS.HIDE, TRIGGERS.SHUTDOWN, TRIGGERS.RENDER_GONE], 96 + [STATES.UNLOADING]: [TRIGGERS.CLEANUP_KEEP, TRIGGERS.CLEANUP_PURGE], 97 + [STATES.CRASHED]: [TRIGGERS.CLEANUP_KEEP, TRIGGERS.RELAUNCH, TRIGGERS.UNINSTALL], 98 + }; 99 + 100 + for (const [state, want] of Object.entries(expected)) { 101 + const got = allowedTriggers(state); 102 + assert.deepEqual(got.sort(), [...want].sort(), `allowedTriggers(${state})`); 103 + } 104 + }); 105 + 106 + test('allowedTriggers returns [] for an unknown state', () => { 107 + assert.deepEqual(allowedTriggers('not-a-state'), []); 108 + }); 109 + 110 + // --------------------------------------------------------------------------- 111 + // Policy invariants (dispatch / dynamic registration gating) 112 + // --------------------------------------------------------------------------- 113 + 114 + test('dispatchable states: ready + visible only', () => { 115 + assert.equal(isDispatchable(STATES.READY), true); 116 + assert.equal(isDispatchable(STATES.VISIBLE), true); 117 + 118 + for (const s of [STATES.UNREGISTERED, STATES.REGISTERED, STATES.LOADING, STATES.UNLOADING, STATES.CRASHED]) { 119 + assert.equal(isDispatchable(s), false, `${s} should not be dispatchable`); 120 + } 121 + }); 122 + 123 + test('dynamic registration states: ready + visible only', () => { 124 + assert.equal(acceptsDynamicRegistration(STATES.READY), true); 125 + assert.equal(acceptsDynamicRegistration(STATES.VISIBLE), true); 126 + 127 + for (const s of [STATES.UNREGISTERED, STATES.REGISTERED, STATES.LOADING, STATES.UNLOADING, STATES.CRASHED]) { 128 + assert.equal(acceptsDynamicRegistration(s), false, `${s} should not accept dynamic registration`); 129 + } 130 + }); 131 + 132 + test('dispatchable and dynamic-registration state sets match the doc', () => { 133 + // Separate exports so they can diverge if policy ever requires it; 134 + // today they're the same list. 135 + assert.deepEqual( 136 + [...DISPATCHABLE_STATES].sort(), 137 + [STATES.READY, STATES.VISIBLE].sort(), 138 + ); 139 + assert.deepEqual( 140 + [...DYNAMIC_REGISTRATION_STATES].sort(), 141 + [STATES.READY, STATES.VISIBLE].sort(), 142 + ); 143 + }); 144 + 145 + // --------------------------------------------------------------------------- 146 + // Reachability (sanity — every state reachable from install, and every 147 + // state has a path back to a terminal or recovery state) 148 + // --------------------------------------------------------------------------- 149 + 150 + test('every state is reachable from the unregistered root via allowed transitions', () => { 151 + // BFS from unregistered 152 + const seen = new Set([STATES.UNREGISTERED]); 153 + const queue = [STATES.UNREGISTERED]; 154 + const allTriggers = Object.values(TRIGGERS); 155 + 156 + while (queue.length > 0) { 157 + const state = queue.shift(); 158 + for (const t of allTriggers) { 159 + const r = validateTransition(state, t); 160 + if (r.ok && !seen.has(r.to)) { 161 + seen.add(r.to); 162 + queue.push(r.to); 163 + } 164 + } 165 + } 166 + 167 + for (const s of Object.values(STATES)) { 168 + assert.ok(seen.has(s), `state unreachable from unregistered: ${s}`); 169 + } 170 + });
+193
tests/unit/tile-lifecycle.test.js
··· 1 + /** 2 + * tile-lifecycle — state store + transition helper tests. 3 + * 4 + * Imports from the compiled dist output (the module is TypeScript, 5 + * compiled by `yarn build` into `dist/backend/electron/tile-lifecycle.js`). 6 + * Uses Node's built-in test runner under Electron's Node context via 7 + * `yarn test:unit`. 8 + */ 9 + 10 + import { test } from 'node:test'; 11 + import assert from 'node:assert/strict'; 12 + 13 + import { 14 + transition, 15 + getState, 16 + listStates, 17 + getLastTransition, 18 + isDispatchReady, 19 + acceptsRegistration, 20 + resetForTests, 21 + STATES, 22 + TRIGGERS, 23 + } from '../../dist/backend/electron/tile-lifecycle.js'; 24 + 25 + /** 26 + * Walk the FSM from `unregistered` up to `target` via legitimate 27 + * transitions. Tests use this instead of a back-door state setter so 28 + * the engine's single-entry-point invariant is preserved. 29 + */ 30 + function seed(tileId, entryId, target) { 31 + const path = { 32 + [STATES.UNREGISTERED]: [], 33 + [STATES.REGISTERED]: [TRIGGERS.INSTALL], 34 + [STATES.LOADING]: [TRIGGERS.INSTALL, TRIGGERS.LOAD], 35 + [STATES.READY]: [TRIGGERS.INSTALL, TRIGGERS.LOAD, TRIGGERS.TILE_READY], 36 + [STATES.VISIBLE]: [TRIGGERS.INSTALL, TRIGGERS.LOAD, TRIGGERS.TILE_READY, TRIGGERS.SHOW], 37 + [STATES.UNLOADING]: [TRIGGERS.INSTALL, TRIGGERS.LOAD, TRIGGERS.TILE_READY, TRIGGERS.SHUTDOWN], 38 + [STATES.CRASHED]: [TRIGGERS.INSTALL, TRIGGERS.LOAD, TRIGGERS.TILE_READY, TRIGGERS.RENDER_GONE], 39 + }[target]; 40 + if (!path) throw new Error(`seed: no path to ${target}`); 41 + for (const trigger of path) { 42 + const r = transition(tileId, entryId, trigger); 43 + if (!r.ok) throw new Error(`seed ${tileId}/${entryId} → ${target}: ${trigger} failed: ${r.error}`); 44 + } 45 + } 46 + 47 + test('unknown tile starts in unregistered state', () => { 48 + resetForTests(); 49 + assert.equal(getState('example', 'home'), STATES.UNREGISTERED); 50 + }); 51 + 52 + test('happy path: unregistered → registered → loading → ready → visible', () => { 53 + resetForTests(); 54 + const tileId = 'example'; 55 + const entryId = 'home'; 56 + 57 + assert.equal(transition(tileId, entryId, TRIGGERS.INSTALL).ok, true); 58 + assert.equal(getState(tileId, entryId), STATES.REGISTERED); 59 + 60 + assert.equal(transition(tileId, entryId, TRIGGERS.LOAD).ok, true); 61 + assert.equal(getState(tileId, entryId), STATES.LOADING); 62 + 63 + assert.equal(transition(tileId, entryId, TRIGGERS.TILE_READY).ok, true); 64 + assert.equal(getState(tileId, entryId), STATES.READY); 65 + 66 + assert.equal(transition(tileId, entryId, TRIGGERS.SHOW).ok, true); 67 + assert.equal(getState(tileId, entryId), STATES.VISIBLE); 68 + }); 69 + 70 + test('disallowed transition returns ok:false and does not mutate state', () => { 71 + resetForTests(); 72 + transition('example', 'home', TRIGGERS.INSTALL); 73 + // Can't skip from registered → ready directly (must go through loading) 74 + const result = transition('example', 'home', TRIGGERS.TILE_READY); 75 + assert.equal(result.ok, false); 76 + assert.equal(getState('example', 'home'), STATES.REGISTERED); 77 + }); 78 + 79 + test('render crash from ready lands in crashed state', () => { 80 + resetForTests(); 81 + transition('example', 'home', TRIGGERS.INSTALL); 82 + transition('example', 'home', TRIGGERS.LOAD); 83 + transition('example', 'home', TRIGGERS.TILE_READY); 84 + transition('example', 'home', TRIGGERS.RENDER_GONE); 85 + assert.equal(getState('example', 'home'), STATES.CRASHED); 86 + }); 87 + 88 + test('crashed tile can recover via cleanup-keep back to registered', () => { 89 + resetForTests(); 90 + seed('example', 'home', STATES.CRASHED); 91 + transition('example', 'home', TRIGGERS.CLEANUP_KEEP); 92 + assert.equal(getState('example', 'home'), STATES.REGISTERED); 93 + }); 94 + 95 + test('dispatch-ready only for ready and visible', () => { 96 + // Walk the FSM to each state that's reachable and check dispatch 97 + // readiness. `unregistered` is the implicit default (no record 98 + // yet), so we test it without any seeding. 99 + resetForTests(); 100 + assert.equal(isDispatchReady('x', 'y'), false); // unregistered 101 + 102 + for (const state of Object.values(STATES)) { 103 + if (state === STATES.UNREGISTERED) continue; 104 + resetForTests(); 105 + seed('x', 'y', state); 106 + const expected = state === STATES.READY || state === STATES.VISIBLE; 107 + assert.equal(isDispatchReady('x', 'y'), expected, `isDispatchReady for ${state}`); 108 + assert.equal(acceptsRegistration('x', 'y'), expected, `acceptsRegistration for ${state}`); 109 + } 110 + }); 111 + 112 + test('listStates returns snapshot of all tracked tiles', () => { 113 + resetForTests(); 114 + transition('a', 'home', TRIGGERS.INSTALL); 115 + transition('b', 'home', TRIGGERS.INSTALL); 116 + transition('b', 'home', TRIGGERS.LOAD); 117 + 118 + const snap = listStates(); 119 + const byKey = new Map(snap.map(r => [`${r.tileId}:${r.entryId}`, r.state])); 120 + assert.equal(byKey.get('a:home'), STATES.REGISTERED); 121 + assert.equal(byKey.get('b:home'), STATES.LOADING); 122 + }); 123 + 124 + test('getLastTransition records from/to/trigger', () => { 125 + resetForTests(); 126 + transition('example', 'home', TRIGGERS.INSTALL); 127 + transition('example', 'home', TRIGGERS.LOAD); 128 + const last = getLastTransition('example', 'home'); 129 + assert.ok(last); 130 + assert.equal(last.from, STATES.REGISTERED); 131 + assert.equal(last.to, STATES.LOADING); 132 + assert.equal(last.trigger, TRIGGERS.LOAD); 133 + assert.ok(last.timestamp > 0); 134 + }); 135 + 136 + test('transition context is preserved in lastTransition record', () => { 137 + resetForTests(); 138 + transition('example', 'home', TRIGGERS.INSTALL, { source: 'boot-discovery' }); 139 + const last = getLastTransition('example', 'home'); 140 + assert.deepEqual(last?.context, { source: 'boot-discovery' }); 141 + }); 142 + 143 + test('disallowed transition does not record a transition', () => { 144 + resetForTests(); 145 + transition('example', 'home', TRIGGERS.INSTALL); 146 + const before = getLastTransition('example', 'home'); 147 + transition('example', 'home', TRIGGERS.TILE_READY); // not allowed from registered 148 + const after = getLastTransition('example', 'home'); 149 + assert.deepEqual(after, before); 150 + }); 151 + 152 + test('cleanup-purge removes the record from listStates (no leak)', () => { 153 + resetForTests(); 154 + transition('example', 'home', TRIGGERS.INSTALL); 155 + transition('example', 'home', TRIGGERS.LOAD); 156 + transition('example', 'home', TRIGGERS.SHUTDOWN); 157 + transition('example', 'home', TRIGGERS.CLEANUP_PURGE); 158 + 159 + assert.equal(getState('example', 'home'), STATES.UNREGISTERED); 160 + // The record should be gone — listStates is empty, reinstalling 161 + // starts from a clean slate. 162 + assert.equal(listStates().length, 0); 163 + }); 164 + 165 + test('uninstall from registered removes the record', () => { 166 + resetForTests(); 167 + transition('example', 'home', TRIGGERS.INSTALL); 168 + transition('example', 'home', TRIGGERS.UNINSTALL); 169 + assert.equal(listStates().length, 0); 170 + }); 171 + 172 + test('key encoding handles tileIds with colons (e.g. atproto handles)', () => { 173 + resetForTests(); 174 + const tileId = 'at://did:plc:abcdef/feature'; 175 + transition(tileId, 'home', TRIGGERS.INSTALL); 176 + transition(tileId, 'home', TRIGGERS.LOAD); 177 + assert.equal(getState(tileId, 'home'), STATES.LOADING); 178 + 179 + const snap = listStates(); 180 + assert.equal(snap.length, 1); 181 + assert.equal(snap[0].tileId, tileId); 182 + assert.equal(snap[0].entryId, 'home'); 183 + }); 184 + 185 + test('resetForTests() throws outside NODE_ENV=test', () => { 186 + const original = process.env.NODE_ENV; 187 + process.env.NODE_ENV = 'production'; 188 + try { 189 + assert.throws(() => resetForTests(), /NODE_ENV=test/); 190 + } finally { 191 + process.env.NODE_ENV = original; 192 + } 193 + });