From 74194e4fc05577d6f2b0125369c12597a462fa14 Mon Sep 17 00:00:00 2001 From: Aamer Akhter Date: Sun, 23 Aug 2026 14:40:13 -0400 Subject: [PATCH] feat(tabs): COD-359 add owner-scoped tab layouts --- .changeset/calm-tabs-foundation.md | 5 + CLAUDE.md | 2 +- docs/wiki/HTTP-API.md | 2 +- src/cron/cron-service.ts | 2 +- src/state-store.ts | 74 +- src/tab-layout-legacy-order.ts | 81 ++ src/tab-layout-persistence.ts | 144 +++ src/tab-layout-service.ts | 663 +++++++++++ src/tab-layout.ts | 547 +++++++++ src/types/app-state.ts | 3 + src/web/ports/index.ts | 1 + src/web/ports/session-port.ts | 2 +- src/web/ports/tab-layout-port.ts | 8 + src/web/public/constants.js | 3 +- src/web/routes/index.ts | 1 + src/web/routes/ralph-routes.ts | 2 +- src/web/routes/session-routes.ts | 38 +- src/web/routes/system-routes.ts | 8 +- src/web/routes/tab-layout-routes.ts | 50 + src/web/routes/webview-routes.ts | 34 +- src/web/server.ts | 113 +- src/web/session-order-sse.ts | 15 + src/web/sse-events.ts | 9 +- src/web/sse-stream-manager.ts | 111 +- src/web/tab-layout-sse.ts | 6 + test/cron-service.test.ts | 40 + test/http-contract.test.ts | 181 ++- test/mocks/mock-route-context.ts | 24 +- test/routes/ralph-routes.test.ts | 26 + test/routes/session-order-routes.test.ts | 83 +- test/routes/system-routes.test.ts | 7 +- test/routes/tab-layout-routes.test.ts | 106 ++ test/routes/webview-routes.test.ts | 74 +- test/scheduled-layout-registration.test.ts | 41 + test/session-cleanup.test.ts | 33 +- test/session-order-sse.test.ts | 244 ++++ test/state-store-tab-layout.test.ts | 134 +++ test/state-store.test.ts | 14 + test/tab-layout-legacy-order.test.ts | 180 +++ test/tab-layout-persistence.test.ts | 264 +++++ test/tab-layout-restore-gate.test.ts | 62 + test/tab-layout-service.test.ts | 1213 ++++++++++++++++++++ test/tab-layout-sse.test.ts | 305 +++++ test/tab-layout.test.ts | 383 ++++++ 44 files changed, 5202 insertions(+), 136 deletions(-) create mode 100644 .changeset/calm-tabs-foundation.md create mode 100644 src/tab-layout-legacy-order.ts create mode 100644 src/tab-layout-persistence.ts create mode 100644 src/tab-layout-service.ts create mode 100644 src/tab-layout.ts create mode 100644 src/web/ports/tab-layout-port.ts create mode 100644 src/web/routes/tab-layout-routes.ts create mode 100644 src/web/session-order-sse.ts create mode 100644 src/web/tab-layout-sse.ts create mode 100644 test/routes/tab-layout-routes.test.ts create mode 100644 test/scheduled-layout-registration.test.ts create mode 100644 test/session-order-sse.test.ts create mode 100644 test/state-store-tab-layout.test.ts create mode 100644 test/tab-layout-legacy-order.test.ts create mode 100644 test/tab-layout-persistence.test.ts create mode 100644 test/tab-layout-restore-gate.test.ts create mode 100644 test/tab-layout-service.test.ts create mode 100644 test/tab-layout-sse.test.ts create mode 100644 test/tab-layout.test.ts diff --git a/.changeset/calm-tabs-foundation.md b/.changeset/calm-tabs-foundation.md new file mode 100644 index 00000000..a1fbb58d --- /dev/null +++ b/.changeset/calm-tabs-foundation.md @@ -0,0 +1,5 @@ +--- +"aicodeman": minor +--- + +Add the owner-scoped tab-layout model, persistence, API, lifecycle repair, and synchronized legacy ordering foundation. diff --git a/CLAUDE.md b/CLAUDE.md index eb6af44c..8beb7d88 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -338,7 +338,7 @@ Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. L ### SSE Event Registry -155 event constants in `src/web/sse-events.ts` (backend) and `SSE_EVENTS` in `constants.js` (frontend). **Both must be kept in sync**, and `test/sse-registry-parity.test.ts` is the guard that pins it (currently exactly in sync, 155 = 155, no drift either direction). The backend file's `@fileoverview` carries the per-category breakdown. +156 event constants in `src/web/sse-events.ts` (backend) and `SSE_EVENTS` in `constants.js` (frontend). **Both must be kept in sync**, and `test/sse-registry-parity.test.ts` is the guard that pins it (currently exactly in sync, 156 = 156, no drift either direction). The backend file's `@fileoverview` carries the per-category breakdown, including the two Web tab events. ### API Routes diff --git a/docs/wiki/HTTP-API.md b/docs/wiki/HTTP-API.md index 325b3521..46bf7dc6 100644 --- a/docs/wiki/HTTP-API.md +++ b/docs/wiki/HTTP-API.md @@ -119,7 +119,7 @@ Shell and external CLI sessions accept `idle`, `working`, and `exit`. ## SSE -`GET /api/events` is the live event stream. 155 event names, kept in sync between server and +`GET /api/events` is the live event stream. 156 event names, kept in sync between server and client with a test that fails on drift. The heartbeat is a **named** `sse:heartbeat` event rather than an SSE comment, because diff --git a/src/cron/cron-service.ts b/src/cron/cron-service.ts index c8e27aa5..476ee8f3 100644 --- a/src/cron/cron-service.ts +++ b/src/cron/cron-service.ts @@ -425,7 +425,7 @@ export class CronService { piConfig, owner: job.owner, }); - this.deps.addSession(session); + await this.deps.addSession(session); this.store.incrementSessionsCreated(); this.deps.persistSessionState(session); await this.deps.setupSessionListeners(session); diff --git a/src/state-store.ts b/src/state-store.ts index 444e4ea3..6afaddbf 100644 --- a/src/state-store.ts +++ b/src/state-store.ts @@ -40,6 +40,8 @@ import { } from './types.js'; import { Debouncer, MAX_SESSION_TOKENS } from './utils/index.js'; import { dataPath, CODEMAN_INSTANCE } from './config/instance.js'; +import { normalizeSessionOrder } from './session-order.js'; +import { validateTabLayout, type TabLayout } from './tab-layout.js'; /** Debounce delay for batching state writes (ms) */ const SAVE_DEBOUNCE_MS = 500; @@ -281,6 +283,9 @@ export class StateStore { if (this.state.sessionOrder) { parts.push(`"sessionOrder":${JSON.stringify(this.state.sessionOrder)}`); } + if (this.state.tabLayouts !== undefined) { + parts.push(`"tabLayouts":${JSON.stringify(this.state.tabLayouts)}`); + } return `{${parts.join(',')}}`; } @@ -514,22 +519,28 @@ export class StateStore { */ cleanupStaleSessions(activeSessionIds: Set): { count: number; - cleaned: Array<{ id: string; name?: string }>; + cleaned: Array<{ id: string; name?: string; owner?: string }>; } { - const allSessionIds = Object.keys(this.state.sessions); - const cleaned: Array<{ id: string; name?: string }> = []; + const staleIds = new Set(Object.keys(this.state.sessions).filter((sessionId) => !activeSessionIds.has(sessionId))); + return this.cleanupSessionsByIds(staleIds); + } - for (const sessionId of allSessionIds) { - if (!activeSessionIds.has(sessionId)) { - if (this.state.sessions[sessionId]?.pinned === true) continue; // COD-142: pinned records persist even with no live session - const name = this.state.sessions[sessionId]?.name; - cleaned.push({ id: sessionId, name }); - delete this.state.sessions[sessionId]; - this.cachedSessionJsons.delete(sessionId); - this.dirtySessions.delete(sessionId); - // Also clean up Ralph state for this session - this.ralphStates.delete(sessionId); - } + /** Deletes only confirmed stale session IDs, retaining records pinned after confirmation. */ + cleanupSessionsByIds(sessionIds: ReadonlySet): { + count: number; + cleaned: Array<{ id: string; name?: string; owner?: string }>; + } { + const cleaned: Array<{ id: string; name?: string; owner?: string }> = []; + + for (const sessionId of sessionIds) { + const session = this.state.sessions[sessionId]; + if (!session || session.pinned === true) continue; // COD-142: pinned records persist even with no live session + cleaned.push({ id: sessionId, name: session.name, owner: session.owner }); + delete this.state.sessions[sessionId]; + this.cachedSessionJsons.delete(sessionId); + this.dirtySessions.delete(sessionId); + // Also clean up Ralph state for this session + this.ralphStates.delete(sessionId); } if (cleaned.length > 0) { @@ -664,6 +675,41 @@ export class StateStore { this.save(); } + /** Returns an owner layout, or null before that owner has been migrated. */ + getTabLayout(owner: string): TabLayout | null { + const layouts = this.state.tabLayouts; + return layouts && Object.hasOwn(layouts, owner) ? layouts[owner] : null; + } + + /** Returns a defensive snapshot of every stored owner layout. */ + getTabLayouts(): Record { + return structuredClone(this.state.tabLayouts ?? {}); + } + + /** Validates and atomically persists one owner layout. */ + setTabLayout(owner: string, layout: TabLayout): void { + const validated = validateTabLayout(layout); + this.state.tabLayouts = { ...(this.state.tabLayouts ?? {}), [owner]: validated }; + this.save(); + } + + /** Atomically publishes validated owner layouts and their latest global compatibility projection. */ + commitTabLayoutProjection( + layouts: Readonly>, + projectOrder: (latest: readonly string[]) => readonly string[] + ): { layouts: Record; sessionOrder: string[] } { + const validated = Object.fromEntries( + Object.entries(layouts).map(([owner, layout]) => [owner, validateTabLayout(layout)]) + ); + const sessionOrder = normalizeSessionOrder(projectOrder([...(this.state.sessionOrder ?? [])])); + const nextLayouts = { ...(this.state.tabLayouts ?? {}), ...validated }; + + this.state.tabLayouts = nextLayouts; + this.state.sessionOrder = sessionOrder; + this.save(); + return { layouts: structuredClone(validated), sessionOrder: [...sessionOrder] }; + } + /** Resets all state to initial values and saves immediately. */ reset(): void { this.state = createInitialState(); diff --git a/src/tab-layout-legacy-order.ts b/src/tab-layout-legacy-order.ts new file mode 100644 index 00000000..d5054a90 --- /dev/null +++ b/src/tab-layout-legacy-order.ts @@ -0,0 +1,81 @@ +/** + * @fileoverview Pure compatibility translation between legacy session order and owner tab layouts. + */ + +import { mergeSessionOrder, normalizeSessionOrder } from './session-order.js'; +import { + normalizeTabLayout, + validateTabLayout, + type TabLayout, + type TabRef, + type TabRefMetadata, +} from './tab-layout.js'; + +export interface OwnerOrderProjection { + owner: string; + ownedIds: readonly string[]; + order: readonly string[]; +} + +export function applyLegacySessionRank( + input: TabLayout, + requestedOrder: readonly string[], + metadata: readonly TabRefMetadata[] +): TabLayout { + const layout = validateTabLayout(input); + const requestedRank = new Map(normalizeSessionOrder(requestedOrder).map((id, index) => [id, index])); + const sessionMetadata = new Map(); + for (const item of metadata) { + if (item.kind !== 'session' || !item.ownerValid || !item.visible || sessionMetadata.has(item.id)) continue; + sessionMetadata.set(item.id, item); + } + + const isRanked = (ref: TabRef): boolean => + ref.kind === 'session' && sessionMetadata.has(ref.id) && requestedRank.has(ref.id); + const prepare = (ref: TabRef): TabRef => { + if (ref.kind !== 'session') return { ...ref }; + const item = sessionMetadata.get(ref.id); + const ownerValidParent = item?.parentSessionId && sessionMetadata.has(item.parentSessionId); + return ownerValidParent ? { ...ref, placement: 'manual' } : { ...ref }; + }; + const rankContainer = (refs: readonly TabRef[]): TabRef[] => { + const ranked = refs + .filter(isRanked) + .map(prepare) + .sort((a, b) => requestedRank.get(a.id)! - requestedRank.get(b.id)!); + let rankedIndex = 0; + return refs.map((ref) => (isRanked(ref) ? ranked[rankedIndex++] : { ...ref })); + }; + + const transformed: TabLayout = { + ...layout, + groups: layout.groups.map((group) => ({ ...group, refs: rankContainer(group.refs) })), + ungrouped: rankContainer(layout.ungrouped), + }; + return normalizeTabLayout(transformed, metadata); +} + +export function recomposeGlobalSessionOrder( + current: readonly string[], + projections: readonly OwnerOrderProjection[], + preferred?: readonly string[] +): string[] { + let result = mergeSessionOrder([...(preferred ?? current)], [...current]); + for (const projection of projections) { + const ownedIds = normalizeSessionOrder(projection.ownedIds); + const owned = new Set(ownedIds); + const canonical = normalizeSessionOrder(projection.order).filter((id) => owned.has(id)); + const canonicalSet = new Set(canonical); + for (const id of ownedIds) { + if (canonicalSet.has(id)) continue; + canonicalSet.add(id); + canonical.push(id); + } + + let canonicalIndex = 0; + const recomposed = result.map((id) => (owned.has(id) ? canonical[canonicalIndex++] : id)); + recomposed.push(...canonical.slice(canonicalIndex)); + result = normalizeSessionOrder(recomposed); + } + return result; +} diff --git a/src/tab-layout-persistence.ts b/src/tab-layout-persistence.ts new file mode 100644 index 00000000..4aca0b67 --- /dev/null +++ b/src/tab-layout-persistence.ts @@ -0,0 +1,144 @@ +/** + * @fileoverview Owner-scoped tab-layout persistence and legacy migration primitives. + * + * This module is deliberately independent of routes and runtime managers. Callers + * provide persisted/live session facts plus saved webviews in server-store order. + */ + +import { normalizeTabLayout, type TabLayout, type TabRef, type TabRefMetadata } from './tab-layout.js'; + +export const SINGLE_USER_LAYOUT_OWNER = '@single'; + +export interface TabLayoutSessionRecord { + id: string; + owner?: string; + createdAt: number; + parentSessionId?: string; +} + +export interface TabLayoutWebviewRecord { + id: string; + owner?: string; +} + +export interface TabLayoutMigrationInput { + owner: string; + layouts?: Readonly>; + sessionOrder?: readonly string[]; + persistedSessions: readonly TabLayoutSessionRecord[]; + liveSessions: readonly TabLayoutSessionRecord[]; + /** Saved webviews in authoritative server-store order. */ + webviews: readonly TabLayoutWebviewRecord[]; + /** Required only when creating a layout, making migration deterministic in tests. */ + updatedAt?: string; +} + +export interface TabLayoutMigrationResult { + layout: TabLayout; + layouts: Record; + created: boolean; +} + +/** Resolve the persistence key without accepting an owner key from a client. */ +export function ownerLayoutKey(username?: string): string { + return username || SINGLE_USER_LAYOUT_OWNER; +} + +function recordOwner(record: { owner?: string }): string { + return record.owner ?? SINGLE_USER_LAYOUT_OWNER; +} + +function compareSessions(a: TabLayoutSessionRecord, b: TabLayoutSessionRecord): number { + return a.createdAt - b.createdAt || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0); +} + +function collectSessions(input: TabLayoutMigrationInput): Map { + const sessions = new Map(); + for (const record of input.persistedSessions) sessions.set(record.id, { ...record }); + // A matching live record is authoritative as a whole. In particular, absent + // optional owner/parent fields mean single-user ownership and root lineage; + // retaining those fields from a stale persisted copy changes their semantics. + for (const record of input.liveSessions) sessions.set(record.id, { ...record }); + return sessions; +} + +function buildMetadata( + input: TabLayoutMigrationInput, + sessions: ReadonlyMap +): TabRefMetadata[] { + const ownerSessions = [...sessions.values()] + .filter((record) => recordOwner(record) === input.owner) + .sort(compareSessions); + const sessionOrder = new Map(ownerSessions.map((record, index) => [record.id, index])); + const metadata: TabRefMetadata[] = [...sessions.values()].map((record) => ({ + kind: 'session', + id: record.id, + ownerValid: recordOwner(record) === input.owner, + visible: true, + order: sessionOrder.get(record.id) ?? record.createdAt, + parentSessionId: record.parentSessionId, + })); + const webviewOffset = ownerSessions.length; + input.webviews.forEach((record, index) => { + metadata.push({ + kind: 'webview', + id: record.id, + ownerValid: recordOwner(record) === input.owner, + visible: true, + order: webviewOffset + index, + }); + }); + return metadata; +} + +/** + * Normalize an existing owner layout, or idempotently migrate legacy flat order. + * Unknown stored refs remain unknown to metadata and are therefore preserved. + * No input object is mutated; validation/capacity failure is atomic. + */ +export function normalizeOrMigrateOwnerTabLayout(input: TabLayoutMigrationInput): TabLayoutMigrationResult { + const sessions = collectSessions(input); + const metadata = buildMetadata(input, sessions); + const existing = input.layouts && Object.hasOwn(input.layouts, input.owner) ? input.layouts[input.owner] : undefined; + if (existing) { + const layout = normalizeTabLayout(existing, metadata); + return { layout, layouts: { ...(input.layouts ?? {}), [input.owner]: layout }, created: false }; + } + + const ownerSessions = [...sessions.values()].filter((record) => recordOwner(record) === input.owner); + const ownerSessionById = new Map(ownerSessions.map((record) => [record.id, record])); + const liveOwnerIds = new Set( + input.liveSessions.filter((record) => recordOwner(record) === input.owner).map((record) => record.id) + ); + const seen = new Set(); + const orderedSessions: TabLayoutSessionRecord[] = []; + for (const id of input.sessionOrder ?? []) { + const record = ownerSessionById.get(id); + if (!record || seen.has(id)) continue; + seen.add(id); + orderedSessions.push(record); + } + for (const record of ownerSessions.filter((item) => !seen.has(item.id)).sort(compareSessions)) { + seen.add(record.id); + orderedSessions.push(record); + } + + const refs: TabRef[] = orderedSessions.map((record) => { + const manual = record.parentSessionId !== undefined && liveOwnerIds.has(record.parentSessionId); + return manual ? { kind: 'session', id: record.id, placement: 'manual' } : { kind: 'session', id: record.id }; + }); + for (const webview of input.webviews) { + if (recordOwner(webview) === input.owner) refs.push({ kind: 'webview', id: webview.id }); + } + + const layout = normalizeTabLayout( + { + version: 0, + groups: [], + ungrouped: refs, + updatedAt: input.updatedAt ?? new Date().toISOString(), + }, + metadata + ); + return { layout, layouts: { ...(input.layouts ?? {}), [input.owner]: layout }, created: true }; +} diff --git a/src/tab-layout-service.ts b/src/tab-layout-service.ts new file mode 100644 index 00000000..146be473 --- /dev/null +++ b/src/tab-layout-service.ts @@ -0,0 +1,663 @@ +/** + * @fileoverview Owner-scoped authoritative tab-layout coordination. + * + * This is the single mutation boundary between the pure layout model, persisted + * state, live sessions, saved webviews, and SSE. Lifecycle callers describe one + * completed server action; this service performs at most one versioned write. + */ +import type { StateStore } from './state-store.js'; +import { mergeSessionOrder, normalizeSessionOrder } from './session-order.js'; +import { applyLegacySessionRank, recomposeGlobalSessionOrder } from './tab-layout-legacy-order.js'; +import { + flattenOwnerSessionOrder, + materializeOrphans, + normalizeTabLayout, + TabLayoutValidationError, + validateTabLayout, + type TabLayout, + type TabRef, + type TabRefMetadata, +} from './tab-layout.js'; +import { + normalizeOrMigrateOwnerTabLayout, + SINGLE_USER_LAYOUT_OWNER, + type TabLayoutSessionRecord, + type TabLayoutWebviewRecord, +} from './tab-layout-persistence.js'; +import { SseEvent } from './web/sse-events.js'; + +export interface TabLayoutSessionLike { + id: string; + owner?: string; + createdAt: number; + parentSessionId?: string; +} + +interface TabLayoutServiceDeps { + store: Pick< + StateStore, + 'getTabLayout' | 'getTabLayouts' | 'getSessions' | 'getSessionOrder' | 'commitTabLayoutProjection' + >; + sessions: ReadonlyMap; + readWebviews(): Promise; + broadcast(event: string, data: unknown): void; + broadcastSessionOrder(change: SessionOrderProjectionChange): void; + now?: () => string; +} + +export type TabLayoutPutResult = { status: 'updated'; layout: TabLayout } | { status: 'conflict'; layout: TabLayout }; + +export interface LegacyOrderActor { + owner: string; + isAdmin: boolean; +} + +export interface SessionOrderProjectionChange { + changedOwnerOrders: Record; + globalOrder: string[]; + globalChanged: boolean; +} + +export interface LegacyOrderPutResult extends SessionOrderProjectionChange { + order: string[]; +} + +export interface RemovedTabLayoutSession { + id: string; + owner?: string; +} + +interface PreparedOwnerLayout { + current: TabLayout | null; + authoritative: TabLayout; + metadata: TabRefMetadata[]; + needsReconciliationCommit: boolean; +} + +interface OwnerProjectionPublication { + owner: string; + previous: TabLayout | null; + next: TabLayout; + metadata: readonly TabRefMetadata[]; + excludedSessionIds?: ReadonlySet; +} + +interface PreparedOrderProjection { + owner: string; + previousOrder: string[]; + authoritativeBeforeIds: string[]; + excludedIds: string[]; + currentIds: string[]; + order: string[]; +} + +const ownerOf = (record: { owner?: string }): string => record.owner ?? SINGLE_USER_LAYOUT_OWNER; +const refKey = (ref: Pick): string => `${ref.kind}\u0000${ref.id}`; +const sameLayout = (a: TabLayout, b: TabLayout): boolean => JSON.stringify(a) === JSON.stringify(b); +const sameOrder = (a: readonly string[], b: readonly string[]): boolean => + a.length === b.length && a.every((id, index) => id === b[index]); + +export class TabLayoutService { + private restorationState: 'pending' | 'complete' | 'failed' | 'skipped' = 'pending'; + private readonly ownerQueues = new Map>(); + + constructor(private readonly deps: TabLayoutServiceDeps) {} + + private async withOwner(owner: string, task: () => Promise): Promise { + const previous = this.ownerQueues.get(owner) ?? Promise.resolve(); + const run = previous.catch(() => undefined).then(task); + const tail = run.then( + () => undefined, + () => undefined + ); + this.ownerQueues.set(owner, tail); + try { + return await run; + } finally { + if (this.ownerQueues.get(owner) === tail) this.ownerQueues.delete(owner); + } + } + + /** Acquire multiple owner queues in stable order so overlapping bulk cleanups cannot deadlock. */ + private async withOwners(owners: readonly string[], task: () => Promise, index = 0): Promise { + if (index >= owners.length) return task(); + return this.withOwner(owners[index], () => this.withOwners(owners, task, index + 1)); + } + + markRestorationComplete(): void { + this.restorationState = 'complete'; + } + + markRestorationFailed(): void { + this.restorationState = 'failed'; + } + + markRestorationSkipped(): void { + this.restorationState = 'skipped'; + } + + assertDeletionReady(): void { + if (this.restorationState === 'complete' || this.restorationState === 'skipped') return; + throw new Error(`Tab layout restoration is ${this.restorationState}; destructive deletion is unavailable`); + } + + /** Repair/migrate every owner visible after startup restoration. */ + async reconcileAfterRestoration(): Promise { + if (this.restorationState !== 'complete') return; + const { persisted, live } = this.sessionRecords(); + const webviews = await this.deps.readWebviews(); + const owners = new Set(); + for (const record of [...persisted, ...live, ...webviews]) owners.add(ownerOf(record)); + for (const owner of owners) await this.get(owner); + } + + private sessionRecords(): { persisted: TabLayoutSessionRecord[]; live: TabLayoutSessionRecord[] } { + const persisted = Object.entries(this.deps.store.getSessions()).map(([id, record]) => ({ + id, + owner: record.owner, + createdAt: record.createdAt, + parentSessionId: record.parentSessionId, + })); + const live = [...this.deps.sessions.values()].map((record) => ({ + id: record.id, + owner: record.owner, + createdAt: record.createdAt, + parentSessionId: record.parentSessionId, + })); + return { persisted, live }; + } + + private async facts(owner: string): Promise<{ + persisted: TabLayoutSessionRecord[]; + live: TabLayoutSessionRecord[]; + webviews: readonly TabLayoutWebviewRecord[]; + metadata: TabRefMetadata[]; + }> { + const { persisted, live } = this.sessionRecords(); + const webviews = await this.deps.readWebviews(); + const sessions = new Map(); + for (const record of persisted) sessions.set(record.id, record); + for (const record of live) sessions.set(record.id, record); + const ownedSessions = [...sessions.values()] + .filter((record) => ownerOf(record) === owner) + .sort((a, b) => a.createdAt - b.createdAt || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)); + const sessionOrder = new Map(ownedSessions.map((record, index) => [record.id, index])); + const metadata: TabRefMetadata[] = [...sessions.values()].map((record) => ({ + kind: 'session', + id: record.id, + ownerValid: ownerOf(record) === owner, + visible: true, + order: sessionOrder.get(record.id) ?? record.createdAt, + parentSessionId: record.parentSessionId, + })); + const offset = ownedSessions.length; + webviews.forEach((record, index) => + metadata.push({ + kind: 'webview', + id: record.id, + ownerValid: ownerOf(record) === owner, + visible: true, + order: offset + index, + }) + ); + return { persisted, live, webviews, metadata }; + } + + private prepareCommit(base: TabLayout, next: TabLayout): TabLayout { + return validateTabLayout({ + ...next, + version: base.version + 1, + updatedAt: (this.deps.now ?? (() => new Date().toISOString()))(), + }); + } + + private prepareOrderProjection(item: OwnerProjectionPublication): PreparedOrderProjection { + const excluded = item.excludedSessionIds ?? new Set(); + const authoritativeBeforeIds = item.metadata + .filter((fact) => fact.kind === 'session' && fact.ownerValid && fact.visible) + .map((fact) => fact.id); + const facts = authoritativeBeforeIds.filter((id) => !excluded.has(id)); + const visible = new Set(facts); + const rawPrevious = item.previous ? flattenOwnerSessionOrder(item.previous) : []; + const rawNext = flattenOwnerSessionOrder(item.next); + const previousOrder = rawPrevious.filter((id) => visible.has(id) || excluded.has(id)); + const order = rawNext.filter((id) => visible.has(id) && !excluded.has(id)); + const excludedIds = normalizeSessionOrder([...excluded]); + return { + owner: item.owner, + previousOrder, + authoritativeBeforeIds: normalizeSessionOrder([...authoritativeBeforeIds, ...excluded]), + excludedIds, + currentIds: normalizeSessionOrder([...order, ...facts]), + order, + }; + } + + private projectOrder( + latest: readonly string[], + projections: readonly PreparedOrderProjection[], + preferred?: readonly string[] + ): string[] { + const before = normalizeSessionOrder(latest); + const removed = new Set( + projections.flatMap((projection) => projection.excludedIds.filter((id) => !projection.currentIds.includes(id))) + ); + return recomposeGlobalSessionOrder( + before.filter((id) => !removed.has(id)), + projections.map((projection) => ({ + owner: projection.owner, + ownedIds: projection.currentIds, + order: projection.order, + })), + preferred + ); + } + + private publish( + layouts: Readonly>, + publications: readonly OwnerProjectionPublication[], + preferred?: readonly string[] + ): SessionOrderProjectionChange { + const projections = publications.map((item) => this.prepareOrderProjection(item)); + let beforeOrder: string[] = []; + const accepted = this.deps.store.commitTabLayoutProjection(layouts, (latest) => { + beforeOrder = normalizeSessionOrder(latest); + return this.projectOrder(beforeOrder, projections, preferred); + }); + const changedEntries: Array<[string, string[]]> = []; + for (const projection of projections) { + const beforeIds = new Set(projection.authoritativeBeforeIds); + const currentIds = new Set(projection.currentIds); + const persistedBefore = beforeOrder.filter((id) => beforeIds.has(id)); + const persistedAfter = accepted.sessionOrder.filter((id) => currentIds.has(id)); + const layoutOrderChanged = !sameOrder(projection.previousOrder, projection.order); + const persistedOwnerSliceChanged = !sameOrder(persistedBefore, persistedAfter); + if (layoutOrderChanged || persistedOwnerSliceChanged) { + changedEntries.push([projection.owner, persistedAfter]); + } + } + const change: SessionOrderProjectionChange = { + changedOwnerOrders: Object.fromEntries(changedEntries), + globalOrder: [...accepted.sessionOrder], + globalChanged: !sameOrder(beforeOrder, accepted.sessionOrder), + }; + for (const [owner, layout] of Object.entries(accepted.layouts)) { + this.deps.broadcast(SseEvent.TabLayoutChanged, { owner, version: layout.version }); + } + if (changedEntries.length > 0 || change.globalChanged) this.deps.broadcastSessionOrder(change); + return change; + } + + private commit( + owner: string, + base: TabLayout, + next: TabLayout, + metadata: readonly TabRefMetadata[], + previous: TabLayout | null = base.version < 0 ? null : base + ): TabLayout { + const stored = this.prepareCommit(base, next); + this.publish({ [owner]: stored }, [{ owner, previous, next: stored, metadata }]); + return stored; + } + + private async prepareUnlocked(owner: string): Promise { + const facts = await this.facts(owner); + const current = this.deps.store.getTabLayout(owner); + const authoritative = normalizeOrMigrateOwnerTabLayout({ + owner, + layouts: current ? { [owner]: current } : undefined, + sessionOrder: this.deps.store.getSessionOrder(), + persistedSessions: facts.persisted, + liveSessions: facts.live, + webviews: facts.webviews, + updatedAt: (this.deps.now ?? (() => new Date().toISOString()))(), + }).layout; + return { + current, + authoritative, + metadata: facts.metadata, + needsReconciliationCommit: !current || !sameLayout(current, authoritative), + }; + } + + private async getUnlocked(owner: string): Promise { + const prepared = await this.prepareUnlocked(owner); + if (!prepared.needsReconciliationCommit) { + const publication = { + owner, + previous: prepared.current, + next: prepared.authoritative, + metadata: prepared.metadata, + }; + const latest = this.deps.store.getSessionOrder(); + const projected = this.projectOrder(latest, [this.prepareOrderProjection(publication)]); + if (!sameOrder(normalizeSessionOrder(latest), projected)) this.publish({}, [publication]); + return prepared.authoritative; + } + const base = prepared.current ?? { ...prepared.authoritative, version: -1 }; + return this.commit(owner, base, prepared.authoritative, prepared.metadata); + } + + async get(owner: string): Promise { + return this.withOwner(owner, () => this.getUnlocked(owner)); + } + + async put(owner: string, desired: unknown, baseVersion: number): Promise { + return this.withOwner(owner, async () => { + const prepared = await this.prepareUnlocked(owner); + if (baseVersion !== prepared.authoritative.version) return { status: 'conflict', layout: prepared.authoritative }; + const validated = validateTabLayout(desired); + const owned = new Set(prepared.metadata.filter((item) => item.ownerValid && item.visible).map(refKey)); + const refs = [...validated.groups.flatMap((group) => group.refs), ...validated.ungrouped]; + const invalid = refs.find((ref) => !owned.has(refKey(ref))); + if (invalid) + throw new TabLayoutValidationError(`ref is not owned by layout owner: ${invalid.kind}:${invalid.id}`); + const normalized = normalizeTabLayout( + { ...validated, version: prepared.authoritative.version }, + prepared.metadata + ); + return { + status: 'updated', + layout: this.commit(owner, prepared.authoritative, normalized, prepared.metadata, prepared.current), + }; + }); + } + + async putLegacyOrder(actor: LegacyOrderActor, requested: readonly string[]): Promise { + return actor.isAdmin ? this.putAdminLegacyOrder(requested) : this.putOwnerLegacyOrder(actor.owner, requested); + } + + private async putOwnerLegacyOrder(owner: string, requested: readonly string[]): Promise { + return this.withOwner(owner, async () => { + const prepared = await this.prepareUnlocked(owner); + const normalized = normalizeSessionOrder(requested); + const visible = new Set( + prepared.metadata + .filter((item) => item.kind === 'session' && item.ownerValid && item.visible) + .map((item) => item.id) + ); + const invalid = normalized.find((id) => !visible.has(id)); + if (invalid) throw new TabLayoutValidationError(`session is not owned by layout owner: ${invalid}`); + const currentKnown = flattenOwnerSessionOrder(prepared.authoritative).filter((id) => visible.has(id)); + const effective = mergeSessionOrder(normalized, currentKnown); + const ranked = applyLegacySessionRank(prepared.authoritative, effective, prepared.metadata); + const needsLayout = prepared.needsReconciliationCommit || !sameLayout(prepared.authoritative, ranked); + const base = prepared.current ?? { ...prepared.authoritative, version: -1 }; + const next = needsLayout ? this.prepareCommit(base, ranked) : prepared.authoritative; + const change = this.publish(needsLayout ? { [owner]: next } : {}, [ + { owner, previous: prepared.current, next, metadata: prepared.metadata }, + ]); + return { order: flattenOwnerSessionOrder(next).filter((id) => visible.has(id)), ...change }; + }); + } + + private async putAdminLegacyOrder(requested: readonly string[]): Promise { + const discoverOwners = (): string[] => { + const owners = new Set(Object.keys(this.deps.store.getTabLayouts())); + const { persisted, live } = this.sessionRecords(); + for (const record of [...persisted, ...live]) owners.add(ownerOf(record)); + return [...owners].sort(); + }; + for (;;) { + const owners = discoverOwners(); + const result = await this.withOwners(owners, async (): Promise => { + if (!sameOrder(owners, discoverOwners())) return null; + const normalized = normalizeSessionOrder(requested); + const knownOwners = new Map(); + const { persisted, live } = this.sessionRecords(); + for (const record of persisted) knownOwners.set(record.id, ownerOf(record)); + for (const record of live) knownOwners.set(record.id, ownerOf(record)); + const invalid = normalized.find((id) => !knownOwners.has(id)); + if (invalid) throw new TabLayoutValidationError(`session is not visible machine-wide: ${invalid}`); + + const publications: OwnerProjectionPublication[] = []; + const updates: Record = Object.create(null) as Record; + for (const owner of owners) { + const prepared = await this.prepareUnlocked(owner); + const visible = new Set( + prepared.metadata + .filter((item) => item.kind === 'session' && item.ownerValid && item.visible) + .map((item) => item.id) + ); + const requestedOwner = normalized.filter((id) => visible.has(id)); + const currentKnown = flattenOwnerSessionOrder(prepared.authoritative).filter((id) => visible.has(id)); + const effective = mergeSessionOrder(requestedOwner, currentKnown); + const ranked = applyLegacySessionRank(prepared.authoritative, effective, prepared.metadata); + const needsLayout = prepared.needsReconciliationCommit || !sameLayout(prepared.authoritative, ranked); + const base = prepared.current ?? { ...prepared.authoritative, version: -1 }; + const next = needsLayout ? this.prepareCommit(base, ranked) : prepared.authoritative; + if (needsLayout) updates[owner] = next; + publications.push({ owner, previous: prepared.current, next, metadata: prepared.metadata }); + } + const change = this.publish(updates, publications, normalized); + return { order: [...change.globalOrder], ...change }; + }); + if (result) return result; + } + } + + /** Reconcile one completed session creation into one versioned mutation. */ + async sessionCreated(owner: string): Promise { + return this.get(owner); + } + + /** Reconcile one completed saved-webview creation into one versioned mutation. */ + async webviewCreated(owner: string): Promise { + return this.get(owner); + } + + async sessionsRemoved(removed: readonly RemovedTabLayoutSession[]): Promise { + if (this.restorationState !== 'complete' || removed.length === 0) return; + const byOwner = new Map(); + for (const item of removed) { + const owner = ownerOf(item); + const ids = byOwner.get(owner) ?? []; + ids.push(item.id); + byOwner.set(owner, ids); + } + const owners = [...byOwner.keys()].sort(); + await this.withOwners(owners, async () => { + const publications: OwnerProjectionPublication[] = []; + const updates: Record = Object.create(null) as Record; + for (const owner of owners) { + const ids = byOwner.get(owner) ?? []; + const prepared = await this.prepareUnlocked(owner); + const current = prepared.current; + // Normalize and prune together so stale cleanup, orphan materialization, + // and missing-ref repair remain one versioned server mutation. + const next = normalizeTabLayout( + materializeOrphans(prepared.authoritative, ids, prepared.metadata), + prepared.metadata + ); + const stored = current && !sameLayout(current, next) ? this.prepareCommit(current, next) : null; + if (stored) updates[owner] = stored; + publications.push({ + owner, + previous: current, + next: stored ?? next, + metadata: prepared.metadata, + excludedSessionIds: new Set(ids), + }); + } + if (publications.length > 0) this.publish(updates, publications); + }); + } + + /** + * Hold the owner mutation lock across an irreversible session deletion. + * All failure-prone normalization happens before `action`; the prepared layout + * commits only after the resource cleanup finishes. + */ + async runSessionDeletion(removed: readonly RemovedTabLayoutSession[], action: () => Promise): Promise { + this.assertDeletionReady(); + if (this.restorationState === 'skipped' || removed.length === 0) return action(); + const owners = new Set(removed.map(ownerOf)); + if (owners.size !== 1) throw new Error('A session deletion transaction must contain exactly one owner'); + const owner = owners.values().next().value as string; + const ids = removed.map((item) => item.id); + return this.withOwner(owner, async () => { + const prepared = await this.prepareUnlocked(owner); + const current = prepared.current; + // Prepare while the soon-to-be-deleted sessions are still known, so + // direct children can be materialized before their parent ref is removed. + const next = materializeOrphans(prepared.authoritative, ids, prepared.metadata); + const stored = current && !sameLayout(current, next) ? this.prepareCommit(current, next) : null; + const result = await action(); + this.publish(stored ? { [owner]: stored } : {}, [ + { + owner, + previous: current, + next: stored ?? next, + metadata: prepared.metadata, + excludedSessionIds: new Set(ids), + }, + ]); + return result; + }); + } + + /** + * Prepare every affected owner layout before bulk stale-state deletion. + * The StateStore action remains synchronous in production, so the candidate + * snapshot cannot change between successful preparation and resource removal. + */ + async runStaleSessionCleanup( + activeSessionIds: ReadonlySet, + action: (ids: ReadonlySet) => T | Promise + ): Promise { + this.assertDeletionReady(); + const candidates = Object.entries(this.deps.store.getSessions()) + .filter(([id, record]) => !activeSessionIds.has(id) && record.pinned !== true) + .map(([id, record]) => ({ id, owner: record.owner })); + if (this.restorationState === 'skipped') return action(new Set(candidates.map((item) => item.id))); + if (candidates.length === 0) return action(new Set()); + + const byOwner = new Map(); + for (const item of candidates) { + const owner = ownerOf(item); + const ids = byOwner.get(owner) ?? []; + ids.push(item.id); + byOwner.set(owner, ids); + } + const owners = [...byOwner.keys()].sort(); + return this.withOwners(owners, async () => { + const webviews = await this.deps.readWebviews(); + const persistedState = this.deps.store.getSessions(); + const persisted = Object.entries(persistedState).map(([id, record]) => ({ + id, + owner: record.owner, + createdAt: record.createdAt, + parentSessionId: record.parentSessionId, + })); + const liveIds = new Set(this.deps.sessions.keys()); + const confirmed = candidates.filter((candidate) => { + const record = persistedState[candidate.id]; + return ( + record !== undefined && + ownerOf(record) === ownerOf(candidate) && + record.pinned !== true && + !activeSessionIds.has(candidate.id) && + !liveIds.has(candidate.id) + ); + }); + const confirmedByOwner = new Map(); + for (const item of confirmed) { + const owner = ownerOf(item); + const ids = confirmedByOwner.get(owner) ?? []; + ids.push(item.id); + confirmedByOwner.set(owner, ids); + } + + const prepared: Array<{ + owner: string; + current: TabLayout | null; + next: TabLayout; + stored: TabLayout | null; + metadata: TabRefMetadata[]; + excludedSessionIds: ReadonlySet; + }> = []; + for (const owner of owners) { + const ids = confirmedByOwner.get(owner) ?? []; + if (ids.length === 0) continue; + const current = this.deps.store.getTabLayout(owner); + const sessions = new Map(); + for (const record of persisted) sessions.set(record.id, record); + for (const record of this.deps.sessions.values()) sessions.set(record.id, record); + const ownedSessions = [...sessions.values()] + .filter((record) => ownerOf(record) === owner) + .sort((a, b) => a.createdAt - b.createdAt || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)); + const sessionOrder = new Map(ownedSessions.map((record, index) => [record.id, index])); + const metadata: TabRefMetadata[] = [...sessions.values()].map((record) => ({ + kind: 'session', + id: record.id, + ownerValid: ownerOf(record) === owner, + visible: true, + order: sessionOrder.get(record.id) ?? record.createdAt, + parentSessionId: record.parentSessionId, + })); + const offset = ownedSessions.length; + webviews.forEach((record, index) => + metadata.push({ + kind: 'webview', + id: record.id, + ownerValid: ownerOf(record) === owner, + visible: true, + order: offset + index, + }) + ); + const authoritative = normalizeOrMigrateOwnerTabLayout({ + owner, + layouts: current ? { [owner]: current } : undefined, + sessionOrder: this.deps.store.getSessionOrder(), + persistedSessions: persisted, + liveSessions: [...this.deps.sessions.values()], + webviews, + updatedAt: (this.deps.now ?? (() => new Date().toISOString()))(), + }).layout; + const next = materializeOrphans(authoritative, ids, metadata); + prepared.push({ + owner, + current, + next, + stored: current && !sameLayout(current, next) ? this.prepareCommit(current, next) : null, + metadata, + excludedSessionIds: new Set(ids), + }); + } + + const result = await action(new Set(confirmed.map((item) => item.id))); + if (prepared.length > 0) { + this.publish( + Object.fromEntries(prepared.filter((item) => item.stored).map((item) => [item.owner, item.stored!])), + prepared.map((item) => ({ + owner: item.owner, + previous: item.current, + next: item.stored ?? item.next, + metadata: item.metadata, + excludedSessionIds: item.excludedSessionIds, + })) + ); + } + return result; + }); + } + + async webviewDeleted(owner: string, id: string): Promise { + this.assertDeletionReady(); + if (this.restorationState === 'skipped') return; + await this.withOwner(owner, async () => { + const current = this.deps.store.getTabLayout(owner); + if (!current) return; + const strip = (refs: readonly TabRef[]): TabRef[] => + refs.filter((ref) => ref.kind !== 'webview' || ref.id !== id).map((ref) => ({ ...ref })); + const stripped: TabLayout = { + ...current, + groups: current.groups.map((group) => ({ ...group, refs: strip(group.refs) })), + ungrouped: strip(current.ungrouped), + }; + const { metadata } = await this.facts(owner); + const next = normalizeTabLayout(stripped, metadata); + if (!sameLayout(current, next)) this.commit(owner, current, next, metadata); + }); + } +} diff --git a/src/tab-layout.ts b/src/tab-layout.ts new file mode 100644 index 00000000..34cda478 --- /dev/null +++ b/src/tab-layout.ts @@ -0,0 +1,547 @@ +/** + * @fileoverview Framework-independent tab layout model. + * + * Callers provide owner-scoped session/webview metadata. This module deliberately + * has no dependency on session runtime, persistence, routes, or browser state. + */ + +export const MAX_TAB_GROUPS = 32; +export const MAX_TAB_GROUP_NAME_LENGTH = 60; +export const MAX_TAB_REFS = 512; + +export type TabRefKind = 'session' | 'webview'; + +export interface TabRef { + kind: TabRefKind; + id: string; + placement?: 'manual'; +} + +export interface TabGroup { + id: string; + name: string; + refs: TabRef[]; +} + +export interface TabLayout { + version: number; + groups: TabGroup[]; + ungrouped: TabRef[]; + updatedAt: string; +} + +/** Owner and lineage facts supplied by the server or browser integration. */ +export interface TabRefMetadata { + kind: TabRefKind; + id: string; + /** False for missing, foreign-owned, or otherwise invalid refs. */ + ownerValid: boolean; + /** False when the owner is not permitted to see/store this ref. */ + visible: boolean; + /** Stable creation/sibling order. Ties fall back to kind and id. */ + order: number; + /** Session-only lineage hint. Ignored for webviews. */ + parentSessionId?: string; +} + +export interface TabMoveTarget { + /** Null denotes the real ungrouped container. */ + groupId: string | null; + /** Zero-based insertion index after removing the moved block. */ + index: number; +} + +export interface CreateTabGroupInput { + id: string; + name: string; + index?: number; +} + +export interface VisibleTabProjectionOptions { + liveSessionIds: ReadonlySet; + openWebviewIds: ReadonlySet; + collapsedGroupIds?: ReadonlySet; + highlighted?: TabRef; +} + +export class TabLayoutValidationError extends Error { + constructor(message: string) { + super(message); + this.name = 'TabLayoutValidationError'; + } +} + +const keyOf = (ref: Pick): string => `${ref.kind}\u0000${ref.id}`; + +function assertRecord(value: unknown, label: string): asserts value is Record { + if (value === null || typeof value !== 'object' || Array.isArray(value)) { + throw new TabLayoutValidationError(`${label} must be an object`); + } +} + +function parseNonEmptyString(value: unknown, label: string): string { + if (typeof value !== 'string' || value.length === 0) { + throw new TabLayoutValidationError(`${label} must be a non-empty string`); + } + return value; +} + +function parseName(value: unknown, label: string): string { + if (typeof value !== 'string') throw new TabLayoutValidationError(`${label} must be a string`); + const trimmed = value.trim(); + if (trimmed.length === 0 || trimmed.length > MAX_TAB_GROUP_NAME_LENGTH) { + throw new TabLayoutValidationError(`${label} must be 1-${MAX_TAB_GROUP_NAME_LENGTH} trimmed characters`); + } + return trimmed; +} + +function parseRef(value: unknown, label: string): TabRef { + assertRecord(value, label); + if (value.kind !== 'session' && value.kind !== 'webview') { + throw new TabLayoutValidationError(`${label}.kind must be session or webview`); + } + const id = parseNonEmptyString(value.id, `${label}.id`); + if (value.placement !== undefined && value.placement !== 'manual') { + throw new TabLayoutValidationError(`${label}.placement must be manual when present`); + } + return value.placement === 'manual' ? { kind: value.kind, id, placement: 'manual' } : { kind: value.kind, id }; +} + +function parseTabLayout(input: unknown, repairDuplicates: boolean): TabLayout { + assertRecord(input, 'layout'); + if (!Number.isSafeInteger(input.version) || (input.version as number) < 0) { + throw new TabLayoutValidationError('layout.version must be a non-negative safe integer'); + } + if (!Array.isArray(input.groups)) throw new TabLayoutValidationError('layout.groups must be an array'); + if (input.groups.length > MAX_TAB_GROUPS) { + throw new TabLayoutValidationError(`layout.groups cannot exceed ${MAX_TAB_GROUPS}`); + } + if (!Array.isArray(input.ungrouped)) throw new TabLayoutValidationError('layout.ungrouped must be an array'); + const updatedAt = parseNonEmptyString(input.updatedAt, 'layout.updatedAt'); + const groupIds = new Set(); + const refKeys = new Set(); + let refCount = input.ungrouped.length; + const parseStoredRef = (entry: unknown, label: string): TabRef => { + const ref = parseRef(entry, label); + const key = keyOf(ref); + if (!repairDuplicates && refKeys.has(key)) { + throw new TabLayoutValidationError(`duplicate ref: ${ref.kind}:${ref.id}`); + } + refKeys.add(key); + return ref; + }; + const groups = input.groups.map((rawGroup, groupIndex): TabGroup => { + const label = `layout.groups[${groupIndex}]`; + assertRecord(rawGroup, label); + const id = parseNonEmptyString(rawGroup.id, `${label}.id`); + if (groupIds.has(id)) throw new TabLayoutValidationError(`duplicate group id: ${id}`); + groupIds.add(id); + if (!Array.isArray(rawGroup.refs)) throw new TabLayoutValidationError(`${label}.refs must be an array`); + refCount += rawGroup.refs.length; + return { + id, + name: parseName(rawGroup.name, `${label}.name`), + refs: rawGroup.refs.map((entry, refIndex) => parseStoredRef(entry, `${label}.refs[${refIndex}]`)), + }; + }); + if (refCount > MAX_TAB_REFS) { + throw new TabLayoutValidationError(`layout cannot contain more than ${MAX_TAB_REFS} refs`); + } + return { + version: input.version as number, + groups, + ungrouped: input.ungrouped.map((entry, index) => parseStoredRef(entry, `layout.ungrouped[${index}]`)), + updatedAt, + }; +} + +/** Validate and defensively clone a layout. Group names are normalized by trimming. */ +export function validateTabLayout(input: unknown): TabLayout { + return parseTabLayout(input, false); +} + +function validMetadata(metadata: readonly TabRefMetadata[]): TabRefMetadata[] { + const byKey = new Map(); + for (const item of metadata) { + if ((item.kind !== 'session' && item.kind !== 'webview') || typeof item.id !== 'string' || item.id.length === 0) { + throw new TabLayoutValidationError('metadata contains an invalid ref identity'); + } + if (!Number.isFinite(item.order)) throw new TabLayoutValidationError(`metadata order is invalid for ${item.id}`); + if (!item.ownerValid || !item.visible) continue; + const key = keyOf(item); + if (!byKey.has(key)) byKey.set(key, { ...item }); + } + const compareText = (a: string, b: string): number => (a < b ? -1 : a > b ? 1 : 0); + const result = [...byKey.values()].sort( + (a, b) => a.order - b.order || compareText(a.kind, b.kind) || compareText(a.id, b.id) + ); + if (result.length > MAX_TAB_REFS) { + throw new TabLayoutValidationError(`owner layout cannot exceed ${MAX_TAB_REFS} refs`); + } + return result; +} + +interface LocatedRef { + ref: TabRef; + container: string | null; + position: number; +} + +function locations(layout: TabLayout): LocatedRef[] { + const result: LocatedRef[] = []; + let position = 0; + for (const group of layout.groups) { + for (const ref of group.refs) result.push({ ref, container: group.id, position: position++ }); + } + for (const ref of layout.ungrouped) result.push({ ref, container: null, position: position++ }); + return result; +} + +function withContainers(layout: TabLayout, refsByContainer: ReadonlyMap): TabLayout { + return { + ...layout, + groups: layout.groups.map((group) => ({ ...group, refs: [...(refsByContainer.get(group.id) ?? [])] })), + ungrouped: [...(refsByContainer.get(null) ?? [])], + }; +} + +/** + * Reconcile a layout against owner-valid metadata and session lineage. + * First stored occurrence wins; missing valid refs append to ungrouped. + */ +export function normalizeTabLayout(input: TabLayout, metadata: readonly TabRefMetadata[]): TabLayout { + const layout = parseTabLayout(input, true); + const valid = validMetadata(metadata); + const metadataByKey = new Map(valid.map((item) => [keyOf(item), item])); + const knownMetadataKeys = new Set(metadata.map((item) => keyOf(item))); + const seen = new Set(); + const dedupedByContainer = new Map(); + for (const group of layout.groups) dedupedByContainer.set(group.id, []); + dedupedByContainer.set(null, []); + + for (const located of locations(layout)) { + const key = keyOf(located.ref); + // Missing metadata is unknown rather than invalid (for example, during + // restoration). Preserve it until an explicit invalid/deletion fact arrives. + if ((knownMetadataKeys.has(key) && !metadataByKey.has(key)) || seen.has(key)) continue; + seen.add(key); + dedupedByContainer.get(located.container)!.push({ ...located.ref }); + } + for (const item of valid) { + const key = keyOf(item); + if (seen.has(key)) continue; + seen.add(key); + dedupedByContainer.get(null)!.push({ kind: item.kind, id: item.id }); + } + if (seen.size > MAX_TAB_REFS) { + throw new TabLayoutValidationError(`normalized layout cannot exceed ${MAX_TAB_REFS} refs`); + } + + let working = withContainers(layout, dedupedByContainer); + const located = locations(working); + const refByKey = new Map(located.map((item) => [keyOf(item.ref), item.ref])); + const sessionById = new Map(valid.filter((item) => item.kind === 'session').map((item) => [item.id, item])); + const manualCycleEdges = new Set(); + const state = new Map(); + + const visit = (id: string): void => { + if (state.get(id) === 'done') return; + state.set(id, 'visiting'); + const item = sessionById.get(id); + const stored = refByKey.get(keyOf({ kind: 'session', id })); + if (item?.parentSessionId && stored?.placement !== 'manual') { + const parent = sessionById.get(item.parentSessionId); + const parentStored = refByKey.get(keyOf({ kind: 'session', id: item.parentSessionId })); + if (parent && parentStored) { + if (state.get(parent.id) === 'visiting') manualCycleEdges.add(id); + else visit(parent.id); + } + } + state.set(id, 'done'); + }; + for (const item of located) + if (item.ref.kind === 'session' && state.get(item.ref.id) === undefined) visit(item.ref.id); + + if (manualCycleEdges.size > 0) { + working = { + ...working, + groups: working.groups.map((group) => ({ + ...group, + refs: group.refs.map((ref) => + ref.kind === 'session' && manualCycleEdges.has(ref.id) ? { ...ref, placement: 'manual' } : ref + ), + })), + ungrouped: working.ungrouped.map((ref) => + ref.kind === 'session' && manualCycleEdges.has(ref.id) ? { ...ref, placement: 'manual' } : ref + ), + }; + } + + const ordered = locations(working); + const updatedRefByKey = new Map(ordered.map((item) => [keyOf(item.ref), item.ref])); + const parentOf = new Map(); + const children = new Map(); + for (const item of ordered) { + if (item.ref.kind !== 'session' || item.ref.placement === 'manual') continue; + const info = sessionById.get(item.ref.id); + const parentId = info?.parentSessionId; + if (!parentId || !sessionById.has(parentId) || !updatedRefByKey.has(keyOf({ kind: 'session', id: parentId }))) + continue; + parentOf.set(item.ref.id, parentId); + const siblings = children.get(parentId) ?? []; + siblings.push(item.ref.id); + children.set(parentId, siblings); + } + + const emitted = new Set(); + const output = new Map(); + for (const group of working.groups) output.set(group.id, []); + output.set(null, []); + const emitSubtree = (root: TabRef, container: string | null): void => { + const rootKey = keyOf(root); + if (emitted.has(rootKey)) return; + emitted.add(rootKey); + output.get(container)!.push({ ...root }); + if (root.kind !== 'session') return; + for (const childId of children.get(root.id) ?? []) { + const child = updatedRefByKey.get(keyOf({ kind: 'session', id: childId })); + if (child) emitSubtree(child, container); + } + }; + for (const item of ordered) { + if (item.ref.kind === 'session' && parentOf.has(item.ref.id)) continue; + emitSubtree(item.ref, item.container); + } + return withContainers(working, output); +} + +function cloneForEdit(input: TabLayout): TabLayout { + return validateTabLayout(input); +} + +function boundedIndex(index: number, length: number, label: string): number { + if (!Number.isSafeInteger(index) || index < 0 || index > length) { + throw new TabLayoutValidationError(`${label} index must be between 0 and ${length}`); + } + return index; +} + +export function createGroup(input: TabLayout, group: CreateTabGroupInput): TabLayout { + const layout = cloneForEdit(input); + if (layout.groups.length >= MAX_TAB_GROUPS) + throw new TabLayoutValidationError(`cannot exceed ${MAX_TAB_GROUPS} groups`); + const id = parseNonEmptyString(group.id, 'group.id'); + if (layout.groups.some((entry) => entry.id === id)) throw new TabLayoutValidationError(`duplicate group id: ${id}`); + const index = boundedIndex(group.index ?? layout.groups.length, layout.groups.length, 'group'); + const groups = [...layout.groups]; + groups.splice(index, 0, { id, name: parseName(group.name, 'group.name'), refs: [] }); + return { ...layout, groups }; +} + +export function renameGroup(input: TabLayout, groupId: string, name: string): TabLayout { + const layout = cloneForEdit(input); + if (!layout.groups.some((group) => group.id === groupId)) + throw new TabLayoutValidationError(`unknown group: ${groupId}`); + return { + ...layout, + groups: layout.groups.map((group) => + group.id === groupId ? { ...group, name: parseName(name, 'group.name') } : group + ), + }; +} + +export function deleteGroup(input: TabLayout, groupId: string): TabLayout { + const layout = cloneForEdit(input); + const group = layout.groups.find((entry) => entry.id === groupId); + if (!group) throw new TabLayoutValidationError(`unknown group: ${groupId}`); + return { + ...layout, + groups: layout.groups.filter((entry) => entry.id !== groupId), + ungrouped: [...layout.ungrouped, ...group.refs.map((ref) => ({ ...ref }))], + }; +} + +export function reorderGroup(input: TabLayout, groupId: string, index: number): TabLayout { + const layout = cloneForEdit(input); + const from = layout.groups.findIndex((group) => group.id === groupId); + if (from < 0) throw new TabLayoutValidationError(`unknown group: ${groupId}`); + const groups = [...layout.groups]; + const [group] = groups.splice(from, 1); + groups.splice(boundedIndex(index, groups.length, 'group'), 0, group); + return { ...layout, groups }; +} + +function mapRef(input: TabLayout, target: TabRef, transform: (ref: TabRef) => TabRef): TabLayout { + const layout = cloneForEdit(input); + let found = false; + const apply = (ref: TabRef): TabRef => { + if (keyOf(ref) !== keyOf(target)) return ref; + found = true; + return transform(ref); + }; + const result = { + ...layout, + groups: layout.groups.map((group) => ({ ...group, refs: group.refs.map(apply) })), + ungrouped: layout.ungrouped.map(apply), + }; + if (!found) throw new TabLayoutValidationError(`unknown ref: ${target.kind}:${target.id}`); + return result; +} + +export function setManualPlacement(input: TabLayout, target: TabRef, manual: boolean): TabLayout { + if (!manual) { + throw new TabLayoutValidationError('manual placement can only be cleared through followParent'); + } + return mapRef(input, target, (ref) => ({ ...ref, placement: 'manual' })); +} + +export function followParent(input: TabLayout, target: TabRef, metadata: readonly TabRefMetadata[]): TabLayout { + const normalized = normalizeTabLayout(input, metadata); + if (target.kind !== 'session') { + throw new TabLayoutValidationError('only a session ref can follow a parent'); + } + + const valid = validMetadata(metadata); + const targetMetadata = valid.find((item) => item.kind === 'session' && item.id === target.id); + if (!targetMetadata?.parentSessionId) { + throw new TabLayoutValidationError(`session has no owner-valid parent: ${target.id}`); + } + const parentMetadata = valid.find((item) => item.kind === 'session' && item.id === targetMetadata.parentSessionId); + if (!parentMetadata) { + throw new TabLayoutValidationError(`session parent is not owner-valid: ${targetMetadata.parentSessionId}`); + } + + const storedKeys = new Set(locations(normalized).map((item) => keyOf(item.ref))); + if (!storedKeys.has(keyOf(target))) { + throw new TabLayoutValidationError(`unknown ref: ${target.kind}:${target.id}`); + } + const parentRef: TabRef = { kind: 'session', id: targetMetadata.parentSessionId }; + if (!storedKeys.has(keyOf(parentRef))) { + throw new TabLayoutValidationError(`session parent is not represented: ${targetMetadata.parentSessionId}`); + } + + const cleared = mapRef(normalized, target, (ref) => ({ kind: ref.kind, id: ref.id })); + return normalizeTabLayout(cleared, metadata); +} + +function descendantKeys(root: TabRef, layout: TabLayout, metadata: readonly TabRefMetadata[]): Set { + const valid = validMetadata(metadata); + const stored = new Map(locations(layout).map((item) => [keyOf(item.ref), item.ref])); + const children = new Map(); + for (const item of valid) { + if (item.kind !== 'session' || !item.parentSessionId) continue; + const child = stored.get(keyOf(item)); + if (!child || child.placement === 'manual' || !stored.has(keyOf({ kind: 'session', id: item.parentSessionId }))) + continue; + const siblings = children.get(item.parentSessionId) ?? []; + siblings.push(item.id); + children.set(item.parentSessionId, siblings); + } + const result = new Set(); + const add = (ref: TabRef): void => { + const key = keyOf(ref); + if (result.has(key)) return; + result.add(key); + if (ref.kind !== 'session') return; + for (const childId of children.get(ref.id) ?? []) add({ kind: 'session', id: childId }); + }; + add(root); + return result; +} + +export function moveRef( + input: TabLayout, + target: TabRef, + destination: TabMoveTarget, + metadata: readonly TabRefMetadata[] +): TabLayout { + let layout = normalizeTabLayout(input, metadata); + const targetKey = keyOf(target); + if (!locations(layout).some((item) => keyOf(item.ref) === targetKey)) { + throw new TabLayoutValidationError(`unknown ref: ${target.kind}:${target.id}`); + } + if (destination.groupId !== null && !layout.groups.some((group) => group.id === destination.groupId)) { + throw new TabLayoutValidationError(`unknown group: ${destination.groupId}`); + } + + const blockKeys = descendantKeys(target, layout, metadata); + const block = locations(layout) + .filter((item) => blockKeys.has(keyOf(item.ref))) + .map((item) => ({ ...item.ref })); + const metadataItem = validMetadata(metadata).find((item) => keyOf(item) === targetKey); + if (target.kind === 'session' && metadataItem?.parentSessionId) block[0] = { ...block[0], placement: 'manual' }; + + const remaining = new Map(); + for (const group of layout.groups) + remaining.set( + group.id, + group.refs.filter((ref) => !blockKeys.has(keyOf(ref))) + ); + remaining.set( + null, + layout.ungrouped.filter((ref) => !blockKeys.has(keyOf(ref))) + ); + const destinationRefs = remaining.get(destination.groupId)!; + const index = boundedIndex(destination.index, destinationRefs.length, 'destination'); + destinationRefs.splice(index, 0, ...block); + layout = withContainers(layout, remaining); + return normalizeTabLayout(layout, metadata); +} + +/** + * Remove explicitly deleted session parents and pin their direct inherited + * children at their current stored positions so a later reused ID cannot adopt them. + */ +export function materializeOrphans( + input: TabLayout, + removedParentIds: readonly string[], + metadata: readonly TabRefMetadata[] +): TabLayout { + const layout = cloneForEdit(input); + const removed = new Set(removedParentIds); + const directChildren = new Set( + validMetadata(metadata) + .filter((item) => item.kind === 'session' && item.parentSessionId && removed.has(item.parentSessionId)) + .map((item) => item.id) + ); + const transform = (refs: readonly TabRef[]): TabRef[] => + refs + .filter((ref) => ref.kind !== 'session' || !removed.has(ref.id)) + .map((ref) => + ref.kind === 'session' && directChildren.has(ref.id) && ref.placement !== 'manual' + ? { ...ref, placement: 'manual' } + : { ...ref } + ); + return { + ...layout, + groups: layout.groups.map((group) => ({ ...group, refs: transform(group.refs) })), + ungrouped: transform(layout.ungrouped), + }; +} + +/** Session-only compatibility order; collapse and webviews do not affect it. */ +export function flattenOwnerSessionOrder(input: TabLayout): string[] { + return locations(validateTabLayout(input)) + .map((item) => item.ref) + .filter((ref): ref is TabRef & { kind: 'session' } => ref.kind === 'session') + .map((ref) => ref.id); +} + +/** Locally renderable order used by tab painting and Alt-number consumers. */ +export function flattenVisibleRefs(input: TabLayout, options: VisibleTabProjectionOptions): TabRef[] { + const layout = validateTabLayout(input); + const collapsed = options.collapsedGroupIds ?? new Set(); + const renderable = (ref: TabRef): boolean => + ref.kind === 'session' ? options.liveSessionIds.has(ref.id) : options.openWebviewIds.has(ref.id); + const highlightedKey = options.highlighted ? keyOf(options.highlighted) : undefined; + const result: TabRef[] = []; + for (const group of layout.groups) { + for (const ref of group.refs) { + if (!renderable(ref)) continue; + if (collapsed.has(group.id) && keyOf(ref) !== highlightedKey) continue; + result.push({ ...ref }); + } + } + for (const ref of layout.ungrouped) if (renderable(ref)) result.push({ ...ref }); + return result; +} diff --git a/src/types/app-state.ts b/src/types/app-state.ts index 4d16ddcd..4382573a 100644 --- a/src/types/app-state.ts +++ b/src/types/app-state.ts @@ -24,6 +24,7 @@ import type { TaskState } from './task.js'; import type { RalphLoopState } from './ralph.js'; import type { RespawnConfig } from './respawn.js'; import type { CronJob, CronJobRun } from './cron.js'; +import type { TabLayout } from '../tab-layout.js'; // ========== Global Stats Types ========== @@ -118,6 +119,8 @@ export interface AppState { cronJobRuns?: Record; /** Global tab order shared across devices (ordered list of sessionIds) — COD-131 */ sessionOrder?: string[]; + /** Owner-scoped authoritative grouped tab layouts. */ + tabLayouts?: Record; } // ========== Default Configuration ========== diff --git a/src/web/ports/index.ts b/src/web/ports/index.ts index 18cbe603..c75b8c0d 100644 --- a/src/web/ports/index.ts +++ b/src/web/ports/index.ts @@ -14,3 +14,4 @@ export type { InfraPort, ScheduledRun } from './infra-port.js'; export type { AuthPort } from './auth-port.js'; export type { OrchestratorPort } from './orchestrator-port.js'; export type { CronPort } from './cron-port.js'; +export type { TabLayoutPort } from './tab-layout-port.js'; diff --git a/src/web/ports/session-port.ts b/src/web/ports/session-port.ts index 4fe3b96d..61e02be3 100644 --- a/src/web/ports/session-port.ts +++ b/src/web/ports/session-port.ts @@ -7,7 +7,7 @@ import type { Session } from '../../session.js'; export interface SessionPort { readonly sessions: ReadonlyMap; - addSession(session: Session): void; + addSession(session: Session): Promise; cleanupSession(sessionId: string, killMux?: boolean, reason?: string): Promise; setupSessionListeners(session: Session): Promise; persistSessionState(session: Session): void; diff --git a/src/web/ports/tab-layout-port.ts b/src/web/ports/tab-layout-port.ts new file mode 100644 index 00000000..ad42da88 --- /dev/null +++ b/src/web/ports/tab-layout-port.ts @@ -0,0 +1,8 @@ +/** @fileoverview Owner-scoped tab-layout capabilities exposed to route modules. */ +import type { TabLayoutService } from '../../tab-layout-service.js'; + +export type { LegacyOrderActor, LegacyOrderPutResult, SessionOrderProjectionChange } from '../../tab-layout-service.js'; + +export interface TabLayoutPort { + readonly tabLayouts: TabLayoutService; +} diff --git a/src/web/public/constants.js b/src/web/public/constants.js index e672be19..d9d550d9 100644 --- a/src/web/public/constants.js +++ b/src/web/public/constants.js @@ -10,7 +10,7 @@ * @globals {function} scheduleBackground - scheduler.postTask wrapper (background priority) * @globals {function} getEventCoords - Unified mouse/touch coordinate extractor * @globals {function} escapeHtml - XSS-safe HTML escaping - * @globals {object} SSE_EVENTS - Centralized SSE event type constants (120 event types; must match backend src/web/sse-events.ts) + * @globals {object} SSE_EVENTS - Centralized SSE event type constants (156 event types; must match backend src/web/sse-events.ts) * @globals {Array} BUILTIN_RESPAWN_PRESETS - Built-in respawn configuration presets * * @dependency None (first in load order) @@ -969,6 +969,7 @@ const SSE_EVENTS = { // Web tabs (dashboard URLs) WEBVIEW_CHANGED: 'webview:changed', + TAB_LAYOUT_CHANGED: 'tab:layoutChanged', }; // ═══════════════════════════════════════════════════════════════ diff --git a/src/web/routes/index.ts b/src/web/routes/index.ts index 43f2fdf0..5b221970 100644 --- a/src/web/routes/index.ts +++ b/src/web/routes/index.ts @@ -26,3 +26,4 @@ export { registerAdminRoutes } from './admin-routes.js'; export { registerWsRoutes } from './ws-routes.js'; export { registerVoiceRoutes } from './voice-routes.js'; export { registerWebviewRoutes, tryWebviewRefererFallback } from './webview-routes.js'; +export { registerTabLayoutRoutes } from './tab-layout-routes.js'; diff --git a/src/web/routes/ralph-routes.ts b/src/web/routes/ralph-routes.ts index df34ef8b..24b08a48 100644 --- a/src/web/routes/ralph-routes.ts +++ b/src/web/routes/ralph-routes.ts @@ -411,7 +411,7 @@ export function registerRalphRoutes( writeFileSync(promptPath, fullPrompt, 'utf-8'); // Register session - ctx.addSession(session); + await ctx.addSession(session); ctx.store.incrementSessionsCreated(); ctx.persistSessionState(session); await ctx.setupSessionListeners(session); diff --git a/src/web/routes/session-routes.ts b/src/web/routes/session-routes.ts index 20c232ce..ab7685e4 100644 --- a/src/web/routes/session-routes.ts +++ b/src/web/routes/session-routes.ts @@ -47,7 +47,8 @@ import { SessionWaitQuerySchema, SessionWaitOutputQuerySchema, } from '../schemas.js'; -import { mergeSessionOrder } from '../../session-order.js'; +import { ownerLayoutKey } from '../../tab-layout-persistence.js'; +import { TabLayoutValidationError } from '../../tab-layout.js'; import { sessionWaits, resolveWaitSignals, @@ -107,7 +108,7 @@ import { setHistoryIndexRefresher, setHistorySessionIndex, } from '../session-history-index.js'; -import type { SessionPort, EventPort, ConfigPort, InfraPort, AuthPort } from '../ports/index.js'; +import type { SessionPort, EventPort, ConfigPort, InfraPort, AuthPort, TabLayoutPort } from '../ports/index.js'; import { RunSummaryTracker } from '../../run-summary.js'; import { MAX_INPUT_LENGTH, MAX_SESSION_NAME_LENGTH } from '../../config/terminal-limits.js'; @@ -641,7 +642,7 @@ async function injectAgentSkill(casePath: string): Promise { export function registerSessionRoutes( app: FastifyInstance, - ctx: SessionPort & EventPort & ConfigPort & InfraPort & AuthPort + ctx: SessionPort & EventPort & ConfigPort & InfraPort & AuthPort & TabLayoutPort ): void { // ═══════════════════════════════════════════════════════════════ // Auth @@ -673,16 +674,23 @@ export function registerSessionRoutes( return (list as Array<{ owner?: string }>).filter((s) => canAccessOwned(user, s.owner)); }); - // ========== Session Tab Order (global sync, COD-131) ========== + // ========== Legacy Session Tab Order (temporary synchronized compatibility bridge) ========== - app.put('/api/session-order', async (req): Promise> => { - const { order } = parseBody(SessionOrderUpdateSchema, req.body, 'Invalid session order'); - // Server is authoritative but never drops ids it knows about that the - // pushing device hadn't loaded yet — those fall to the end (mergeSessionOrder). - const merged = mergeSessionOrder(order, ctx.store.getSessionOrder()); - ctx.store.setSessionOrder(merged); - ctx.broadcast(SseEvent.SessionOrderChanged, { order: merged }); - return { success: true, data: { order: merged } }; + app.put('/api/session-order', async (req, reply): Promise> => { + try { + const { order } = parseBody(SessionOrderUpdateSchema, req.body, 'Invalid session order'); + const user = getAuthUser(req); + const result = await ctx.tabLayouts.putLegacyOrder( + { owner: ownerLayoutKey(ownerFor(req)), isAdmin: user.role === 'admin' }, + order + ); + return { success: true, data: { order: result.order } }; + } catch (error) { + if (error instanceof TabLayoutValidationError) { + return reply.code(400).send(createErrorResponse(ApiErrorCode.INVALID_INPUT, error.message)); + } + throw error; + } }); // ========== Session Creation ========== @@ -930,7 +938,7 @@ export function registerSessionRoutes( parentSessionId: resolveParentSessionId(ctx, req, body.parentSessionId, owner), }); - ctx.addSession(session); + await ctx.addSession(session); ctx.store.incrementSessionsCreated(); ctx.persistSessionState(session); await ctx.setupSessionListeners(session); @@ -2643,7 +2651,7 @@ export function registerSessionRoutes( allowedTools: runClaudeModeConfig.allowedTools, owner: runOwner, }); - ctx.addSession(session); + await ctx.addSession(session); ctx.store.incrementSessionsCreated(); ctx.persistSessionState(session); await ctx.setupSessionListeners(session); @@ -3057,7 +3065,7 @@ export function registerSessionRoutes( } } - ctx.addSession(session); + await ctx.addSession(session); ctx.store.incrementSessionsCreated(); ctx.persistSessionState(session); await ctx.setupSessionListeners(session); diff --git a/src/web/routes/system-routes.ts b/src/web/routes/system-routes.ts index 299b8276..d90e5a92 100644 --- a/src/web/routes/system-routes.ts +++ b/src/web/routes/system-routes.ts @@ -49,7 +49,7 @@ import { import { SseEvent } from '../sse-events.js'; import { getInstallInfo, checkForUpdate, startUpdate, getUpdateStatusForApi } from '../self-update.js'; import { getRepositoryStatus } from '../repo-status.js'; -import type { SessionPort, EventPort, ConfigPort, InfraPort, AuthPort } from '../ports/index.js'; +import type { SessionPort, EventPort, ConfigPort, InfraPort, AuthPort, TabLayoutPort } from '../ports/index.js'; import { AUTH_COOKIE_NAME } from '../middleware/auth.js'; import { QR_AUTH_FAILURE_MAX } from '../../config/tunnel-config.js'; import { AUTH_SESSION_TTL_MS } from '../../config/auth-config.js'; @@ -129,7 +129,7 @@ export function resolveSpanUrl(hostHeader: string | undefined, fallbackPort = '3 export function registerSystemRoutes( app: FastifyInstance, - ctx: SessionPort & EventPort & ConfigPort & InfraPort & AuthPort + ctx: SessionPort & EventPort & ConfigPort & InfraPort & AuthPort & TabLayoutPort ): void { const windowStatesPath = dataPath('subagent-window-states.json'); const parentMapPath = dataPath('subagent-parents.json'); @@ -454,7 +454,9 @@ export function registerSystemRoutes( app.post('/api/cleanup-state', async () => { const activeSessionIds = new Set(ctx.sessions.keys()); - const result = ctx.store.cleanupStaleSessions(activeSessionIds); + const result = await ctx.tabLayouts.runStaleSessionCleanup(activeSessionIds, (ids) => + ctx.store.cleanupSessionsByIds(ids) + ); const lifecycleLog = getLifecycleLog(); for (const s of result.cleaned) { lifecycleLog.log({ event: 'stale_cleaned', sessionId: s.id, name: s.name }); diff --git a/src/web/routes/tab-layout-routes.ts b/src/web/routes/tab-layout-routes.ts new file mode 100644 index 00000000..bf94c25c --- /dev/null +++ b/src/web/routes/tab-layout-routes.ts @@ -0,0 +1,50 @@ +/** @fileoverview Authenticated owner-scoped tab-layout read/write API. */ +import type { FastifyInstance } from 'fastify'; +import { ownerLayoutKey } from '../../tab-layout-persistence.js'; +import { TabLayoutValidationError } from '../../tab-layout.js'; +import { ApiErrorCode, createErrorResponse } from '../../types.js'; +import { ownerFor } from '../route-helpers.js'; +import type { TabLayoutPort } from '../ports/index.js'; + +export const TAB_LAYOUT_BODY_LIMIT = 128 * 1024; + +function parseWriteBody(body: unknown): { baseVersion: number; layout: unknown } { + if (body === null || typeof body !== 'object' || Array.isArray(body)) { + throw new TabLayoutValidationError('body must be an object'); + } + const keys = Object.keys(body); + if (keys.length !== 2 || !Object.hasOwn(body, 'baseVersion') || !Object.hasOwn(body, 'layout')) { + throw new TabLayoutValidationError('body must contain exactly baseVersion and layout'); + } + const input = body as { baseVersion?: unknown; layout?: unknown }; + if (!Number.isSafeInteger(input.baseVersion) || (input.baseVersion as number) < 0 || input.layout === undefined) { + throw new TabLayoutValidationError('baseVersion must be a non-negative safe integer and layout is required'); + } + return { baseVersion: input.baseVersion as number, layout: input.layout }; +} + +export function registerTabLayoutRoutes(app: FastifyInstance, ctx: TabLayoutPort): void { + app.get('/api/tab-layout', async (req) => ({ + success: true, + data: { layout: await ctx.tabLayouts.get(ownerLayoutKey(ownerFor(req))) }, + })); + + app.put('/api/tab-layout', { bodyLimit: TAB_LAYOUT_BODY_LIMIT }, async (req, reply) => { + try { + const { baseVersion, layout } = parseWriteBody(req.body); + const result = await ctx.tabLayouts.put(ownerLayoutKey(ownerFor(req)), layout, baseVersion); + if (result.status === 'conflict') { + return reply.code(409).send({ + ...createErrorResponse(ApiErrorCode.CONFLICT, 'Tab layout version conflict'), + data: { layout: result.layout }, + }); + } + return { success: true, data: { layout: result.layout } }; + } catch (error) { + if (error instanceof TabLayoutValidationError) { + return reply.code(400).send(createErrorResponse(ApiErrorCode.INVALID_INPUT, error.message)); + } + throw error; + } + }); +} diff --git a/src/web/routes/webview-routes.ts b/src/web/routes/webview-routes.ts index 16eac81b..6f545607 100644 --- a/src/web/routes/webview-routes.ts +++ b/src/web/routes/webview-routes.ts @@ -53,7 +53,8 @@ import { AUTH_COOKIE_NAME } from '../middleware/auth.js'; import { canAccessOwned, getAuthUser, ownerFor, parseBody } from '../route-helpers.js'; import { WebviewCreateSchema, WebviewProbeSchema, WebviewUpdateSchema } from '../schemas.js'; import { SseEvent } from '../sse-events.js'; -import type { EventPort } from '../ports/index.js'; +import type { EventPort, TabLayoutPort } from '../ports/index.js'; +import { ownerLayoutKey } from '../../tab-layout-persistence.js'; import { buildDownstreamResponseHeaders, buildProxyCorsHeaders, @@ -98,14 +99,14 @@ function withWebviews(fn: (list: Webview[]) => Promise | T): Promise { return next; } -export function registerWebviewRoutes(app: FastifyInstance, ctx: EventPort): void { +export function registerWebviewRoutes(app: FastifyInstance, ctx: EventPort & TabLayoutPort): void { registerCrudRoutes(app, ctx); registerProxyRoutes(app); } // ───────────────────────────── CRUD ───────────────────────────── -function registerCrudRoutes(app: FastifyInstance, ctx: EventPort): void { +function registerCrudRoutes(app: FastifyInstance, ctx: EventPort & TabLayoutPort): void { app.get('/api/webviews', async (req) => { const user = getAuthUser(req); const all = await readWebviews(configDir()); @@ -145,6 +146,21 @@ function registerCrudRoutes(app: FastifyInstance, ctx: EventPort): void { .send(createErrorResponse(ApiErrorCode.INVALID_INPUT, `Webview limit reached (max ${MAX_WEBVIEWS})`)); } + try { + await ctx.tabLayouts.webviewCreated(ownerLayoutKey(created.owner)); + } catch (error) { + // The saved webview and its layout ref are one logical creation. If the + // layout rejects the new ref (for example at MAX_TAB_REFS), roll back the + // already-written JSON record and publish neither creation event. + await withWebviews(async (list) => { + const index = list.findIndex((webview) => webview.id === created.id); + if (index >= 0) { + list.splice(index, 1); + await writeWebviews(configDir(), list); + } + }); + throw error; + } ctx.broadcast(SseEvent.WebviewChanged, { action: 'created', id: created.id }); return { success: true, data: created }; }); @@ -187,9 +203,19 @@ function registerCrudRoutes(app: FastifyInstance, ctx: EventPort): void { const index = list.findIndex((w) => w.id === id); if (index === -1) return 'not-found' as const; if (!canAccessOwned(user, list[index].owner)) return 'forbidden' as const; + const removed = list[index]; list.splice(index, 1); await writeWebviews(configDir(), list); - return 'deleted' as const; + try { + await ctx.tabLayouts.webviewDeleted(ownerLayoutKey(removed.owner), id); + } catch (error) { + // Still inside withWebviews' mutex: restore the exact record at its + // original position without overwriting any concurrent mutation. + list.splice(index, 0, removed); + await writeWebviews(configDir(), list); + throw error; + } + return { status: 'deleted' as const, owner: removed.owner }; }); if (result === 'not-found') { diff --git a/src/web/server.ts b/src/web/server.ts index 8a776645..293e60c3 100644 --- a/src/web/server.ts +++ b/src/web/server.ts @@ -50,6 +50,8 @@ import { RespawnController, RespawnConfig } from '../respawn-controller.js'; import type { TerminalMultiplexer } from '../mux-interface.js'; import { createMultiplexer } from '../mux-factory.js'; import { getStore } from '../state-store.js'; +import { TabLayoutService } from '../tab-layout-service.js'; +import { readWebviews } from '../webview-store.js'; import { extractCompletionPhrase } from '../ralph-config.js'; import { fileStreamManager } from '../file-stream-manager.js'; import { @@ -81,6 +83,7 @@ import { applyWorkspaceHooks } from '../hooks-config.js'; import { PushSubscriptionStore } from '../push-store.js'; import webpush from 'web-push'; import { SseStreamManager } from './sse-stream-manager.js'; +import { deriveTabLayoutSseHint } from './tab-layout-sse.js'; import { type SessionListenerRefs, createSessionListeners, @@ -170,6 +173,7 @@ import { registerWsRoutes, registerVoiceRoutes, registerWebviewRoutes, + registerTabLayoutRoutes, tryWebviewRefererFallback, } from './routes/index.js'; import { CronService } from '../cron/cron-service.js'; @@ -247,6 +251,7 @@ export class WebServer extends EventEmitter { private cronService!: CronService; private sse: SseStreamManager; private store = getStore(); + private tabLayouts!: TabLayoutService; private port: number; private host: string; private https: boolean; @@ -350,6 +355,17 @@ export class WebServer extends EventEmitter { }, this.cleanup ); + this.tabLayouts = new TabLayoutService({ + store: this.store, + sessions: this.sessions, + readWebviews: () => readWebviews(getDataDir()), + broadcast: this.broadcast.bind(this), + broadcastSessionOrder: (change) => { + this.cachedLightState = null; + this.sse.broadcastSessionOrder(change); + }, + }); + if (this.testMode) this.tabLayouts.markRestorationSkipped(); // Approvals Inbox → SSE. The singleton has no server reference; these // callbacks are its only way out. Broadcasts carry sessionId, so the @@ -595,6 +611,17 @@ export class WebServer extends EventEmitter { } } + /** Add a tentative session only after its owner layout accepts the creation. */ + private async registerSessionWithLayout(session: Session): Promise { + this.sessions.set(session.id, session); + try { + await this.tabLayouts.sessionCreated(session.owner ?? '@single'); + } catch (error) { + this.sessions.delete(session.id); + throw error; + } + } + /** * Build a route context object satisfying all 5 port interfaces. * Single object with zero runtime cost — ISP enforced at the type level. @@ -605,9 +632,8 @@ export class WebServer extends EventEmitter { return { // SessionPort sessions: this.sessions as ReadonlyMap, - addSession: (session: Session) => { - this.sessions.set(session.id, session); - }, + addSession: this.registerSessionWithLayout.bind(this), + tabLayouts: this.tabLayouts, cleanupSession: this.cleanupSession.bind(this), setupSessionListeners: this.setupSessionListeners.bind(this), persistSessionState: this.persistSessionState.bind(this), @@ -991,6 +1017,7 @@ export class WebServer extends EventEmitter { registerAdminRoutes(this.app, ctx); registerOrchestratorRoutes(this.app, ctx); registerWebviewRoutes(this.app, ctx); + registerTabLayoutRoutes(this.app, ctx); // Cron: build the service from the same context, recompute // due times for any persisted jobs, then expose it to its routes. @@ -1154,8 +1181,19 @@ export class WebServer extends EventEmitter { } } - private async _doCleanupSession(sessionId: string, killMux: boolean, reason?: string): Promise { + private async _doCleanupSession( + sessionId: string, + killMux: boolean, + reason?: string, + coordinateLayout = true + ): Promise { const session = this.sessions.get(sessionId); + const pinned = session?.pinned === true || this.store.getSession(sessionId)?.pinned === true; + if (coordinateLayout && session && killMux && !pinned) { + return this.tabLayouts.runSessionDeletion([{ id: sessionId, owner: session.owner }], () => + this._doCleanupSession(sessionId, killMux, reason, false) + ); + } const lifecycleLog = getLifecycleLog(); lifecycleLog.log({ event: killMux ? 'deleted' : 'detached', @@ -1857,7 +1895,7 @@ export class WebServer extends EventEmitter { // mode) so the flag-off path stays byte-identical. session = new Session({ workingDir: run.workingDir }); } - this.sessions.set(session.id, session); + await this.registerSessionWithLayout(session); this.store.incrementSessionsCreated(); this.persistSessionState(session); await this.setupSessionListeners(session); @@ -1987,9 +2025,11 @@ export class WebServer extends EventEmitter { * Called on startup and can be called via API endpoint. * @returns Number of sessions cleaned up */ - private cleanupStaleSessions(): number { + private async cleanupStaleSessions(): Promise { const activeSessionIds = new Set(this.sessions.keys()); - const result = this.store.cleanupStaleSessions(activeSessionIds); + const result = await this.tabLayouts.runStaleSessionCleanup(activeSessionIds, (ids) => + this.store.cleanupSessionsByIds(ids) + ); const lifecycleLog = getLifecycleLog(); for (const s of result.cleaned) { lifecycleLog.log({ event: 'stale_cleaned', sessionId: s.id, name: s.name }); @@ -2013,11 +2053,13 @@ export class WebServer extends EventEmitter { /** Shallow-filter the light-state blob to what a non-admin user may see. */ private filterLightStateForUser(base: Record, username: string): Record { - const ownedIds = new Set(); + const authoritativeOwners = new Map(); + for (const [id, session] of Object.entries(this.store.getSessions())) authoritativeOwners.set(id, session.owner); + for (const [id, session] of this.sessions) authoritativeOwners.set(id, session.owner); + const ownedIds = new Set([...authoritativeOwners].filter(([, owner]) => owner === username).map(([id]) => id)); const ownedClaudeIds = new Set(); - for (const [id, s] of this.sessions) { + for (const s of this.sessions.values()) { if (s.owner === username) { - ownedIds.add(id); if (s.claudeSessionId) ownedClaudeIds.add(s.claudeSessionId); } } @@ -2035,6 +2077,9 @@ export class WebServer extends EventEmitter { const filtered: Record = { ...base, sessions, + sessionOrder: Array.isArray(base.sessionOrder) + ? (base.sessionOrder as string[]).filter((id) => ownedIds.has(id)) + : [], respawnStatus, scheduledRuns: [], // legacy ScheduledRun has no owner yet → admin-only subagents: bySession(base.subagents, 'sessionId'), @@ -2071,6 +2116,7 @@ export class WebServer extends EventEmitter { const result = { version: APP_VERSION, sessions: this.getLightSessionsState(), + sessionOrder: this.store.getSessionOrder(), scheduledRuns: Array.from(this.scheduledRuns.values()), respawnStatus, globalStats: this.store.getAggregateStats(activeSessionTokens), @@ -2079,7 +2125,6 @@ export class WebServer extends EventEmitter { timestamp: now, inputCjkForm: process.env.INPUT_CJK_FORM?.toUpperCase() === 'ON', planUsage: getLatestPlanUsage(), // last-known plan-usage telemetry, for the header chip on fresh load - sessionOrder: this.store.getSessionOrder(), // global tab order, synced across devices (COD-131) }; this.cachedLightState = { data: result, timestamp: now }; @@ -2118,6 +2163,11 @@ export class WebServer extends EventEmitter { ) { return { adminOnly: true }; } + // Layout payloads contain only trusted routing metadata. Route them to that + // exact owner plus admins, never by resolving a client-supplied ref. + if (event.startsWith('tab:')) { + return deriveTabLayoutSseHint(data); + } // Session-scoped families: resolve the owner from the payload's session id. const SESSION_PREFIXES = [ 'session:', @@ -2363,26 +2413,26 @@ export class WebServer extends EventEmitter { // This prevents race conditions where clients connect before state is ready // CRITICAL: Skip in test mode to prevent tests from picking up user sessions if (!this.testMode) { - await this.restoreMuxSessions(); + const restored = await this.restoreMuxSessions(); + await this.finalizeRestoredState(restored); // Instance-scoped reaper: after restore, `docker rm -f` managed containers of // THIS instance whose case is gone from docker-cases.json (best-effort, never // touches another instance's containers). Runs after restore so containers // still referenced by a restored session are preserved. - void import('../docker-hosts.js') - .then(({ reapOrphanedDockerContainers }) => reapOrphanedDockerContainers(getDataDir(), CODEMAN_INSTANCE)) - .then((reaped) => { - if (reaped.length > 0) - console.log(`[Docker] reaped ${reaped.length} orphaned container(s): ${reaped.join(', ')}`); - }) - .catch(() => { - /* best-effort — daemon may be absent */ - }); + if (restored) { + void import('../docker-hosts.js') + .then(({ reapOrphanedDockerContainers }) => reapOrphanedDockerContainers(getDataDir(), CODEMAN_INSTANCE)) + .then((reaped) => { + if (reaped.length > 0) + console.log(`[Docker] reaped ${reaped.length} orphaned container(s): ${reaped.join(', ')}`); + }) + .catch(() => { + /* best-effort — daemon may be absent */ + }); + } } - // Clean up stale sessions from state file that don't have active mux sessions - this.cleanupStaleSessions(); - // Bound disk use under heavy paste-image traffic: delete `paste-*` files // older than 7 days from each live session's .claude-images/ hourly. if (!this.testMode) { @@ -2618,7 +2668,7 @@ export class WebServer extends EventEmitter { return false; } - private async restoreMuxSessions(): Promise { + private async restoreMuxSessions(): Promise { try { // Reconcile mux sessions to find which ones are still alive (also discovers unknown ones) const { alive, dead, discovered } = await this.mux.reconcileSessions(); @@ -2897,11 +2947,24 @@ export class WebServer extends EventEmitter { if (dead.length > 0) { console.log(`[Server] Cleaned up ${dead.length} dead mux session(s)`); } + return true; } catch (err) { console.error('[Server] Failed to restore mux sessions:', err); + return false; } } + /** Unlock destructive reconciliation only after mux restoration fully succeeds. */ + private async finalizeRestoredState(restored: boolean): Promise { + if (!restored) { + this.tabLayouts.markRestorationFailed(); + return; + } + this.tabLayouts.markRestorationComplete(); + await this.cleanupStaleSessions(); + await this.tabLayouts.reconcileAfterRestoration(); + } + /** * Install Codeman's hooks into the workspaces of the sessions just recovered. * diff --git a/src/web/session-order-sse.ts b/src/web/session-order-sse.ts new file mode 100644 index 00000000..b59062ad --- /dev/null +++ b/src/web/session-order-sse.ts @@ -0,0 +1,15 @@ +/** @fileoverview Trusted per-recipient payload selection for legacy session-order invalidations. */ +import type { SessionOrderProjectionChange } from '../tab-layout-service.js'; +import type { AuthUser } from '../types.js'; + +export function sessionOrderPayloadFor( + identity: AuthUser | undefined, + change: SessionOrderProjectionChange +): { order: string[] } | undefined { + if (!identity || identity.role === 'admin') { + return change.globalChanged ? { order: [...change.globalOrder] } : undefined; + } + if (!Object.hasOwn(change.changedOwnerOrders, identity.username)) return undefined; + const order = change.changedOwnerOrders[identity.username]; + return Array.isArray(order) ? { order: [...order] } : undefined; +} diff --git a/src/web/sse-events.ts b/src/web/sse-events.ts index b2feb899..5fcae0d1 100644 --- a/src/web/sse-events.ts +++ b/src/web/sse-events.ts @@ -5,7 +5,7 @@ * and referenced by the frontend (`SSE_EVENTS` in `constants.js`). * Both files MUST be kept in sync. * - * 155 event constants organized by category: + * 156 event constants organized by category: * - **Core** (1): init * - **Transport** (1): sse:heartbeat * - **Session lifecycle** (23): created, updated, deleted, terminal, idle, working, ... @@ -25,14 +25,14 @@ * - **Plan orchestration** (5): started, progress, subagent, completed, cancelled * - **Tunnel** (7): started, stopped, progress, error, qrRotated, qrRegenerated, qrAuthUsed * - **Image / attachments** (2): image:detected, attachment:detected - * - **Hooks** (8): idle_prompt, permission_prompt, elicitation_dialog, elicitation_complete, elicitation_response, stop, teammate_idle, task_completed + * - **Hooks** (9): idle_prompt, permission_prompt, elicitation_dialog, elicitation_complete, elicitation_response, stop, teammate_idle, task_completed, suppressed * - **Approvals** (3): pending, updated, resolved (cross-session Approvals Inbox) * - **Orchestrator** (12): stateChanged, planProgress, planReady, phase*, verification, task*, completed, error * - **Clipboard** (1): write * - **Cases** (4): created, linked, deleted, order-changed * - **Docker cases** (8): exportComplete/Failed, importComplete, imageBuild*, containerRecreated * - **Multi-user** (3): admin:usersChanged, auth:passwordChangeRequired, session:orderChanged - * - **Web tabs** (1): webview:changed + * - **Web tabs** (2): webview:changed, tab:layoutChanged * * Naming convention: `domain:action` (e.g., `session:created`, `respawn:stateChanged`) * @@ -449,6 +449,8 @@ export const SessionOrderChanged = 'session:orderChanged' as const; * Payload: `{ action: 'created' | 'updated' | 'deleted', id }`. The client * re-fetches the list rather than patching from the payload. */ export const WebviewChanged = 'webview:changed' as const; +/** Owner-scoped layout invalidation. Payload contains only `{ owner, version }`. */ +export const TabLayoutChanged = 'tab:layoutChanged' as const; // ─── Namespace Re-export ───────────────────────────────────────────────────── @@ -665,4 +667,5 @@ export const SseEvent = { // Web tabs (dashboard URLs) WebviewChanged, + TabLayoutChanged, } as const; diff --git a/src/web/sse-stream-manager.ts b/src/web/sse-stream-manager.ts index d1a5c2ea..7a2c9c03 100644 --- a/src/web/sse-stream-manager.ts +++ b/src/web/sse-stream-manager.ts @@ -17,9 +17,11 @@ import type { FastifyReply } from 'fastify'; import type { BackgroundTask } from '../session.js'; +import type { SessionOrderProjectionChange } from '../tab-layout-service.js'; import type { AuthUser } from '../types.js'; import { CleanupManager, StaleExpirationMap } from '../utils/index.js'; import { SseEvent } from './sse-events.js'; +import { sessionOrderPayloadFor } from './session-order-sse.js'; import { TERMINAL_BATCH_INTERVAL, TASK_UPDATE_BATCH_INTERVAL, @@ -34,6 +36,7 @@ import { // Appending SSE comment padding (ignored by EventSource) forces the proxy to flush. // Pre-computed once at startup to avoid repeated string allocation. const SSE_PADDING = ':' + 'p'.repeat(SSE_PADDING_SIZE) + '\n'; +const UNROUTED_TAB_LAYOUT = Symbol('unrouted-tab-layout'); /** Dependencies injected by WebServer — keeps SseStreamManager decoupled from session/respawn state. */ interface SseStreamManagerDeps { @@ -77,6 +80,10 @@ export class SseStreamManager { private remoteSseClients: Set = new Set(); /** Clients with backpressure — skip writes until 'drain' fires */ private backpressuredClients: Set = new Set(); + /** Latest already recipient-filtered legacy order frame awaiting a client's drain. */ + private pendingSessionOrderFrames: Map = new Map(); + /** Latest owner-filtered tab-layout invalidation per affected owner awaiting a client's drain. */ + private pendingTabLayoutFrames: Map> = new Map(); // ─── Tunnel State ─────────────────────────────────────── /** Cached tunnel active state — updated on TunnelStarted/TunnelStopped to avoid getUrl() on every broadcast */ @@ -144,10 +151,7 @@ export class SseStreamManager { // If a previous reply registered the same id (reconnect), drop the old one. const prev = this.sseClientsById.get(clientId); if (prev && prev !== reply) { - this.sseClients.delete(prev); - this.remoteSseClients.delete(prev); - this.backpressuredClients.delete(prev); - this.sseClientIdentity.delete(prev); + this.removeClient(prev); } this.sseClientsById.set(clientId, reply); } @@ -157,6 +161,8 @@ export class SseStreamManager { this.sseClients.delete(reply); this.remoteSseClients.delete(reply); this.backpressuredClients.delete(reply); + this.pendingSessionOrderFrames.delete(reply); + this.pendingTabLayoutFrames.delete(reply); this.sseClientIdentity.delete(reply); // Clear any clientId mappings pointing at this reply for (const [id, r] of this.sseClientsById) { @@ -199,8 +205,7 @@ export class SseStreamManager { try { reply.raw.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`); } catch { - this.sseClients.delete(reply); - this.remoteSseClients.delete(reply); + this.removeClient(reply); } } @@ -210,7 +215,44 @@ export class SseStreamManager { try { reply.raw.write(SSE_PADDING); } catch { - /* client gone */ + this.removeClient(reply); + } + } + + private markBackpressured(reply: FastifyReply): void { + this.backpressuredClients.add(reply); + reply.raw.once('drain', () => this.flushBackpressuredClient(reply)); + } + + private flushBackpressuredClient(reply: FastifyReply): void { + if (!this.sseClients.has(reply)) return; + this.backpressuredClients.delete(reply); + try { + const drainPadding = this._isTunnelActive ? SSE_PADDING : ''; + const recovered = reply.raw.write(`event: ${SseEvent.SessionNeedsRefresh}\ndata: {}\n\n${drainPadding}`); + if (!recovered) { + this.markBackpressured(reply); + return; + } + const pendingLayouts = this.pendingTabLayoutFrames.get(reply); + if (pendingLayouts) { + for (const [owner, pendingLayout] of pendingLayouts) { + pendingLayouts.delete(owner); + this.sendSSEPreformatted(reply, pendingLayout); + if (!this.sseClients.has(reply)) return; + if (this.backpressuredClients.has(reply)) { + if (pendingLayouts.size === 0) this.pendingTabLayoutFrames.delete(reply); + return; + } + } + this.pendingTabLayoutFrames.delete(reply); + } + const pendingOrder = this.pendingSessionOrderFrames.get(reply); + if (!pendingOrder) return; + this.pendingSessionOrderFrames.delete(reply); + this.sendSSEPreformatted(reply, pendingOrder); + } catch { + this.removeClient(reply); } } @@ -224,24 +266,11 @@ export class SseStreamManager { try { const ok = reply.raw.write(message); if (!ok) { - // Buffer is full — mark as backpressured, resume on drain - this.backpressuredClients.add(reply); - reply.raw.once('drain', () => { - this.backpressuredClients.delete(reply); - // Client may have missed terminal data during backpressure. - // Tell it to reload the active session's buffer to recover. - try { - const drainPadding = this._isTunnelActive ? SSE_PADDING : ''; - reply.raw.write(`event: ${SseEvent.SessionNeedsRefresh}\ndata: {}\n\n${drainPadding}`); - } catch { - /* client gone */ - } - }); + // Buffer is full — mark as backpressured, resume on drain. + this.markBackpressured(reply); } } catch { - this.sseClients.delete(reply); - this.remoteSseClients.delete(reply); - this.backpressuredClients.delete(reply); + this.removeClient(reply); } } @@ -276,6 +305,36 @@ export class SseStreamManager { for (const [client] of this.sseClients) { // Multi-user ownership routing (no-op for identity-less single-user clients). if (!this.canDeliver(client, hint)) continue; + if (event === SseEvent.TabLayoutChanged && this.backpressuredClients.has(client)) { + const owner = + data !== null && + typeof data === 'object' && + Object.hasOwn(data, 'owner') && + typeof (data as { owner?: unknown }).owner === 'string' + ? (data as { owner: string }).owner + : (hint?.username ?? hint?.owner ?? UNROUTED_TAB_LAYOUT); + let pending = this.pendingTabLayoutFrames.get(client); + if (!pending) { + pending = new Map(); + this.pendingTabLayoutFrames.set(client, pending); + } + pending.set(owner, message); + continue; + } + this.sendSSEPreformatted(client, message); + } + } + + /** Dispatch the legacy order projection selected from each trusted client identity. */ + broadcastSessionOrder(change: SessionOrderProjectionChange): void { + for (const [client] of this.sseClients) { + const payload = sessionOrderPayloadFor(this.sseClientIdentity.get(client), change); + if (!payload) continue; + const message = `event: ${SseEvent.SessionOrderChanged}\ndata: ${JSON.stringify(payload)}\n\n`; + if (this.backpressuredClients.has(client)) { + this.pendingSessionOrderFrames.set(client, message); + continue; + } this.sendSSEPreformatted(client, message); } } @@ -504,9 +563,7 @@ export class SseStreamManager { // Remove dead clients for (const client of deadClients) { - this.sseClients.delete(client); - this.remoteSseClients.delete(client); - this.backpressuredClients.delete(client); + this.removeClient(client); } if (deadClients.length > 0) { @@ -553,6 +610,8 @@ export class SseStreamManager { this.sseClients.clear(); this.remoteSseClients.clear(); this.backpressuredClients.clear(); + this.pendingSessionOrderFrames.clear(); + this.pendingTabLayoutFrames.clear(); // Clear per-session batch timers for (const timer of this.terminalBatchTimers.values()) { diff --git a/src/web/tab-layout-sse.ts b/src/web/tab-layout-sse.ts new file mode 100644 index 00000000..e85a8122 --- /dev/null +++ b/src/web/tab-layout-sse.ts @@ -0,0 +1,6 @@ +/** @fileoverview Trusted owner routing metadata for tab-layout invalidations. */ +import type { SseRoutingHint } from './sse-stream-manager.js'; + +export function deriveTabLayoutSseHint(data: unknown): SseRoutingHint { + return { username: (data as { owner?: string }).owner, sessionScoped: true }; +} diff --git a/test/cron-service.test.ts b/test/cron-service.test.ts index 68ee369b..cf10f37d 100644 --- a/test/cron-service.test.ts +++ b/test/cron-service.test.ts @@ -434,6 +434,46 @@ describe('CronService', () => { }); describe('runNow', () => { + it('awaits layout insertion and stops lifecycle work when registration rejects', async () => { + const store = makeStore(); + const sessions = new Map(); + let rejectRegistration!: (error: Error) => void; + const addSession = vi.fn( + () => + new Promise((_resolve, reject) => { + rejectRegistration = reject; + }) + ); + const persistSessionState = vi.fn(); + const setupSessionListeners = vi.fn(async () => {}); + const service = new CronService({ + store, + sessions, + addSession, + persistSessionState, + setupSessionListeners, + broadcast: vi.fn(), + getGlobalNiceConfig: vi.fn(async () => undefined), + getModelConfig: vi.fn(async () => null), + getClaudeModeConfig: vi.fn(async () => ({})), + getCheckpointDefaultEnabled: vi.fn(async () => true), + mux: { backend: 'tmux' }, + } as unknown as CronDeps); + const job = service.createJob(mkInput({ enabled: false })); + + const pending = service.runNow(job.id); + await vi.waitFor(() => expect(addSession).toHaveBeenCalledTimes(1)); + expect(persistSessionState).not.toHaveBeenCalled(); + expect(setupSessionListeners).not.toHaveBeenCalled(); + + rejectRegistration(new Error('layout capacity exceeded')); + const run = await pending; + expect(run!.status).toBe('failed'); + expect(run!.errorMessage).toMatch(/layout capacity exceeded/); + expect(persistSessionState).not.toHaveBeenCalled(); + expect(setupSessionListeners).not.toHaveBeenCalled(); + }); + it('launches regardless of enabled/schedule state', async () => { const job = svc.service.createJob(mkInput({ enabled: false })); const run = await svc.service.runNow(job.id); diff --git a/test/http-contract.test.ts b/test/http-contract.test.ts index 30e46c35..11dcdd60 100644 --- a/test/http-contract.test.ts +++ b/test/http-contract.test.ts @@ -6,8 +6,10 @@ * These behaviors live in server.ts (preSerialization hook, setNotFoundHandler), * which the route-test harness does not install — so they need a real WebServer. */ -import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest'; +import { flattenOwnerSessionOrder, type TabLayout } from '../src/tab-layout.js'; import { WebServer } from '../src/web/server.js'; +import { SseEvent } from '../src/web/sse-events.js'; const PORT = 3168; @@ -33,6 +35,183 @@ describe('Stable HTTP contract (live server)', () => { expect(body.data.version).toBeDefined(); }); + it('preserves the legacy global session order in single-user light state without exposing layouts', async () => { + const res = await fetch(`${base}/api/status`); + const body = await res.json(); + expect(body.data.sessionOrder).toEqual([]); + expect(body.data).not.toHaveProperty('tabLayouts'); + }); + + it('filters status order for regular users while admins and single-user mode retain the global projection', () => { + type FakeSession = { + id: string; + owner: string; + inputTokens: number; + outputTokens: number; + totalCost: number; + toLightDetailedState(): { id: string; owner: string }; + }; + type StatusInternals = { + sessions: Map; + store: { getSessionOrder(): string[]; setSessionOrder(order: string[]): void }; + cachedLightState: unknown; + cachedSessionsList: unknown; + getLightState(identity?: { username: string; role: 'admin' | 'user' }): Record; + }; + const internals = server as unknown as StatusInternals; + const previousOrder = internals.store.getSessionOrder(); + const previousSessions = new Map(internals.sessions); + const fakeSession = (id: string, owner: string): FakeSession => ({ + id, + owner, + inputTokens: 0, + outputTokens: 0, + totalCost: 0, + toLightDetailedState: () => ({ id, owner }), + }); + + try { + internals.sessions.clear(); + internals.sessions.set('a1', fakeSession('a1', 'alice')); + internals.sessions.set('b1', fakeSession('b1', 'bob')); + internals.sessions.set('a2', fakeSession('a2', 'alice')); + internals.store.setSessionOrder(['b1', 'a1', 'a2']); + internals.cachedLightState = null; + internals.cachedSessionsList = null; + + vi.stubEnv('CODEMAN_MULTIUSER', '1'); + expect(internals.getLightState({ username: 'alice', role: 'user' }).sessionOrder).toEqual(['a1', 'a2']); + expect(internals.getLightState({ username: 'root', role: 'admin' }).sessionOrder).toEqual(['b1', 'a1', 'a2']); + + vi.stubEnv('CODEMAN_MULTIUSER', '0'); + expect(internals.getLightState().sessionOrder).toEqual(['b1', 'a1', 'a2']); + } finally { + vi.unstubAllEnvs(); + internals.sessions.clear(); + for (const [id, session] of previousSessions) internals.sessions.set(id, session); + internals.store.setSessionOrder(previousOrder); + internals.cachedLightState = null; + internals.cachedSessionsList = null; + } + }); + + it('keeps the tab layout foundation smoke contract atomic and writable', async () => { + type TabLayoutInternals = { + sessions: Map; + store: { + getState(): { sessionOrder?: string[]; tabLayouts?: Record }; + getSessionOrder(): string[]; + getTabLayout(owner: string): TabLayout | null; + getTabLayouts(): Record; + commitTabLayoutProjection: (...args: unknown[]) => unknown; + save(): void; + }; + sse: { + addClient(reply: unknown, sessionFilter: Set | null, isRemote: boolean): void; + removeClient(reply: unknown): void; + broadcast: (...args: unknown[]) => void; + broadcastSessionOrder: (...args: unknown[]) => void; + }; + }; + const internals = server as unknown as TabLayoutInternals; + const commit = vi.spyOn(internals.store, 'commitTabLayoutProjection'); + const layoutEvent = vi.spyOn(internals.sse, 'broadcast'); + const orderEvent = vi.spyOn(internals.sse, 'broadcastSessionOrder'); + const previousSessions = new Map(internals.sessions); + const storeState = internals.store.getState(); + const previousSessionOrder = storeState.sessionOrder ? [...storeState.sessionOrder] : undefined; + const previousTabLayouts = storeState.tabLayouts ? structuredClone(storeState.tabLayouts) : undefined; + const recipientWrites: string[] = []; + const recipient = { raw: { write: (chunk: string) => (recipientWrites.push(chunk), true) } }; + internals.sse.addClient(recipient, null, false); + const requested = { + version: 0, + groups: [], + ungrouped: [], + updatedAt: '2026-08-23T00:00:00.000Z', + }; + + try { + expect(internals.store.getTabLayout('@single')).toBeNull(); + + const direct = await fetch(`${base}/api/tab-layout`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ baseVersion: 0, layout: requested }), + }); + expect(direct.status).toBe(200); + expect((await direct.json()).data.layout.version).toBe(1); + expect(commit).toHaveBeenCalledTimes(1); + expect(layoutEvent).toHaveBeenCalledTimes(1); + expect(layoutEvent).toHaveBeenCalledWith(SseEvent.TabLayoutChanged, { owner: '@single', version: 1 }, undefined); + expect(orderEvent).not.toHaveBeenCalled(); + expect(recipientWrites).toEqual(['event: tab:layoutChanged\ndata: {"owner":"@single","version":1}\n\n']); + recipientWrites.length = 0; + + const layoutBeforeConflict = internals.store.getTabLayout('@single'); + const orderBeforeConflict = internals.store.getSessionOrder(); + const writesBeforeConflict = commit.mock.calls.length; + const layoutEventsBeforeConflict = layoutEvent.mock.calls.length; + const orderEventsBeforeConflict = orderEvent.mock.calls.length; + const stale = await fetch(`${base}/api/tab-layout`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ baseVersion: 0, layout: requested }), + }); + expect(stale.status).toBe(409); + expect((await stale.json()).errorCode).toBe('CONFLICT'); + expect(internals.store.getTabLayout('@single')).toEqual(layoutBeforeConflict); + expect(internals.store.getSessionOrder()).toEqual(orderBeforeConflict); + expect(commit).toHaveBeenCalledTimes(writesBeforeConflict); + expect(layoutEvent).toHaveBeenCalledTimes(layoutEventsBeforeConflict); + expect(orderEvent).toHaveBeenCalledTimes(orderEventsBeforeConflict); + expect(recipientWrites).toEqual([]); + + const ownerGet = await fetch(`${base}/api/tab-layout`); + expect(ownerGet.status).toBe(200); + expect((await ownerGet.json()).data.layout.version).toBe(1); + + const firstId = 'tab-layout-smoke-a'; + const secondId = 'tab-layout-smoke-b'; + internals.sessions.set(firstId, { id: firstId, createdAt: 1 }); + internals.sessions.set(secondId, { id: secondId, createdAt: 2 }); + const orderEventsBeforeLegacy = orderEvent.mock.calls.length; + const legacyPut = await fetch(`${base}/api/session-order`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ order: [secondId, firstId] }), + }); + expect(legacyPut.status).toBe(200); + expect((await legacyPut.json()).data.order).toEqual([secondId, firstId]); + expect(internals.store.getSessionOrder()).toEqual([secondId, firstId]); + expect(flattenOwnerSessionOrder(internals.store.getTabLayout('@single')!)).toEqual([secondId, firstId]); + expect(orderEvent).toHaveBeenCalledTimes(orderEventsBeforeLegacy + 1); + expect(orderEvent).toHaveBeenLastCalledWith({ + changedOwnerOrders: { '@single': [secondId, firstId] }, + globalOrder: [secondId, firstId], + globalChanged: true, + }); + expect(recipientWrites.at(-1)).toBe( + `event: session:orderChanged\ndata: {"order":["${secondId}","${firstId}"]}\n\n` + ); + } finally { + internals.sse.removeClient(recipient); + internals.sessions.clear(); + for (const [id, session] of previousSessions) internals.sessions.set(id, session); + if (previousSessionOrder) storeState.sessionOrder = [...previousSessionOrder]; + else delete storeState.sessionOrder; + if (previousTabLayouts) storeState.tabLayouts = structuredClone(previousTabLayouts); + else delete storeState.tabLayouts; + internals.store.save(); + commit.mockRestore(); + layoutEvent.mockRestore(); + orderEvent.mockRestore(); + } + + expect(internals.store.getSessionOrder()).toEqual(previousSessionOrder ?? []); + expect(internals.store.getTabLayouts()).toEqual(previousTabLayouts ?? {}); + }); + it('serves the same envelope on the /api/v1 alias', async () => { const res = await fetch(`${base}/api/v1/status`); expect(res.status).toBe(200); diff --git a/test/mocks/mock-route-context.ts b/test/mocks/mock-route-context.ts index e7b60419..7dd4222e 100644 --- a/test/mocks/mock-route-context.ts +++ b/test/mocks/mock-route-context.ts @@ -33,9 +33,30 @@ export function createMockRouteContext(options?: { return { // -- SessionPort -- sessions, - addSession: vi.fn((s: MockSession) => { + addSession: vi.fn(async (s: MockSession) => { sessions.set(s.id, s); }), + tabLayouts: { + get: vi.fn(), + put: vi.fn(), + putLegacyOrder: vi.fn(async (_actor: unknown, order: readonly string[]) => ({ + order: [...order], + changedOwnerOrders: {}, + globalOrder: [...order], + globalChanged: false, + })), + sessionCreated: vi.fn(), + webviewCreated: vi.fn(), + sessionsRemoved: vi.fn(async () => {}), + webviewDeleted: vi.fn(async () => {}), + markRestorationComplete: vi.fn(), + markRestorationFailed: vi.fn(), + markRestorationSkipped: vi.fn(), + assertDeletionReady: vi.fn(), + runSessionDeletion: vi.fn(async (_removed, action) => action()), + runStaleSessionCleanup: vi.fn(async (_activeIds, action) => action(new Set())), + reconcileAfterRestoration: vi.fn(async () => {}), + }, cleanupSession: vi.fn(async () => {}), setupSessionListeners: vi.fn(async () => {}), persistSessionState: vi.fn(), @@ -82,6 +103,7 @@ export function createMockRouteContext(options?: { getGlobalStats: vi.fn(() => ({ sessionsCreated: 0 })), getDailyStats: vi.fn(() => []), cleanupStaleSessions: vi.fn(() => ({ count: 0, cleaned: [] })), + cleanupSessionsByIds: vi.fn(() => ({ count: 0, cleaned: [] })), }, port: 3000, https: false, diff --git a/test/routes/ralph-routes.test.ts b/test/routes/ralph-routes.test.ts index 3ed39418..4f2eaa56 100644 --- a/test/routes/ralph-routes.test.ts +++ b/test/routes/ralph-routes.test.ts @@ -461,6 +461,32 @@ describe('ralph-routes', () => { // ========== POST /api/ralph-loop/start ========== describe('POST /api/ralph-loop/start', () => { + it('awaits layout insertion and stops lifecycle work when registration rejects', async () => { + let rejectRegistration!: (error: Error) => void; + harness.ctx.addSession.mockImplementationOnce( + () => + new Promise((_resolve, reject) => { + rejectRegistration = reject; + }) + ); + + const pending = harness.app.inject({ + method: 'POST', + url: '/api/ralph-loop/start', + payload: { taskDescription: 'test task', completionPhrase: 'DONE', caseName: 'registration-order' }, + }); + await vi.waitFor(() => expect(harness.ctx.addSession).toHaveBeenCalledTimes(1)); + expect(harness.ctx.persistSessionState).not.toHaveBeenCalled(); + expect(harness.ctx.setupSessionListeners).not.toHaveBeenCalled(); + + rejectRegistration(new Error('layout capacity exceeded')); + const response = await pending; + expect(response.statusCode).toBe(500); + expect(harness.ctx.persistSessionState).not.toHaveBeenCalled(); + expect(harness.ctx.setupSessionListeners).not.toHaveBeenCalled(); + expect(harness.ctx.broadcast).not.toHaveBeenCalledWith('session:created', expect.anything()); + }); + it('rejects invalid request body', async () => { const res = await harness.app.inject({ method: 'POST', diff --git a/test/routes/session-order-routes.test.ts b/test/routes/session-order-routes.test.ts index f7dbba7f..4c8150f2 100644 --- a/test/routes/session-order-routes.test.ts +++ b/test/routes/session-order-routes.test.ts @@ -1,11 +1,11 @@ /** - * @fileoverview Tests for PUT /api/session-order (global tab-order sync, COD-131). + * @fileoverview Tests for the synchronized legacy PUT /api/session-order endpoint. * * Uses app.inject() — no real HTTP ports needed. * Asserts the uniform envelope contract: * SUCCESS -> 2xx, { success: true, data: { order } } * ERROR -> 4xx/5xx, { success: false, error, errorCode } - * and that the order is persisted to the (mock) StateStore + broadcast over SSE. + * Legacy callers are routed through the authenticated owner-scoped tab-layout service. */ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import Fastify, { type FastifyInstance } from 'fastify'; @@ -13,6 +13,7 @@ import fastifyCookie from '@fastify/cookie'; import { createMockRouteContext, type MockRouteContext } from '../mocks/index.js'; import { installRouteErrorHandler } from '../../src/web/route-error-handler.js'; import { ApiErrorCode, httpStatusForErrorCode } from '../../src/types.js'; +import { TabLayoutValidationError } from '../../src/tab-layout.js'; // registerSessionRoutes pulls in session.js which can shell out; stub the bits // that would touch the OS at import/registration time. None are needed by the @@ -29,11 +30,22 @@ interface LocalHarness { ctx: MockRouteContext; } -async function buildHarness(): Promise { +async function buildHarness(authUser = { username: 'alice', role: 'user' as const }): Promise { const app = Fastify({ logger: false }); await app.register(fastifyCookie); + app.addHook('onRequest', async (req) => { + (req as unknown as { authUser: typeof authUser }).authUser = authUser; + }); const ctx = createMockRouteContext(); + Object.assign(ctx.tabLayouts, { + putLegacyOrder: vi.fn(async (_actor: unknown, order: string[]) => ({ + order: [...order], + changedOwnerOrders: {}, + globalOrder: [...order], + globalChanged: true, + })), + }); registerSessionRoutes(app, ctx as unknown as Parameters[1]); // Mirror production's uniform-envelope preSerialization hook (server.ts). @@ -60,35 +72,22 @@ describe('PUT /api/session-order', () => { let harness: LocalHarness; beforeEach(async () => { + vi.stubEnv('CODEMAN_MULTIUSER', '1'); harness = await buildHarness(); }); afterEach(async () => { await harness.app.close(); + vi.unstubAllEnvs(); }); - it('persists the order and returns it in the envelope', async () => { - const res = await harness.app.inject({ - method: 'PUT', - url: '/api/session-order', - payload: { order: ['a', 'b', 'c'] }, + it('routes a regular legacy PUT through the authenticated owner layout service', async () => { + vi.mocked(harness.ctx.tabLayouts.putLegacyOrder).mockResolvedValueOnce({ + order: ['a', 'b'], + changedOwnerOrders: { alice: ['a', 'b'] }, + globalOrder: ['a', 'b'], + globalChanged: true, }); - - expect(res.statusCode).toBe(200); - const body = res.json(); - expect(body).toEqual({ success: true, data: { order: ['a', 'b', 'c'] } }); - // Persisted to the store. - expect(harness.ctx.store.setSessionOrder).toHaveBeenCalledWith(['a', 'b', 'c']); - expect(harness.ctx.store.getSessionOrder()).toEqual(['a', 'b', 'c']); - // Broadcast over SSE. - expect(harness.ctx.broadcast).toHaveBeenCalledWith('session:orderChanged', { order: ['a', 'b', 'c'] }); - }); - - it('preserves a server-only id (unknown to the pushing device) at the end', async () => { - // Seed the store with an order containing a server-only id "z". - harness.ctx.store.setSessionOrder(['a', 'z', 'b']); - (harness.ctx.broadcast as ReturnType).mockClear(); - const res = await harness.app.inject({ method: 'PUT', url: '/api/session-order', @@ -96,25 +95,24 @@ describe('PUT /api/session-order', () => { }); expect(res.statusCode).toBe(200); - const body = res.json(); - // Incoming order wins, server-only "z" falls to the end. - expect(body).toEqual({ success: true, data: { order: ['b', 'a', 'z'] } }); - expect(harness.ctx.store.getSessionOrder()).toEqual(['b', 'a', 'z']); - expect(harness.ctx.broadcast).toHaveBeenCalledWith('session:orderChanged', { order: ['b', 'a', 'z'] }); + expect(harness.ctx.tabLayouts.putLegacyOrder).toHaveBeenCalledWith({ owner: 'alice', isAdmin: false }, ['b', 'a']); + expect(res.json().data.order).toEqual(['a', 'b']); }); - it('normalizes junk input (dedup + drop empties) before persisting', async () => { + it('uses the machine-wide admin bridge for an admin caller', async () => { + await harness.app.close(); + harness = await buildHarness({ username: 'root', role: 'admin' }); const res = await harness.app.inject({ method: 'PUT', url: '/api/session-order', - payload: { order: ['a', 'a', '', 'b'] }, + payload: { order: ['b', 'a'] }, }); expect(res.statusCode).toBe(200); - expect(res.json()).toEqual({ success: true, data: { order: ['a', 'b'] } }); + expect(harness.ctx.tabLayouts.putLegacyOrder).toHaveBeenCalledWith({ owner: 'root', isAdmin: true }, ['b', 'a']); }); - it('rejects a non-array order with a 4xx envelope', async () => { + it('rejects malformed bodies before invoking the service', async () => { const res = await harness.app.inject({ method: 'PUT', url: '/api/session-order', @@ -124,6 +122,23 @@ describe('PUT /api/session-order', () => { expect(res.statusCode).toBeGreaterThanOrEqual(400); const body = res.json(); expect(body.success).toBe(false); - expect(harness.ctx.store.setSessionOrder).not.toHaveBeenCalled(); + expect(harness.ctx.tabLayouts.putLegacyOrder).not.toHaveBeenCalled(); + }); + + it('maps owner-boundary validation failures to INVALID_INPUT', async () => { + vi.mocked(harness.ctx.tabLayouts.putLegacyOrder).mockRejectedValueOnce( + new TabLayoutValidationError('session is not owned by layout owner: foreign') + ); + const res = await harness.app.inject({ + method: 'PUT', + url: '/api/session-order', + payload: { order: ['foreign'] }, + }); + + expect(res.statusCode).toBe(400); + expect(res.json()).toMatchObject({ + success: false, + errorCode: ApiErrorCode.INVALID_INPUT, + }); }); }); diff --git a/test/routes/system-routes.test.ts b/test/routes/system-routes.test.ts index e121ba5d..cbb6ee1a 100644 --- a/test/routes/system-routes.test.ts +++ b/test/routes/system-routes.test.ts @@ -322,11 +322,16 @@ describe('system-routes', () => { describe('POST /api/cleanup-state', () => { it('cleans up stale session state', async () => { + const runStaleSessionCleanup = vi.fn( + async (_activeIds: Set, action: (ids: ReadonlySet) => unknown) => action(new Set()) + ); + harness.ctx.tabLayouts.runStaleSessionCleanup = runStaleSessionCleanup; const res = await harness.app.inject({ method: 'POST', url: '/api/cleanup-state' }); expect(res.statusCode).toBe(200); const body = JSON.parse(res.body); expect(body.cleanedSessions).toBe(0); - expect(harness.ctx.store.cleanupStaleSessions).toHaveBeenCalled(); + expect(harness.ctx.store.cleanupSessionsByIds).toHaveBeenCalledWith(new Set()); + expect(runStaleSessionCleanup).toHaveBeenCalledOnce(); }); }); diff --git a/test/routes/tab-layout-routes.test.ts b/test/routes/tab-layout-routes.test.ts new file mode 100644 index 00000000..ee7b2901 --- /dev/null +++ b/test/routes/tab-layout-routes.test.ts @@ -0,0 +1,106 @@ +/** + * @fileoverview Owner-scoped tab-layout HTTP concurrency and validation contract. + */ +import Fastify from 'fastify'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { registerTabLayoutRoutes } from '../../src/web/routes/tab-layout-routes.js'; +import { installRouteErrorHandler } from '../../src/web/route-error-handler.js'; +import type { TabLayout } from '../../src/tab-layout.js'; + +const layout = (version = 3): TabLayout => ({ + version, + groups: [], + ungrouped: [{ kind: 'session', id: 'mine' }], + updatedAt: '2026-08-16T00:00:00.000Z', +}); + +async function harness(username?: string, role: 'admin' | 'user' = 'user') { + const app = Fastify({ logger: false }); + if (username) { + app.addHook('onRequest', async (req) => { + (req as unknown as { authUser: { username: string; role: 'admin' | 'user' } }).authUser = { username, role }; + }); + } + const service = { + get: vi.fn(async () => layout()), + put: vi.fn(async (_owner: string, desired: unknown, baseVersion: number) => ({ + status: 'updated' as const, + layout: { ...(desired as TabLayout), version: baseVersion + 1 }, + })), + }; + registerTabLayoutRoutes(app, { tabLayouts: service } as never); + installRouteErrorHandler(app); + await app.ready(); + return { app, service }; +} + +afterEach(() => vi.unstubAllEnvs()); + +describe('tab layout routes', () => { + it('maps single-user requests to @single and never accepts an owner override', async () => { + vi.stubEnv('CODEMAN_MULTIUSER', '0'); + const { app, service } = await harness(); + const response = await app.inject({ method: 'GET', url: '/api/tab-layout?owner=foreign' }); + expect(response.statusCode).toBe(200); + expect(service.get).toHaveBeenCalledWith('@single'); + await app.close(); + }); + + it('uses the authenticated username in multi-user mode, including for admins', async () => { + vi.stubEnv('CODEMAN_MULTIUSER', '1'); + const { app, service } = await harness('admin-a', 'admin'); + await app.inject({ method: 'GET', url: '/api/tab-layout?owner=someone-else' }); + expect(service.get).toHaveBeenCalledWith('admin-a'); + await app.close(); + }); + + it.each([2, 4])('returns 409 with the authoritative prepared layout when baseVersion=%s', async (baseVersion) => { + vi.stubEnv('CODEMAN_MULTIUSER', '1'); + const { app, service } = await harness('alice'); + service.put.mockResolvedValueOnce({ status: 'conflict', layout: layout(3) }); + const response = await app.inject({ + method: 'PUT', + url: '/api/tab-layout', + payload: { baseVersion, layout: layout(baseVersion) }, + }); + expect(response.statusCode).toBe(409); + expect(response.json().success).toBe(false); + expect(response.json().errorCode).toBe('CONFLICT'); + expect(response.json().data.layout).toEqual(layout(3)); + expect(service.put).toHaveBeenCalledWith('alice', layout(baseVersion), baseVersion); + await app.close(); + }); + + it('rejects malformed writes before invoking the service', async () => { + const { app, service } = await harness(); + const response = await app.inject({ method: 'PUT', url: '/api/tab-layout', payload: { baseVersion: -1 } }); + expect(response.statusCode).toBe(400); + expect(service.put).not.toHaveBeenCalled(); + await app.close(); + }); + + it('rejects extra write keys before invoking the service', async () => { + const { app, service } = await harness(); + const response = await app.inject({ + method: 'PUT', + url: '/api/tab-layout', + payload: { baseVersion: 3, layout: layout(), owner: 'foreign' }, + }); + + expect(response.statusCode).toBe(400); + expect(response.json().errorCode).toBe('INVALID_INPUT'); + expect(service.put).not.toHaveBeenCalled(); + await app.close(); + }); + + it('has an explicit conservative body limit', async () => { + const { app } = await harness(); + const response = await app.inject({ + method: 'PUT', + url: '/api/tab-layout', + payload: { baseVersion: 3, layout: layout(), padding: 'x'.repeat(140 * 1024) }, + }); + expect(response.statusCode).toBe(413); + await app.close(); + }); +}); diff --git a/test/routes/webview-routes.test.ts b/test/routes/webview-routes.test.ts index 85b10475..490155d2 100644 --- a/test/routes/webview-routes.test.ts +++ b/test/routes/webview-routes.test.ts @@ -5,7 +5,7 @@ * the developer's real ~/.codeman/webviews.json. */ -import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import Fastify, { type FastifyInstance } from 'fastify'; import fastifyCookie from '@fastify/cookie'; import fastifyWebsocket from '@fastify/websocket'; @@ -16,17 +16,23 @@ import { registerWebviewRoutes } from '../../src/web/routes/webview-routes.js'; import { installRouteErrorHandler } from '../../src/web/route-error-handler.js'; import { webviewCapabilities } from '../../src/webview-capabilities.js'; import { capabilityFromProxyPath } from '../../src/web/webview-proxy.js'; +import { TabLayoutService } from '../../src/tab-layout-service.js'; +import type { TabLayout } from '../../src/tab-layout.js'; let app: FastifyInstance; let tmpDir: string; let savedDataDir: string | undefined; const broadcasts: Array<{ event: string; data: unknown }> = []; +const webviewCreated = vi.fn(async () => {}); +const webviewDeleted = vi.fn(async () => {}); beforeEach(async () => { tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'codeman-webviews-')); savedDataDir = process.env.CODEMAN_DATA_DIR; process.env.CODEMAN_DATA_DIR = tmpDir; broadcasts.length = 0; + webviewCreated.mockClear(); + webviewDeleted.mockClear(); app = Fastify({ logger: false }); await app.register(fastifyCookie); @@ -34,6 +40,7 @@ beforeEach(async () => { await app.register(fastifyWebsocket); registerWebviewRoutes(app, { broadcast: (event: string, data: unknown) => broadcasts.push({ event, data }), + tabLayouts: { webviewCreated, webviewDeleted }, } as never); installRouteErrorHandler(app); await app.ready(); @@ -90,6 +97,58 @@ describe('POST /api/webviews', () => { } }); + it('rolls back webview persistence and emits nothing when layout capacity rejects insertion', async () => { + const refs = Array.from({ length: 512 }, (_, index) => ({ kind: 'session' as const, id: `s-${index}` })); + const original: TabLayout = { + version: 9, + groups: [], + ungrouped: refs, + updatedAt: '2026-08-16T00:00:00.000Z', + }; + let stored = original; + const live = new Map(refs.map((ref, index) => [ref.id, { id: ref.id, createdAt: index }])); + const atomicBroadcast = vi.fn(); + const service = new TabLayoutService({ + store: { + getTabLayout: () => stored, + setTabLayout: (_owner, layout) => { + stored = layout; + }, + getSessions: () => ({}), + getSessionOrder: () => [], + } as never, + sessions: live, + readWebviews: async () => + (await import('../../src/webview-store.js')).readWebviews(tmpDir) as Promise< + Array<{ id: string; owner?: string }> + >, + broadcast: atomicBroadcast, + broadcastSessionOrder: vi.fn(), + }); + const atomicApp = Fastify({ logger: false }); + await atomicApp.register(fastifyCookie); + await atomicApp.register(fastifyWebsocket); + registerWebviewRoutes(atomicApp, { + broadcast: atomicBroadcast, + tabLayouts: service, + } as never); + installRouteErrorHandler(atomicApp); + await atomicApp.ready(); + + const response = await atomicApp.inject({ + method: 'POST', + url: '/api/webviews', + payload: { name: 'overflow', url: 'https://example.test/' }, + }); + const list = (await atomicApp.inject({ method: 'GET', url: '/api/webviews' })).json().data.webviews; + + expect(response.statusCode).toBe(500); + expect(list).toEqual([]); + expect(stored).toEqual(original); + expect(atomicBroadcast).not.toHaveBeenCalled(); + await atomicApp.close(); + }); + it('rejects URLs carrying embedded credentials', async () => { const res = await create({ name: 'bad', url: 'http://user:pass@host:4000/' }); expect(res.statusCode).toBe(400); @@ -142,6 +201,19 @@ describe('DELETE /api/webviews/:id', () => { expect(webviewCapabilities.resolve(cap)).toBeUndefined(); }); + it('keeps the exact saved record and emits nothing when layout deletion fails', async () => { + const created = (await create({ name: 'Keep me', url: 'https://keep.example/' })).json().data; + broadcasts.length = 0; + webviewDeleted.mockRejectedValueOnce(new Error('tab layout restoration failed')); + + const response = await app.inject({ method: 'DELETE', url: `/api/webviews/${created.id}` }); + const list = (await app.inject({ method: 'GET', url: '/api/webviews' })).json().data.webviews; + + expect(response.statusCode).toBe(500); + expect(list).toEqual([created]); + expect(broadcasts).toEqual([]); + }); + it('404s an unknown id', async () => { expect((await app.inject({ method: 'DELETE', url: '/api/webviews/nope' })).statusCode).toBe(404); }); diff --git a/test/scheduled-layout-registration.test.ts b/test/scheduled-layout-registration.test.ts new file mode 100644 index 00000000..d027c2a0 --- /dev/null +++ b/test/scheduled-layout-registration.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it, vi } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { WebServer } from '../src/web/server.js'; + +describe('scheduled session layout registration', () => { + it('registers the layout before lifecycle work and rolls back a rejected tentative session', async () => { + const sessions = new Map(); + const session = { id: 'scheduled-session', owner: 'alice' }; + const sessionCreated = vi.fn(async () => { + throw new Error('layout unavailable'); + }); + const server = Object.create(WebServer.prototype) as { + sessions: typeof sessions; + tabLayouts: { sessionCreated: typeof sessionCreated }; + registerSessionWithLayout(session: typeof session): Promise; + }; + server.sessions = sessions; + server.tabLayouts = { sessionCreated }; + + await expect(server.registerSessionWithLayout(session)).rejects.toThrow('layout unavailable'); + + expect(sessionCreated).toHaveBeenCalledWith('alice'); + expect(sessions.has(session.id)).toBe(false); + }); + + it('uses the shared registration helper in the scheduled loop before persistence and listeners', () => { + const source = readFileSync(new URL('../src/web/server.ts', import.meta.url), 'utf8'); + const loop = source.slice( + source.indexOf('private async runScheduledLoop'), + source.indexOf('private async stopScheduledRun') + ); + + expect(loop).toContain('await this.registerSessionWithLayout(session);'); + expect(loop.indexOf('await this.registerSessionWithLayout(session);')).toBeLessThan( + loop.indexOf('this.store.incrementSessionsCreated();') + ); + expect(loop.indexOf('await this.registerSessionWithLayout(session);')).toBeLessThan( + loop.indexOf('await this.setupSessionListeners(session);') + ); + }); +}); diff --git a/test/session-cleanup.test.ts b/test/session-cleanup.test.ts index c5d760b8..73e40c06 100644 --- a/test/session-cleanup.test.ts +++ b/test/session-cleanup.test.ts @@ -1,8 +1,8 @@ import { describe, it, expect, beforeAll, afterAll, afterEach, vi } from 'vitest'; import { WebServer } from '../src/web/server.js'; -import { existsSync, rmSync } from 'node:fs'; +import { existsSync, mkdtempSync, rmSync } from 'node:fs'; import { join } from 'node:path'; -import { homedir } from 'node:os'; +import { homedir, tmpdir } from 'node:os'; const TEST_PORT = 3120; const CASES_DIR = join(homedir(), 'codeman-cases'); @@ -15,10 +15,12 @@ const CASES_DIR = join(homedir(), 'codeman-cases'); describe('Session Cleanup', () => { let server: WebServer; let baseUrl: string; + let testWorkingDir: string; const createdCases: string[] = []; const createdSessions: string[] = []; beforeAll(async () => { + testWorkingDir = mkdtempSync(join(tmpdir(), 'codeman-cleanup-test-')); server = new WebServer(TEST_PORT, false, true); await server.start(); baseUrl = `http://localhost:${TEST_PORT}`; @@ -43,9 +45,36 @@ describe('Session Cleanup', () => { } catch {} } await server.stop(); + rmSync(testWorkingDir, { recursive: true, force: true }); }, 60000); describe('Session Deletion', () => { + it('rejects before stopping the session when layout deletion preparation fails', async () => { + const createRes = await fetch(`${baseUrl}/api/sessions`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ workingDir: testWorkingDir }), + }); + const created = await createRes.json(); + const id = created.data.session.id; + createdSessions.push(id); + const internals = server as unknown as { + tabLayouts: { runSessionDeletion: (...args: unknown[]) => Promise }; + }; + const deletionSpy = vi + .spyOn(internals.tabLayouts, 'runSessionDeletion') + .mockRejectedValueOnce(new Error('layout prune unavailable')); + try { + const deleteRes = await fetch(`${baseUrl}/api/sessions/${id}`, { method: 'DELETE' }); + const getRes = await fetch(`${baseUrl}/api/sessions/${id}`); + + expect(deleteRes.status).toBe(500); + expect(getRes.status).toBe(200); + } finally { + deletionSpy.mockRestore(); + } + }); + it('should properly stop and cleanup interactive session', async () => { const caseName = `cleanup-test-${Date.now()}`; createdCases.push(caseName); diff --git a/test/session-order-sse.test.ts b/test/session-order-sse.test.ts new file mode 100644 index 00000000..f3ff0db2 --- /dev/null +++ b/test/session-order-sse.test.ts @@ -0,0 +1,244 @@ +/** @fileoverview Trusted recipient selection for legacy session-order SSE compatibility. */ +import type { FastifyReply } from 'fastify'; +import { describe, expect, it } from 'vitest'; +import type { SessionOrderProjectionChange } from '../src/tab-layout-service.js'; +import { CleanupManager } from '../src/utils/index.js'; +import { sessionOrderPayloadFor } from '../src/web/session-order-sse.js'; +import { SseStreamManager } from '../src/web/sse-stream-manager.js'; + +function changeWith( + changedOwnerOrders: Record, + globalOrder: string[], + globalChanged: boolean +): SessionOrderProjectionChange { + return { changedOwnerOrders, globalOrder, globalChanged }; +} + +function client() { + const writes: string[] = []; + return { + writes, + reply: { raw: { write: (chunk: string) => (writes.push(chunk), true) } } as unknown as FastifyReply, + }; +} + +function backpressuredClient(options: { throwAfterBackpressure?: boolean } = {}) { + const writes: string[] = []; + let firstWrite = true; + let onDrain: (() => void) | undefined; + const raw = { + write(chunk: string) { + if (!firstWrite && options.throwAfterBackpressure) throw new Error('client disconnected'); + writes.push(chunk); + if (firstWrite) { + firstWrite = false; + return false; + } + return true; + }, + once(event: string, callback: () => void) { + if (event === 'drain') onDrain = callback; + return raw; + }, + }; + return { + writes, + reply: { raw } as unknown as FastifyReply, + drain: () => { + const callback = onDrain; + onDrain = undefined; + callback?.(); + }, + }; +} + +describe('legacy session-order SSE routing', () => { + it('selects an owner slice for the matching regular user and nothing for another user', () => { + const change = changeWith({ alice: ['a2', 'a1'] }, ['a2', 'b1', 'a1'], true); + expect(sessionOrderPayloadFor({ username: 'alice', role: 'user' }, change)).toEqual({ order: ['a2', 'a1'] }); + expect(sessionOrderPayloadFor({ username: 'bob', role: 'user' }, change)).toBeUndefined(); + }); + + it('does not treat inherited object properties as changed owner slices', () => { + const change = changeWith({ alice: ['a1'] }, ['a1'], true); + expect(sessionOrderPayloadFor({ username: 'constructor', role: 'user' }, change)).toBeUndefined(); + }); + + it('selects the global projection for admins even when only interleaving changed', () => { + const change = changeWith({}, ['b1', 'a1'], true); + expect(sessionOrderPayloadFor({ username: 'admin', role: 'admin' }, change)).toEqual({ order: ['b1', 'a1'] }); + }); + + it('uses the global projection for identity-less single-user clients', () => { + const change = changeWith({ '@single': ['s2', 's1'] }, ['s2', 's1'], true); + expect(sessionOrderPayloadFor(undefined, change)).toEqual({ order: ['s2', 's1'] }); + }); + + it('delivers owner slices to every matching device, the global order to admins, and nothing to other users', () => { + const cleanup = new CleanupManager(); + const manager = new SseStreamManager({ getSessionStateWithRespawn: () => null }, cleanup); + const alicePhone = client(); + const aliceDesktop = client(); + const bob = client(); + const admin = client(); + manager.addClient(alicePhone.reply, null, false, undefined, { username: 'alice', role: 'user' }); + manager.addClient(aliceDesktop.reply, null, false, undefined, { username: 'alice', role: 'user' }); + manager.addClient(bob.reply, null, false, undefined, { username: 'bob', role: 'user' }); + manager.addClient(admin.reply, null, false, undefined, { username: 'root', role: 'admin' }); + + manager.broadcastSessionOrder(changeWith({ alice: ['a2', 'a1'] }, ['a2', 'b1', 'a1'], true)); + + const ownerFrame = 'event: session:orderChanged\ndata: {"order":["a2","a1"]}\n\n'; + expect(alicePhone.writes).toEqual([ownerFrame]); + expect(aliceDesktop.writes).toEqual([ownerFrame]); + expect(bob.writes).toEqual([]); + expect(admin.writes).toEqual(['event: session:orderChanged\ndata: {"order":["a2","b1","a1"]}\n\n']); + cleanup.dispose(); + }); + + it('delivers a global-only interleaving change to admins only', () => { + const cleanup = new CleanupManager(); + const manager = new SseStreamManager({ getSessionStateWithRespawn: () => null }, cleanup); + const alice = client(); + const admin = client(); + manager.addClient(alice.reply, null, false, undefined, { username: 'alice', role: 'user' }); + manager.addClient(admin.reply, null, false, undefined, { username: 'root', role: 'admin' }); + + manager.broadcastSessionOrder(changeWith({}, ['b1', 'a1'], true)); + + expect(alice.writes).toEqual([]); + expect(admin.writes).toEqual(['event: session:orderChanged\ndata: {"order":["b1","a1"]}\n\n']); + cleanup.dispose(); + }); + + it.each([ + { + label: 'order-only repair', + change: changeWith({ alice: ['a', 'b'] }, ['a', 'bob-1', 'b'], true), + ownerOrder: ['a', 'b'], + globalOrder: ['a', 'bob-1', 'b'], + }, + { + label: 'legacy-only deletion', + change: changeWith({ alice: [] }, ['bob-1'], true), + ownerOrder: [], + globalOrder: ['bob-1'], + }, + ])('delivers an $label correction to same-owner devices and admins only', ({ change, ownerOrder, globalOrder }) => { + const cleanup = new CleanupManager(); + const manager = new SseStreamManager({ getSessionStateWithRespawn: () => null }, cleanup); + const alicePhone = client(); + const aliceDesktop = client(); + const bob = client(); + const admin = client(); + manager.addClient(alicePhone.reply, null, false, undefined, { username: 'alice', role: 'user' }); + manager.addClient(aliceDesktop.reply, null, false, undefined, { username: 'alice', role: 'user' }); + manager.addClient(bob.reply, null, false, undefined, { username: 'bob', role: 'user' }); + manager.addClient(admin.reply, null, false, undefined, { username: 'root', role: 'admin' }); + + manager.broadcastSessionOrder(change); + + const ownerFrame = `event: session:orderChanged\ndata: ${JSON.stringify({ order: ownerOrder })}\n\n`; + expect(alicePhone.writes).toEqual([ownerFrame]); + expect(aliceDesktop.writes).toEqual([ownerFrame]); + expect(bob.writes).toEqual([]); + expect(admin.writes).toEqual([`event: session:orderChanged\ndata: ${JSON.stringify({ order: globalOrder })}\n\n`]); + cleanup.dispose(); + }); + + it('skips an inherited-key username without starving later matching and admin recipients', () => { + const cleanup = new CleanupManager(); + const manager = new SseStreamManager({ getSessionStateWithRespawn: () => null }, cleanup); + const constructorUser = client(); + const alice = client(); + const admin = client(); + manager.addClient(constructorUser.reply, null, false, undefined, { username: 'constructor', role: 'user' }); + manager.addClient(alice.reply, null, false, undefined, { username: 'alice', role: 'user' }); + manager.addClient(admin.reply, null, false, undefined, { username: 'root', role: 'admin' }); + + expect(() => manager.broadcastSessionOrder(changeWith({ alice: ['a1'] }, ['a1'], true))).not.toThrow(); + + expect(constructorUser.writes).toEqual([]); + expect(alice.writes).toEqual(['event: session:orderChanged\ndata: {"order":["a1"]}\n\n']); + expect(admin.writes).toEqual(['event: session:orderChanged\ndata: {"order":["a1"]}\n\n']); + cleanup.dispose(); + }); + + it('coalesces the latest filtered owner order while backpressured and flushes it on drain', () => { + const cleanup = new CleanupManager(); + const manager = new SseStreamManager({ getSessionStateWithRespawn: () => null }, cleanup); + const blockedAlice = backpressuredClient(); + const liveAlice = client(); + const bob = client(); + const admin = client(); + manager.addClient(blockedAlice.reply, null, false, undefined, { username: 'alice', role: 'user' }); + manager.addClient(liveAlice.reply, null, false, undefined, { username: 'alice', role: 'user' }); + manager.addClient(bob.reply, null, false, undefined, { username: 'bob', role: 'user' }); + manager.addClient(admin.reply, null, false, undefined, { username: 'root', role: 'admin' }); + + manager.broadcast('test:prime-backpressure', {}, { username: 'alice' }); + for (const writes of [blockedAlice.writes, liveAlice.writes, bob.writes, admin.writes]) writes.length = 0; + + manager.broadcastSessionOrder(changeWith({ alice: ['a2', 'a1'] }, ['a2', 'b1', 'a1'], true)); + manager.broadcastSessionOrder(changeWith({ alice: ['a1', 'a2'] }, ['b1', 'a1', 'a2'], true)); + + expect(blockedAlice.writes).toEqual([]); + expect(liveAlice.writes).toEqual([ + 'event: session:orderChanged\ndata: {"order":["a2","a1"]}\n\n', + 'event: session:orderChanged\ndata: {"order":["a1","a2"]}\n\n', + ]); + expect(bob.writes).toEqual([]); + expect(admin.writes).toEqual([ + 'event: session:orderChanged\ndata: {"order":["a2","b1","a1"]}\n\n', + 'event: session:orderChanged\ndata: {"order":["b1","a1","a2"]}\n\n', + ]); + + blockedAlice.drain(); + + expect(blockedAlice.writes).toEqual([ + 'event: session:needsRefresh\ndata: {}\n\n', + 'event: session:orderChanged\ndata: {"order":["a1","a2"]}\n\n', + ]); + cleanup.dispose(); + }); + + it('clears a queued owner order when a backpressured client disconnects', () => { + const cleanup = new CleanupManager(); + const manager = new SseStreamManager({ getSessionStateWithRespawn: () => null }, cleanup); + const alice = backpressuredClient(); + manager.addClient(alice.reply, null, false, undefined, { username: 'alice', role: 'user' }); + manager.broadcast('test:prime-backpressure', {}, { username: 'alice' }); + alice.writes.length = 0; + manager.broadcastSessionOrder(changeWith({ alice: ['a2', 'a1'] }, ['a2', 'a1'], true)); + + manager.removeClient(alice.reply); + alice.drain(); + + expect(alice.writes).toEqual([]); + cleanup.dispose(); + }); + + it('isolates a drain write failure from later healthy recipients', () => { + const cleanup = new CleanupManager(); + const manager = new SseStreamManager({ getSessionStateWithRespawn: () => null }, cleanup); + const brokenAlice = backpressuredClient({ throwAfterBackpressure: true }); + const liveAlice = client(); + const admin = client(); + manager.addClient(brokenAlice.reply, null, false, undefined, { username: 'alice', role: 'user' }); + manager.addClient(liveAlice.reply, null, false, undefined, { username: 'alice', role: 'user' }); + manager.addClient(admin.reply, null, false, undefined, { username: 'root', role: 'admin' }); + manager.broadcast('test:prime-backpressure', {}, { username: 'alice' }); + brokenAlice.writes.length = 0; + liveAlice.writes.length = 0; + admin.writes.length = 0; + manager.broadcastSessionOrder(changeWith({ alice: ['a2', 'a1'] }, ['a2', 'a1'], true)); + + expect(() => brokenAlice.drain()).not.toThrow(); + expect(manager.clientCount).toBe(2); + + manager.broadcastSessionOrder(changeWith({ alice: ['a1', 'a2'] }, ['a1', 'a2'], true)); + expect(liveAlice.writes.at(-1)).toBe('event: session:orderChanged\ndata: {"order":["a1","a2"]}\n\n'); + expect(admin.writes.at(-1)).toBe('event: session:orderChanged\ndata: {"order":["a1","a2"]}\n\n'); + cleanup.dispose(); + }); +}); diff --git a/test/state-store-tab-layout.test.ts b/test/state-store-tab-layout.test.ts new file mode 100644 index 00000000..2b5b5562 --- /dev/null +++ b/test/state-store-tab-layout.test.ts @@ -0,0 +1,134 @@ +/** + * @fileoverview Atomic StateStore publication tests for tab layouts and their legacy session-order projection. + */ + +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { StateStore } from '../src/state-store.js'; +import { recomposeGlobalSessionOrder } from '../src/tab-layout-legacy-order.js'; +import type { TabLayout } from '../src/tab-layout.js'; + +const tempDirs: string[] = []; + +afterEach(() => { + vi.restoreAllMocks(); + for (const dir of tempDirs.splice(0)) rmSync(dir, { recursive: true, force: true }); +}); + +function createStore(seed: { sessionOrder?: string[]; tabLayouts?: Record } = {}): StateStore { + const dir = mkdtempSync(join(tmpdir(), 'codeman-state-tab-layout-')); + tempDirs.push(dir); + const file = join(dir, 'state.json'); + writeFileSync(file, JSON.stringify(seed)); + return new StateStore(file); +} + +const ownerLayout = (ids: readonly string[], version = 1): TabLayout => ({ + version, + groups: [], + ungrouped: ids.map((id) => ({ kind: 'session', id })), + updatedAt: `2026-08-23T12:00:0${version}.000Z`, +}); + +describe('StateStore.commitTabLayoutProjection', () => { + it('returns a defensive snapshot of all stored owner layouts for trusted owner discovery', () => { + const alice = ownerLayout(['a1'], 1); + const store = createStore({ tabLayouts: { alice, constructor: ownerLayout(['c1'], 2) } }); + + const layouts = store.getTabLayouts(); + + expect(Object.keys(layouts)).toEqual(['alice', 'constructor']); + layouts.alice.ungrouped[0].id = 'caller-mutated'; + expect(store.getTabLayout('alice')).toEqual(alice); + }); + + it('validates all layouts and publishes them with a latest-state projection using one save schedule', () => { + const originalAlice = ownerLayout(['a1'], 1); + const store = createStore({ sessionOrder: ['a1'], tabLayouts: { alice: originalAlice } }); + const save = vi.spyOn(store, 'save').mockImplementation(() => undefined); + const alice = ownerLayout(['a2', 'a1'], 2); + const constructorOwner = ownerLayout(['constructor'], 1); + const projected = ['a2', 'a1', 'constructor', 'a2']; + + const result = store.commitTabLayoutProjection({ alice, constructor: constructorOwner }, (latest) => { + expect(latest).toEqual(['a1']); + expect(store.getSessionOrder()).toEqual(['a1']); + expect(store.getTabLayout('alice')).toEqual(originalAlice); + (latest as string[]).push('projector-local-mutation'); + return projected; + }); + + expect(store.getTabLayout('alice')).toEqual(alice); + expect(store.getTabLayout('constructor')).toEqual(constructorOwner); + expect(store.getSessionOrder()).toEqual(['a2', 'a1', 'constructor']); + expect(result).toEqual({ + layouts: { alice, constructor: constructorOwner }, + sessionOrder: ['a2', 'a1', 'constructor'], + }); + expect(save).toHaveBeenCalledTimes(1); + + alice.ungrouped[0].id = 'caller-mutated'; + constructorOwner.ungrouped[0].id = 'caller-mutated'; + projected[0] = 'caller-mutated'; + result.layouts.alice.ungrouped[0].id = 'return-mutated'; + result.layouts.constructor.ungrouped[0].id = 'return-mutated'; + result.sessionOrder[0] = 'return-mutated'; + + expect(store.getTabLayout('alice')?.ungrouped[0].id).toBe('a2'); + expect(store.getTabLayout('constructor')?.ungrouped[0].id).toBe('constructor'); + expect(store.getSessionOrder()).toEqual(['a2', 'a1', 'constructor']); + }); + + it('changes neither representation and does not project or save when any layout is invalid', () => { + const original = ownerLayout(['a1'], 1); + const store = createStore({ sessionOrder: ['a1'], tabLayouts: { alice: original } }); + const save = vi.spyOn(store, 'save').mockImplementation(() => undefined); + const project = vi.fn(() => ['a2']); + const invalid = { ...ownerLayout(['b1'], 2), version: -1 }; + + expect(() => store.commitTabLayoutProjection({ bob: ownerLayout(['b1'], 2), alice: invalid }, project)).toThrow( + /version/ + ); + + expect(project).not.toHaveBeenCalled(); + expect(save).not.toHaveBeenCalled(); + expect(store.getTabLayout('alice')).toEqual(original); + expect(store.getTabLayout('bob')).toBeNull(); + expect(store.getSessionOrder()).toEqual(['a1']); + }); + + it('changes neither representation and does not save when projection throws', () => { + const original = ownerLayout(['a1'], 1); + const store = createStore({ sessionOrder: ['a1'], tabLayouts: { alice: original } }); + const save = vi.spyOn(store, 'save').mockImplementation(() => undefined); + + expect(() => + store.commitTabLayoutProjection({ alice: ownerLayout(['a2'], 2) }, () => { + throw new Error('projection rejected'); + }) + ).toThrow('projection rejected'); + + expect(save).not.toHaveBeenCalled(); + expect(store.getTabLayout('alice')).toEqual(original); + expect(store.getSessionOrder()).toEqual(['a1']); + }); + + it('derives sequential owner projections from the latest committed order', () => { + const store = createStore({ sessionOrder: ['a1', 'b1', 'a2', 'b2'] }); + const save = vi.spyOn(store, 'save').mockImplementation(() => undefined); + + store.commitTabLayoutProjection({ alice: ownerLayout(['a2', 'a1'], 2) }, (latest) => + recomposeGlobalSessionOrder(latest, [{ owner: 'alice', ownedIds: ['a1', 'a2'], order: ['a2', 'a1'] }]) + ); + store.commitTabLayoutProjection({ bob: ownerLayout(['b2', 'b1'], 2) }, (latest) => + recomposeGlobalSessionOrder(latest, [{ owner: 'bob', ownedIds: ['b1', 'b2'], order: ['b2', 'b1'] }]) + ); + + expect(store.getSessionOrder()).toEqual(['a2', 'b2', 'a1', 'b1']); + expect(store.getTabLayout('alice')).toEqual(ownerLayout(['a2', 'a1'], 2)); + expect(store.getTabLayout('bob')).toEqual(ownerLayout(['b2', 'b1'], 2)); + expect(save).toHaveBeenCalledTimes(2); + }); +}); diff --git a/test/state-store.test.ts b/test/state-store.test.ts index 56b522df..c351af3a 100644 --- a/test/state-store.test.ts +++ b/test/state-store.test.ts @@ -199,6 +199,20 @@ describe('StateStore', () => { expect(store.getSession('pinned-1')).not.toBeNull(); expect(store.getSession('plain-1')).toBeNull(); }); + + it('cleans only requested unpinned session ids', () => { + const store = new StateStore(testFilePath); + store.setSession('requested', createMockSessionState('requested')); + store.setSession('pinned', { ...createMockSessionState('pinned'), pinned: true, pinnedAt: Date.now() }); + store.setSession('unrequested', createMockSessionState('unrequested')); + + const result = store.cleanupSessionsByIds(new Set(['requested', 'pinned', 'missing'])); + + expect(result.cleaned.map((session) => session.id)).toEqual(['requested']); + expect(store.getSession('requested')).toBeNull(); + expect(store.getSession('pinned')).not.toBeNull(); + expect(store.getSession('unrequested')).not.toBeNull(); + }); }); describe('task operations', () => { diff --git a/test/tab-layout-legacy-order.test.ts b/test/tab-layout-legacy-order.test.ts new file mode 100644 index 00000000..f0fb5229 --- /dev/null +++ b/test/tab-layout-legacy-order.test.ts @@ -0,0 +1,180 @@ +/** + * @fileoverview Pure compatibility translation between legacy session order and owner tab layouts. + */ + +import { describe, expect, it } from 'vitest'; +import { + applyLegacySessionRank, + recomposeGlobalSessionOrder, + type OwnerOrderProjection, +} from '../src/tab-layout-legacy-order.js'; +import { normalizeTabLayout, validateTabLayout, type TabLayout, type TabRefMetadata } from '../src/tab-layout.js'; + +const session = (id: string, placement?: 'manual') => + placement ? { kind: 'session' as const, id, placement } : { kind: 'session' as const, id }; +const webview = (id: string) => ({ kind: 'webview' as const, id }); +const metadata = (id: string, overrides: Partial = {}): TabRefMetadata => ({ + kind: 'session', + id, + ownerValid: true, + visible: true, + order: 0, + ...overrides, +}); +const layout = (overrides: Partial = {}): TabLayout => ({ + version: 1, + groups: [], + ungrouped: [], + updatedAt: '2026-08-23T12:00:00.000Z', + ...overrides, +}); + +describe('applyLegacySessionRank', () => { + it('ranks only authoritative sessions while anchoring webviews and refs absent from metadata', () => { + const input = layout({ + ungrouped: [session('a'), webview('w1'), session('unknown'), session('b')], + }); + + expect(applyLegacySessionRank(input, ['b', 'a'], [metadata('a'), metadata('b')])).toEqual( + layout({ + ungrouped: [session('b'), webview('w1'), session('unknown'), session('a')], + }) + ); + }); + + it('ranks sessions only inside their current containers without changing groups or webview slots', () => { + const input = layout({ + groups: [ + { id: 'first', name: 'First', refs: [session('a'), webview('w1'), session('d')] }, + { id: 'second', name: 'Second', refs: [session('b'), webview('w2'), session('c')] }, + ], + ungrouped: [session('e'), webview('w3'), session('f')], + }); + const facts = ['a', 'b', 'c', 'd', 'e', 'f'].map((id, order) => metadata(id, { order })); + + const result = applyLegacySessionRank(input, ['f', 'c', 'd', 'e', 'b', 'a'], facts); + + expect(result.groups).toEqual([ + { id: 'first', name: 'First', refs: [session('d'), webview('w1'), session('a')] }, + { id: 'second', name: 'Second', refs: [session('c'), webview('w2'), session('b')] }, + ]); + expect(result.ungrouped).toEqual([session('f'), webview('w3'), session('e')]); + }); + + it('materializes ranked children with represented owner-valid parents before normalization', () => { + const input = layout({ + groups: [ + { + id: 'family', + name: 'Family', + refs: [session('parent'), session('child'), session('other')], + }, + ], + }); + const facts = [ + metadata('parent', { order: 0 }), + metadata('child', { order: 1, parentSessionId: 'parent' }), + metadata('other', { order: 2 }), + ]; + + const result = applyLegacySessionRank(input, ['child', 'other', 'parent'], facts); + + expect(result.groups[0].refs).toEqual([session('child', 'manual'), session('other'), session('parent')]); + expect(normalizeTabLayout(result, facts)).toEqual(result); + }); + + it('keeps a ranked child manual in its group when normalization materializes its missing parent', () => { + const input = layout({ + groups: [{ id: 'child-group', name: 'Child', refs: [session('child')] }], + }); + const facts = [metadata('parent', { order: 0 }), metadata('child', { order: 1, parentSessionId: 'parent' })]; + + const result = applyLegacySessionRank(input, ['child', 'parent'], facts); + + expect(result.groups[0].refs).toEqual([session('child', 'manual')]); + expect(result.ungrouped).toEqual([session('parent')]); + expect(normalizeTabLayout(result, facts)).toEqual(result); + }); + + it('does not mutate inputs and returns a validated canonical deep clone', () => { + const input = layout({ + groups: [{ id: 'g', name: ' Work ', refs: [session('a'), webview('w')] }], + ungrouped: [session('b')], + }); + const requested = ['b', 'a']; + const facts = [metadata('a', { order: 0 }), metadata('b', { order: 1 })]; + const beforeInput = structuredClone(input); + const beforeRequested = [...requested]; + const beforeFacts = structuredClone(facts); + + const result = applyLegacySessionRank(input, requested, facts); + + expect(input).toEqual(beforeInput); + expect(requested).toEqual(beforeRequested); + expect(facts).toEqual(beforeFacts); + expect(result.groups[0].name).toBe('Work'); + expect(validateTabLayout(result)).toEqual(result); + expect(result).not.toBe(input); + expect(result.groups[0]).not.toBe(input.groups[0]); + expect(result.groups[0].refs[0]).not.toBe(input.groups[0].refs[0]); + }); +}); + +describe('recomposeGlobalSessionOrder', () => { + it('replaces only owner slots, preserves interleaving, appends new IDs once, and deduplicates', () => { + const current = ['a1', 'foreign', 'b1', 'a2', 'unmapped', 'a1']; + const projections: OwnerOrderProjection[] = [ + { + owner: 'alice', + ownedIds: ['a1', 'a2', 'a3', 'a3'], + order: ['a3', 'a2', 'a2', 'a1', 'foreign'], + }, + ]; + const beforeCurrent = [...current]; + const beforeProjections = structuredClone(projections); + + const result = recomposeGlobalSessionOrder(current, projections); + + expect(result).toEqual(['a3', 'foreign', 'b1', 'a2', 'unmapped', 'a1']); + expect(current).toEqual(beforeCurrent); + expect(projections).toEqual(beforeProjections); + }); + + it('starts from merged preferred order when preserving admin cross-owner intent', () => { + expect( + recomposeGlobalSessionOrder( + ['a1', 'b1', 'a2', 'server-only'], + [{ owner: 'alice', ownedIds: ['a1', 'a2'], order: ['a2', 'a1'] }], + ['b1', 'a1', 'a2'] + ) + ).toEqual(['b1', 'a2', 'a1', 'server-only']); + }); + + it('derives sequential owner projections from the latest order without a lost update', () => { + const initial = ['a1', 'b1', 'a2', 'b2']; + const afterAlice = recomposeGlobalSessionOrder(initial, [ + { owner: 'alice', ownedIds: ['a1', 'a2'], order: ['a2', 'a1'] }, + ]); + const afterBob = recomposeGlobalSessionOrder(afterAlice, [ + { owner: 'bob', ownedIds: ['b1', 'b2'], order: ['b2', 'b1'] }, + ]); + + expect(afterAlice).toEqual(['a2', 'b1', 'a1', 'b2']); + expect(afterBob).toEqual(['a2', 'b2', 'a1', 'b1']); + }); + + it('handles prototype-like owner names and IDs without corrupting membership', () => { + expect( + recomposeGlobalSessionOrder( + ['__proto__', 'foreign', 'constructor'], + [ + { + owner: 'constructor', + ownedIds: ['__proto__', 'constructor', 'toString'], + order: ['toString', 'constructor', '__proto__'], + }, + ] + ) + ).toEqual(['toString', 'foreign', 'constructor', '__proto__']); + }); +}); diff --git a/test/tab-layout-persistence.test.ts b/test/tab-layout-persistence.test.ts new file mode 100644 index 00000000..f070e9ba --- /dev/null +++ b/test/tab-layout-persistence.test.ts @@ -0,0 +1,264 @@ +/** + * @fileoverview Owner-scoped tab-layout persistence and legacy migration tests. + */ + +import { mkdtempSync, readFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; +import { StateStore } from '../src/state-store.js'; +import { + SINGLE_USER_LAYOUT_OWNER, + normalizeOrMigrateOwnerTabLayout, + ownerLayoutKey, + type TabLayoutMigrationInput, +} from '../src/tab-layout-persistence.js'; +import { MAX_TAB_REFS, TabLayoutValidationError, type TabLayout } from '../src/tab-layout.js'; + +const tempDirs: string[] = []; + +afterEach(() => { + for (const dir of tempDirs.splice(0)) rmSync(dir, { recursive: true, force: true }); +}); + +const baseInput = (overrides: Partial = {}): TabLayoutMigrationInput => ({ + owner: SINGLE_USER_LAYOUT_OWNER, + layouts: {}, + sessionOrder: [], + persistedSessions: [], + liveSessions: [], + webviews: [], + updatedAt: '2026-08-16T12:00:00.000Z', + ...overrides, +}); + +describe('owner layout key', () => { + it('uses the reserved single-user key or authenticated username', () => { + expect(ownerLayoutKey()).toBe('@single'); + expect(ownerLayoutKey('alice')).toBe('alice'); + }); +}); + +describe('normalizeOrMigrateOwnerTabLayout', () => { + it('filters global sessionOrder by owner and appends remaining sessions deterministically', () => { + const result = normalizeOrMigrateOwnerTabLayout( + baseInput({ + owner: 'alice', + sessionOrder: ['foreign', 'late', 'ordered', 'missing'], + persistedSessions: [ + { id: 'foreign', owner: 'bob', createdAt: 1 }, + { id: 'late', owner: 'alice', createdAt: 30 }, + { id: 'ordered', owner: 'alice', createdAt: 20 }, + { id: 'tie-b', owner: 'alice', createdAt: 10 }, + { id: 'tie-a', owner: 'alice', createdAt: 10 }, + ], + }) + ); + + expect(result.created).toBe(true); + expect(result.layout.ungrouped).toEqual([ + { kind: 'session', id: 'late' }, + { kind: 'session', id: 'ordered' }, + { kind: 'session', id: 'tie-a' }, + { kind: 'session', id: 'tie-b' }, + ]); + }); + + it('includes persisted stopped/pinned sessions and de-duplicates live records', () => { + const result = normalizeOrMigrateOwnerTabLayout( + baseInput({ + persistedSessions: [ + { id: 'pinned', createdAt: 1 }, + { id: 'shared', createdAt: 5 }, + ], + liveSessions: [{ id: 'shared', createdAt: 2 }], + }) + ); + + expect(result.layout.ungrouped).toEqual([ + { kind: 'session', id: 'pinned' }, + { kind: 'session', id: 'shared' }, + ]); + }); + + it('treats an ownerless live record as authoritative single-user ownership', () => { + const result = normalizeOrMigrateOwnerTabLayout( + baseInput({ + persistedSessions: [{ id: 'moved', owner: 'alice', createdAt: 1 }], + liveSessions: [{ id: 'moved', createdAt: 2 }], + }) + ); + + expect(result.layout.ungrouped).toEqual([{ kind: 'session', id: 'moved' }]); + }); + + it('treats an absent live parentSessionId as an authoritative root', () => { + const result = normalizeOrMigrateOwnerTabLayout( + baseInput({ + sessionOrder: ['child', 'former-parent'], + persistedSessions: [ + { id: 'child', parentSessionId: 'former-parent', createdAt: 1 }, + { id: 'former-parent', createdAt: 2 }, + ], + liveSessions: [ + { id: 'child', createdAt: 1 }, + { id: 'former-parent', createdAt: 2 }, + ], + }) + ); + + expect(result.layout.ungrouped).toEqual([ + { kind: 'session', id: 'child' }, + { kind: 'session', id: 'former-parent' }, + ]); + }); + + it('marks migrated children manual when their parent is live and same-owner', () => { + const result = normalizeOrMigrateOwnerTabLayout( + baseInput({ + owner: 'alice', + sessionOrder: ['child', 'parent', 'foreign-child'], + persistedSessions: [ + { id: 'child', owner: 'alice', parentSessionId: 'parent', createdAt: 2 }, + { id: 'parent', owner: 'alice', createdAt: 1 }, + { id: 'foreign-child', owner: 'alice', parentSessionId: 'foreign-parent', createdAt: 3 }, + ], + liveSessions: [ + { id: 'parent', owner: 'alice', createdAt: 1 }, + { id: 'foreign-parent', owner: 'bob', createdAt: 1 }, + ], + }) + ); + + expect(result.layout.ungrouped).toEqual([ + { kind: 'session', id: 'child', placement: 'manual' }, + { kind: 'session', id: 'parent' }, + { kind: 'session', id: 'foreign-child' }, + ]); + }); + + it('appends owner webviews in server store order after sessions', () => { + const result = normalizeOrMigrateOwnerTabLayout( + baseInput({ + owner: 'alice', + persistedSessions: [{ id: 'session', owner: 'alice', createdAt: 1 }], + webviews: [ + { id: 'second', owner: 'alice' }, + { id: 'foreign', owner: 'bob' }, + { id: 'first', owner: 'alice' }, + ], + }) + ); + + expect(result.layout.ungrouped).toEqual([ + { kind: 'session', id: 'session' }, + { kind: 'webview', id: 'second' }, + { kind: 'webview', id: 'first' }, + ]); + }); + + it('normalizes an existing layout idempotently without pruning unknown refs', () => { + const existing: TabLayout = { + version: 7, + groups: [ + { + id: 'g', + name: ' Work ', + refs: [ + { kind: 'session', id: 'unknown' }, + { kind: 'session', id: 'foreign' }, + ], + }, + ], + ungrouped: [{ kind: 'session', id: 'known' }], + updatedAt: 'old', + }; + const input = baseInput({ + layouts: { '@single': existing }, + persistedSessions: [ + { id: 'known', createdAt: 1 }, + { id: 'foreign', owner: 'alice', createdAt: 2 }, + ], + }); + + const once = normalizeOrMigrateOwnerTabLayout(input); + const twice = normalizeOrMigrateOwnerTabLayout({ ...input, layouts: once.layouts }); + + expect(once.created).toBe(false); + expect(once.layout.groups).toEqual([{ id: 'g', name: 'Work', refs: [{ kind: 'session', id: 'unknown' }] }]); + expect(twice.layout).toEqual(once.layout); + }); + + it('migrates an owner named constructor instead of reading the inherited prototype key', () => { + const result = normalizeOrMigrateOwnerTabLayout( + baseInput({ + owner: 'constructor', + layouts: { + alice: { + version: 1, + groups: [], + ungrouped: [], + updatedAt: 'old', + }, + }, + }) + ); + + expect(result.created).toBe(true); + expect(result.layouts.constructor).toBeDefined(); + expect(result.layout.version).toBe(0); + }); + + it('accepts exactly 512 refs and rejects 513 atomically without truncation', () => { + const sessions = Array.from({ length: MAX_TAB_REFS }, (_, i) => ({ id: `s-${i}`, createdAt: i })); + const accepted = normalizeOrMigrateOwnerTabLayout(baseInput({ persistedSessions: sessions })); + expect(accepted.layout.ungrouped).toHaveLength(MAX_TAB_REFS); + + const layouts = { untouched: accepted.layout }; + expect(() => + normalizeOrMigrateOwnerTabLayout( + baseInput({ layouts, persistedSessions: [...sessions, { id: 'overflow', createdAt: MAX_TAB_REFS }] }) + ) + ).toThrow(TabLayoutValidationError); + expect(layouts).toEqual({ untouched: accepted.layout }); + }); +}); + +describe('StateStore tabLayouts allowlist', () => { + it('persists and reloads full owner layouts across a restart', () => { + const dir = mkdtempSync(join(tmpdir(), 'codeman-tab-layout-')); + tempDirs.push(dir); + const file = join(dir, 'state.json'); + const layout: TabLayout = { + version: 3, + groups: [{ id: 'g', name: 'Work', refs: [{ kind: 'session', id: 's', placement: 'manual' }] }], + ungrouped: [{ kind: 'webview', id: 'w' }], + updatedAt: '2026-08-16T12:00:00.000Z', + }; + + const store = new StateStore(file); + store.setTabLayout('alice', layout); + store.saveNow(); + + expect(JSON.parse(readFileSync(file, 'utf8')).tabLayouts).toEqual({ alice: layout }); + expect(new StateStore(file).getTabLayout('alice')).toEqual(layout); + }); + + it('does not treat an inherited constructor property as a persisted owner layout', () => { + const dir = mkdtempSync(join(tmpdir(), 'codeman-tab-layout-')); + tempDirs.push(dir); + const file = join(dir, 'state.json'); + const layout: TabLayout = { + version: 3, + groups: [], + ungrouped: [], + updatedAt: '2026-08-16T12:00:00.000Z', + }; + + const store = new StateStore(file); + store.setTabLayout('alice', layout); + store.saveNow(); + + expect(store.getTabLayout('constructor')).toBeNull(); + }); +}); diff --git a/test/tab-layout-restore-gate.test.ts b/test/tab-layout-restore-gate.test.ts new file mode 100644 index 00000000..117e29e3 --- /dev/null +++ b/test/tab-layout-restore-gate.test.ts @@ -0,0 +1,62 @@ +/** @fileoverview Startup restoration must gate destructive tab-layout reconciliation. */ +import { describe, expect, it, vi } from 'vitest'; +import { WebServer } from '../src/web/server.js'; + +describe('tab layout restore gate', () => { + it('does not unlock pruning, cleanup stale state, or reconcile after a failed restore', async () => { + const markRestorationComplete = vi.fn(); + const markRestorationFailed = vi.fn(); + const reconcileAfterRestoration = vi.fn(async () => {}); + const cleanupStaleSessions = vi.fn(async () => 0); + const server = Object.create(WebServer.prototype) as { + tabLayouts: { + markRestorationComplete: typeof markRestorationComplete; + markRestorationFailed: typeof markRestorationFailed; + reconcileAfterRestoration: typeof reconcileAfterRestoration; + }; + cleanupStaleSessions: typeof cleanupStaleSessions; + mux: { reconcileSessions(): Promise }; + restoreMuxSessions(): Promise; + finalizeRestoredState(restored: boolean): Promise; + }; + server.tabLayouts = { markRestorationComplete, markRestorationFailed, reconcileAfterRestoration }; + server.cleanupStaleSessions = cleanupStaleSessions; + server.mux = { reconcileSessions: vi.fn(async () => Promise.reject(new Error('mux unavailable'))) }; + const errorLog = vi.spyOn(console, 'error').mockImplementation(() => {}); + + const restored = await server.restoreMuxSessions(); + await server.finalizeRestoredState(restored); + + expect(restored).toBe(false); + expect(markRestorationComplete).not.toHaveBeenCalled(); + expect(markRestorationFailed).toHaveBeenCalledTimes(1); + expect(cleanupStaleSessions).not.toHaveBeenCalled(); + expect(reconcileAfterRestoration).not.toHaveBeenCalled(); + expect(errorLog).toHaveBeenCalledWith('[Server] Failed to restore mux sessions:', expect.any(Error)); + }); + + it('unlocks cleanup and reconciliation only after successful restoration', async () => { + const order: string[] = []; + const server = Object.create(WebServer.prototype) as { + tabLayouts: { + markRestorationComplete(): void; + markRestorationFailed(): void; + reconcileAfterRestoration(): Promise; + }; + cleanupStaleSessions(): Promise; + finalizeRestoredState(restored: boolean): Promise; + }; + server.tabLayouts = { + markRestorationComplete: () => order.push('complete'), + markRestorationFailed: () => order.push('failed'), + reconcileAfterRestoration: async () => { + order.push('reconcile'); + }, + }; + server.cleanupStaleSessions = async () => (order.push('cleanup'), 0); + + await server.finalizeRestoredState(true); + + expect(order).toEqual(['complete', 'cleanup', 'reconcile']); + }); +}); diff --git a/test/tab-layout-service.test.ts b/test/tab-layout-service.test.ts new file mode 100644 index 00000000..753e2517 --- /dev/null +++ b/test/tab-layout-service.test.ts @@ -0,0 +1,1213 @@ +/** + * @fileoverview Tab-layout server coordinator lifecycle, ownership, and version tests. + */ +import { describe, expect, it, vi } from 'vitest'; +import { TabLayoutService } from '../src/tab-layout-service.js'; +import { SseEvent } from '../src/web/sse-events.js'; +import type { TabLayout } from '../src/tab-layout.js'; + +type SessionFact = { id: string; owner?: string; createdAt: number; parentSessionId?: string; pinned?: boolean }; + +function createHarness( + options: { + layouts?: Record; + order?: string[]; + persisted?: SessionFact[]; + live?: SessionFact[]; + webviews?: Array<{ id: string; owner?: string }>; + now?: () => string; + } = {} +) { + const layouts = { ...(options.layouts ?? {}) }; + const persisted = Object.fromEntries((options.persisted ?? []).map((session) => [session.id, session])); + const live = new Map((options.live ?? []).map((session) => [session.id, session])); + const order = [...(options.order ?? [])]; + const readWebviews = vi.fn(async () => options.webviews ?? []); + const store = { + getTabLayout: vi.fn((owner: string) => layouts[owner] ?? null), + getTabLayouts: vi.fn(() => ({ ...layouts })), + getSessions: vi.fn(() => persisted), + getSessionOrder: vi.fn(() => [...order]), + commitTabLayoutProjection: vi.fn( + (updates: Readonly>, project: (latest: readonly string[]) => readonly string[]) => { + const projected = [...project([...order])]; + Object.assign(layouts, structuredClone(updates)); + order.splice(0, order.length, ...projected); + return { layouts: structuredClone(updates), sessionOrder: [...order] }; + } + ), + }; + const broadcast = vi.fn(); + const broadcastSessionOrder = vi.fn(); + const service = new TabLayoutService({ + store: store as never, + sessions: live as never, + readWebviews, + broadcast, + broadcastSessionOrder, + now: options.now ?? (() => '2026-08-16T12:00:00.000Z'), + }); + return { service, store, broadcast, broadcastSessionOrder, layouts, order, live, persisted, readWebviews }; +} + +const existing = (refs: TabLayout['ungrouped'], version = 7): TabLayout => ({ + version, + groups: [], + ungrouped: refs, + updatedAt: '2026-08-15T00:00:00.000Z', +}); + +describe('TabLayoutService', () => { + it('guards destructive transactions by restoration state while allowing skipped test mode', async () => { + const h = createHarness({ layouts: { '@single': existing([{ kind: 'session', id: 'mine' }]) } }); + const action = vi.fn(async () => 'removed'); + + await expect(h.service.runSessionDeletion([{ id: 'mine' }], action)).rejects.toThrow(/restoration.*pending/i); + expect(action).not.toHaveBeenCalled(); + + h.service.markRestorationFailed(); + await expect(h.service.runSessionDeletion([{ id: 'mine' }], action)).rejects.toThrow(/restoration.*failed/i); + expect(action).not.toHaveBeenCalled(); + + h.service.markRestorationSkipped(); + await expect(h.service.runSessionDeletion([{ id: 'mine' }], action)).resolves.toBe('removed'); + expect(action).toHaveBeenCalledTimes(1); + expect(h.store.commitTabLayoutProjection).not.toHaveBeenCalled(); + expect(h.broadcastSessionOrder).not.toHaveBeenCalled(); + }); + + it('prepares and commits a complete-state session deletion around the resource action', async () => { + const h = createHarness({ + layouts: { alice: existing([{ kind: 'session', id: 'mine' }]) }, + live: [{ id: 'mine', owner: 'alice', createdAt: 1 }], + }); + h.service.markRestorationComplete(); + const action = vi.fn(async () => { + expect(h.store.commitTabLayoutProjection).not.toHaveBeenCalled(); + h.live.delete('mine'); + return 'removed'; + }); + + await expect(h.service.runSessionDeletion([{ id: 'mine', owner: 'alice' }], action)).resolves.toBe('removed'); + + expect(h.layouts.alice.ungrouped).toEqual([]); + expect(h.layouts.alice.version).toBe(8); + }); + + it('leaves stale records, layouts, versions, and events intact when bulk preparation fails', async () => { + const h = createHarness({ + layouts: { alice: existing([{ kind: 'session', id: 'stale' }]) }, + persisted: [{ id: 'stale', owner: 'alice', createdAt: 1 }], + }); + h.service.markRestorationComplete(); + const cleanup = vi.fn(() => { + delete h.persisted.stale; + return { count: 1, cleaned: [{ id: 'stale', owner: 'alice' }] }; + }); + h.store.getSessions + .mockImplementationOnce(() => h.persisted) + .mockImplementationOnce(() => { + throw new Error('malformed persisted state'); + }); + + await expect(h.service.runStaleSessionCleanup(new Set(), cleanup)).rejects.toThrow('malformed persisted state'); + + expect(cleanup).not.toHaveBeenCalled(); + expect(h.persisted.stale).toBeDefined(); + expect(h.layouts.alice).toEqual(existing([{ kind: 'session', id: 'stale' }])); + expect(h.store.commitTabLayoutProjection).not.toHaveBeenCalled(); + expect(h.broadcast).not.toHaveBeenCalled(); + expect(h.broadcastSessionOrder).not.toHaveBeenCalled(); + }); + + it('bulk-cleans multiple owners once each while preserving pinned stale records', async () => { + const h = createHarness({ + layouts: { + alice: existing([ + { kind: 'session', id: 'alice-stale' }, + { kind: 'session', id: 'alice-pinned' }, + ]), + bob: existing([{ kind: 'session', id: 'bob-stale' }]), + }, + persisted: [ + { id: 'bob-stale', owner: 'bob', createdAt: 1 }, + { id: 'alice-stale', owner: 'alice', createdAt: 2 }, + { id: 'alice-pinned', owner: 'alice', createdAt: 3, pinned: true }, + ], + }); + h.service.markRestorationComplete(); + const cleanup = vi.fn(() => { + delete h.persisted['alice-stale']; + delete h.persisted['bob-stale']; + return { count: 2 }; + }); + + await expect(h.service.runStaleSessionCleanup(new Set(), cleanup)).resolves.toEqual({ count: 2 }); + + expect(h.layouts.alice.ungrouped).toEqual([{ kind: 'session', id: 'alice-pinned' }]); + expect(h.layouts.bob.ungrouped).toEqual([]); + expect(h.layouts.alice.version).toBe(8); + expect(h.layouts.bob.version).toBe(8); + expect(h.store.commitTabLayoutProjection).toHaveBeenCalledTimes(1); + expect(Object.keys(h.store.commitTabLayoutProjection.mock.calls[0][0])).toEqual(['alice', 'bob']); + expect(h.broadcast).toHaveBeenCalledTimes(2); + }); + + it('does not commit a prepared stale layout when asynchronous cleanup rejects', async () => { + const h = createHarness({ + layouts: { alice: existing([{ kind: 'session', id: 'alice-stale' }]) }, + persisted: [{ id: 'alice-stale', owner: 'alice', createdAt: 1 }], + }); + h.service.markRestorationComplete(); + const cleanup = vi.fn(async () => { + throw new Error('state removal failed'); + }); + + await expect(h.service.runStaleSessionCleanup(new Set(), cleanup)).rejects.toThrow('state removal failed'); + + expect(h.layouts.alice).toEqual(existing([{ kind: 'session', id: 'alice-stale' }])); + expect(h.store.commitTabLayoutProjection).not.toHaveBeenCalled(); + expect(h.broadcast).not.toHaveBeenCalled(); + }); + + it('binds stale cleanup to revalidated original candidates after an owner queue wait', async () => { + const h = createHarness({ + layouts: { + alice: existing([{ kind: 'session', id: 'alice-stale' }]), + bob: existing([{ kind: 'session', id: 'bob-new-stale' }]), + }, + order: ['alice-stale'], + persisted: [{ id: 'alice-stale', owner: 'alice', createdAt: 1 }], + }); + h.service.markRestorationComplete(); + let releaseFacts!: () => void; + const factsBlocked = new Promise((resolve) => { + releaseFacts = resolve; + }); + h.readWebviews.mockImplementationOnce(async () => { + await factsBlocked; + return []; + }); + + const occupiedOwnerQueue = h.service.get('alice'); + await vi.waitFor(() => expect(h.readWebviews).toHaveBeenCalledOnce()); + const cleanup = vi.fn((ids: ReadonlySet) => ({ ids: [...ids] })); + const pending = h.service.runStaleSessionCleanup(new Set(), cleanup); + + h.persisted['alice-stale'].pinned = true; + h.persisted['bob-new-stale'] = { id: 'bob-new-stale', owner: 'bob', createdAt: 2 }; + releaseFacts(); + + await occupiedOwnerQueue; + await expect(pending).resolves.toEqual({ ids: [] }); + expect(cleanup).toHaveBeenCalledTimes(1); + expect(cleanup).toHaveBeenCalledWith(new Set()); + expect(h.persisted['alice-stale']).toBeDefined(); + expect(h.persisted['bob-new-stale']).toBeDefined(); + expect(h.layouts.alice.ungrouped).toEqual([{ kind: 'session', id: 'alice-stale' }]); + expect(h.layouts.bob.ungrouped).toEqual([{ kind: 'session', id: 'bob-new-stale' }]); + expect(h.store.commitTabLayoutProjection).not.toHaveBeenCalled(); + }); + + it('allows skipped-mode stale cleanup without pruning or versioning layouts', async () => { + const h = createHarness({ + layouts: { '@single': existing([{ kind: 'session', id: 'stale' }]) }, + persisted: [{ id: 'stale', createdAt: 1 }], + }); + h.service.markRestorationSkipped(); + const cleanup = vi.fn(() => ({ count: 1 })); + + await expect(h.service.runStaleSessionCleanup(new Set(), cleanup)).resolves.toEqual({ count: 1 }); + + expect(cleanup).toHaveBeenCalledOnce(); + expect(h.store.commitTabLayoutProjection).not.toHaveBeenCalled(); + expect(h.broadcast).not.toHaveBeenCalled(); + }); + + it('repairs missing owner refs once while preserving unknown refs', async () => { + const h = createHarness({ + layouts: { alice: existing([{ kind: 'session', id: 'unknown' }]) }, + live: [{ id: 'mine', owner: 'alice', createdAt: 1 }], + }); + const first = await h.service.get('alice'); + expect(first.ungrouped).toEqual([ + { kind: 'session', id: 'unknown' }, + { kind: 'session', id: 'mine' }, + ]); + expect(first.version).toBe(8); + await h.service.get('alice'); + expect(h.store.commitTabLayoutProjection).toHaveBeenCalledTimes(1); + expect(h.broadcast).toHaveBeenCalledWith(SseEvent.TabLayoutChanged, { owner: 'alice', version: 8 }); + }); + + it('repairs only the legacy projection on GET when the stored layout is already canonical', async () => { + const h = createHarness({ + layouts: { + alice: existing([ + { kind: 'session', id: 'a' }, + { kind: 'session', id: 'b' }, + ]), + }, + order: ['b', 'a'], + live: [ + { id: 'a', owner: 'alice', createdAt: 1 }, + { id: 'b', owner: 'alice', createdAt: 2 }, + ], + }); + + const first = await h.service.get('alice'); + const second = await h.service.get('alice'); + + expect(first).toEqual( + existing([ + { kind: 'session', id: 'a' }, + { kind: 'session', id: 'b' }, + ]) + ); + expect(second).toEqual(first); + expect(h.order).toEqual(['a', 'b']); + expect(h.layouts.alice.version).toBe(7); + expect(h.store.commitTabLayoutProjection).toHaveBeenCalledTimes(1); + expect(h.store.commitTabLayoutProjection.mock.calls[0][0]).toEqual({}); + expect(h.broadcast).not.toHaveBeenCalled(); + expect(h.broadcastSessionOrder).toHaveBeenCalledExactlyOnceWith({ + changedOwnerOrders: { alice: ['a', 'b'] }, + globalOrder: ['a', 'b'], + globalChanged: true, + }); + expect(h.broadcastSessionOrder).toHaveBeenCalledExactlyOnceWith({ + changedOwnerOrders: { alice: ['a', 'b'] }, + globalOrder: ['a', 'b'], + globalChanged: true, + }); + }); + + it('isolates owner metadata and filters saved webviews by exact owner', async () => { + const h = createHarness({ + live: [ + { id: 'a', owner: 'alice', createdAt: 1 }, + { id: 'b', owner: 'bob', createdAt: 2 }, + ], + webviews: [ + { id: 'wa', owner: 'alice' }, + { id: 'wb', owner: 'bob' }, + ], + }); + expect((await h.service.get('alice')).ungrouped.map((ref) => ref.id)).toEqual(['a', 'wa']); + expect((await h.service.get('bob')).ungrouped.map((ref) => ref.id)).toEqual(['b', 'wb']); + }); + + it('rejects foreign and unknown refs atomically even for an admin-owned layout', async () => { + const h = createHarness({ + layouts: { admin: existing([{ kind: 'session', id: 'mine' }]) }, + live: [ + { id: 'mine', owner: 'admin', createdAt: 1 }, + { id: 'foreign', owner: 'bob', createdAt: 2 }, + ], + }); + const desired = existing([{ kind: 'session', id: 'foreign' }]); + await expect(h.service.put('admin', desired, 7)).rejects.toThrow(/not owned/); + expect(h.store.commitTabLayoutProjection).not.toHaveBeenCalled(); + }); + + it.each([6, 8])('conflicts on every non-exact base version (%s)', async (baseVersion) => { + const h = createHarness({ + layouts: { alice: existing([{ kind: 'session', id: 'mine' }]) }, + live: [{ id: 'mine', owner: 'alice', createdAt: 1 }], + }); + expect(await h.service.put('alice', existing([{ kind: 'session', id: 'mine' }]), baseVersion)).toEqual({ + status: 'conflict', + layout: existing([{ kind: 'session', id: 'mine' }]), + }); + expect(h.store.commitTabLayoutProjection).not.toHaveBeenCalled(); + expect(h.broadcastSessionOrder).not.toHaveBeenCalled(); + }); + + it('returns an unpersisted prepared layout for a stale first write without broadcasting', async () => { + const h = createHarness({ + live: [{ id: 'mine', owner: 'alice', createdAt: 1 }], + }); + + await expect(h.service.put('alice', existing([{ kind: 'session', id: 'mine' }]), 7)).resolves.toEqual({ + status: 'conflict', + layout: { + version: 0, + groups: [], + ungrouped: [{ kind: 'session', id: 'mine' }], + updatedAt: '2026-08-16T12:00:00.000Z', + }, + }); + expect(h.layouts.alice).toBeUndefined(); + expect(h.store.commitTabLayoutProjection).not.toHaveBeenCalled(); + expect(h.broadcast).not.toHaveBeenCalled(); + expect(h.broadcastSessionOrder).not.toHaveBeenCalled(); + }); + + it('returns a normalized prepared layout for a stale write without persisting the reconciliation', async () => { + const h = createHarness({ + layouts: { alice: existing([{ kind: 'session', id: 'unknown' }]) }, + live: [{ id: 'mine', owner: 'alice', createdAt: 1 }], + }); + + await expect(h.service.put('alice', existing([{ kind: 'session', id: 'mine' }]), 6)).resolves.toEqual({ + status: 'conflict', + layout: existing([ + { kind: 'session', id: 'unknown' }, + { kind: 'session', id: 'mine' }, + ]), + }); + expect(h.layouts.alice).toEqual(existing([{ kind: 'session', id: 'unknown' }])); + expect(h.store.commitTabLayoutProjection).not.toHaveBeenCalled(); + expect(h.broadcast).not.toHaveBeenCalled(); + expect(h.broadcastSessionOrder).not.toHaveBeenCalled(); + }); + + it('folds first-write initialization and the requested update into one commit', async () => { + const h = createHarness({ + live: [{ id: 'mine', owner: 'alice', createdAt: 1 }], + }); + const desired = { + ...existing([]), + groups: [{ id: 'g', name: 'Mine', refs: [{ kind: 'session', id: 'mine' }] }], + }; + + const result = await h.service.put('alice', desired, 0); + + expect(result).toEqual({ + status: 'updated', + layout: { + version: 1, + groups: [{ id: 'g', name: 'Mine', refs: [{ kind: 'session', id: 'mine' }] }], + ungrouped: [], + updatedAt: '2026-08-16T12:00:00.000Z', + }, + }); + expect(h.store.commitTabLayoutProjection).toHaveBeenCalledTimes(1); + expect(h.store.commitTabLayoutProjection.mock.calls[0][0]).toEqual({ alice: result.layout }); + expect(h.broadcast).toHaveBeenCalledExactlyOnceWith(SseEvent.TabLayoutChanged, { owner: 'alice', version: 1 }); + }); + + it('uses the prepared authoritative ordering for a matching folded first write with tied session timestamps', async () => { + const h = createHarness({ + live: [ + { id: 'a', owner: 'alice', createdAt: 1 }, + { id: 'B', owner: 'alice', createdAt: 1 }, + ], + }); + + const result = await h.service.put('alice', existing([]), 0); + + expect(result).toEqual({ + status: 'updated', + layout: { + version: 1, + groups: [], + ungrouped: [ + { kind: 'session', id: 'B' }, + { kind: 'session', id: 'a' }, + ], + updatedAt: '2026-08-16T12:00:00.000Z', + }, + }); + }); + + it('folds normalization and a matching requested update into one commit', async () => { + const h = createHarness({ + layouts: { alice: existing([{ kind: 'session', id: 'unknown' }]) }, + live: [{ id: 'mine', owner: 'alice', createdAt: 1 }], + }); + const desired = { + ...existing([]), + groups: [{ id: 'g', name: 'Mine', refs: [{ kind: 'session', id: 'mine' }] }], + }; + + const result = await h.service.put('alice', desired, 7); + + expect(result).toEqual({ + status: 'updated', + layout: { + version: 8, + groups: [{ id: 'g', name: 'Mine', refs: [{ kind: 'session', id: 'mine' }] }], + ungrouped: [], + updatedAt: '2026-08-16T12:00:00.000Z', + }, + }); + expect(h.store.commitTabLayoutProjection).toHaveBeenCalledTimes(1); + expect(h.store.commitTabLayoutProjection.mock.calls[0][0]).toEqual({ alice: result.layout }); + expect(h.broadcast).toHaveBeenCalledExactlyOnceWith(SseEvent.TabLayoutChanged, { owner: 'alice', version: 8 }); + }); + + it('stores a matching write with one fresh version and minimal event payload', async () => { + const h = createHarness({ + layouts: { alice: existing([{ kind: 'session', id: 'mine' }]) }, + live: [{ id: 'mine', owner: 'alice', createdAt: 1 }], + }); + const result = await h.service.put( + 'alice', + { ...existing([{ kind: 'session', id: 'mine' }]), groups: [{ id: 'g', name: ' Group ', refs: [] }] }, + 7 + ); + expect(result.status).toBe('updated'); + expect(result.layout).toMatchObject({ version: 8, updatedAt: '2026-08-16T12:00:00.000Z' }); + expect(result.layout.groups[0].name).toBe('Group'); + expect(h.broadcast).toHaveBeenLastCalledWith(SseEvent.TabLayoutChanged, { owner: 'alice', version: 8 }); + }); + + it('serializes concurrent writes so only one exact base version can win', async () => { + const h = createHarness({ + layouts: { alice: existing([{ kind: 'session', id: 'mine' }]) }, + live: [{ id: 'mine', owner: 'alice', createdAt: 1 }], + }); + const desired = existing([{ kind: 'session', id: 'mine' }]); + const results = await Promise.all([h.service.put('alice', desired, 7), h.service.put('alice', desired, 7)]); + expect(results.map((result) => result.status).sort()).toEqual(['conflict', 'updated']); + expect(h.store.commitTabLayoutProjection).toHaveBeenCalledTimes(1); + expect(h.layouts.alice.version).toBe(8); + }); + + it('places a root at the end and a child after its parent subtree', async () => { + const h = createHarness({ + layouts: { + alice: existing([ + { kind: 'session', id: 'parent' }, + { kind: 'session', id: 'tail' }, + ]), + }, + live: [ + { id: 'parent', owner: 'alice', createdAt: 1 }, + { id: 'tail', owner: 'alice', createdAt: 2 }, + ], + }); + h.live.set('root', { id: 'root', owner: 'alice', createdAt: 3 } as never); + await h.service.sessionCreated('alice'); + expect(h.layouts.alice.ungrouped.map((ref) => ref.id)).toEqual(['parent', 'tail', 'root']); + h.live.set('child', { id: 'child', owner: 'alice', createdAt: 4, parentSessionId: 'parent' } as never); + await h.service.sessionCreated('alice'); + expect(h.layouts.alice.ungrouped.map((ref) => ref.id)).toEqual(['parent', 'child', 'tail', 'root']); + expect(h.layouts.alice.version).toBe(9); + }); + + it('appends a newly saved owner webview and rejects over-limit writes atomically', async () => { + const webviews: Array<{ id: string; owner?: string }> = []; + const h = createHarness({ + layouts: { alice: existing([{ kind: 'session', id: 'mine' }]) }, + live: [{ id: 'mine', owner: 'alice', createdAt: 1 }], + webviews, + }); + webviews.push({ id: 'dashboard', owner: 'alice' }); + await h.service.webviewCreated('alice'); + expect(h.layouts.alice.ungrouped.at(-1)).toEqual({ kind: 'webview', id: 'dashboard' }); + + const tooMany = Array.from({ length: 513 }, (_, index) => ({ kind: 'session' as const, id: `s-${index}` })); + await expect(h.service.put('alice', existing(tooMany, 8), 8)).rejects.toThrow(/512/); + expect(h.layouts.alice.version).toBe(8); + }); + + it('materializes children and removes an explicitly deleted parent in one mutation', async () => { + const h = createHarness({ + layouts: { + alice: existing([ + { kind: 'session', id: 'parent' }, + { kind: 'session', id: 'child' }, + ]), + }, + live: [{ id: 'child', owner: 'alice', createdAt: 2, parentSessionId: 'parent' }], + }); + h.service.markRestorationComplete(); + await h.service.sessionsRemoved([{ id: 'parent', owner: 'alice' }]); + expect(h.layouts.alice.ungrouped).toEqual([{ kind: 'session', id: 'child', placement: 'manual' }]); + expect(h.layouts.alice.version).toBe(8); + expect(h.store.commitTabLayoutProjection).toHaveBeenCalledTimes(1); + }); + + it('publishes multi-owner post-removal repairs in one combined transaction', async () => { + const h = createHarness({ + layouts: { + alice: existing([{ kind: 'session', id: 'a-stale' }]), + bob: existing([{ kind: 'session', id: 'b-stale' }]), + }, + order: ['a-stale', 'b-stale'], + }); + h.service.markRestorationComplete(); + + await h.service.sessionsRemoved([ + { id: 'a-stale', owner: 'alice' }, + { id: 'b-stale', owner: 'bob' }, + ]); + + expect(h.store.commitTabLayoutProjection).toHaveBeenCalledTimes(1); + expect(Object.keys(h.store.commitTabLayoutProjection.mock.calls[0][0])).toEqual(['alice', 'bob']); + expect(h.layouts.alice.ungrouped).toEqual([]); + expect(h.layouts.bob.ungrouped).toEqual([]); + expect(h.order).toEqual([]); + }); + + it('does not publish any owner when later multi-owner post-removal preparation fails', async () => { + const h = createHarness({ + layouts: { + alice: existing([{ kind: 'session', id: 'a-stale' }]), + bob: existing([{ kind: 'session', id: 'b-stale' }]), + }, + order: ['a-stale', 'b-stale'], + }); + h.service.markRestorationComplete(); + h.readWebviews.mockResolvedValueOnce([]).mockRejectedValueOnce(new Error('bob facts failed')); + + await expect( + h.service.sessionsRemoved([ + { id: 'a-stale', owner: 'alice' }, + { id: 'b-stale', owner: 'bob' }, + ]) + ).rejects.toThrow('bob facts failed'); + + expect(h.store.commitTabLayoutProjection).not.toHaveBeenCalled(); + expect(h.layouts.alice.ungrouped.map((ref) => ref.id)).toEqual(['a-stale']); + expect(h.layouts.bob.ungrouped.map((ref) => ref.id)).toEqual(['b-stale']); + }); + + it('prepares deletion version metadata before running the irreversible action', async () => { + const action = vi.fn(async () => 'removed'); + const h = createHarness({ + layouts: { alice: existing([{ kind: 'session', id: 'mine' }]) }, + live: [{ id: 'mine', owner: 'alice', createdAt: 1 }], + now: () => { + throw new Error('clock failed'); + }, + }); + h.service.markRestorationComplete(); + + await expect(h.service.runSessionDeletion([{ id: 'mine', owner: 'alice' }], action)).rejects.toThrow( + 'clock failed' + ); + + expect(action).not.toHaveBeenCalled(); + expect(h.store.commitTabLayoutProjection).not.toHaveBeenCalled(); + }); + + it('removes a legacy-only ID when deleting before owner layout migration', async () => { + const h = createHarness({ + order: ['mine'], + live: [{ id: 'mine', owner: 'alice', createdAt: 1 }], + }); + h.service.markRestorationComplete(); + const action = vi.fn(async () => { + h.live.delete('mine'); + return 'removed'; + }); + + await expect(h.service.runSessionDeletion([{ id: 'mine', owner: 'alice' }], action)).resolves.toBe('removed'); + + expect(h.layouts.alice).toBeUndefined(); + expect(h.order).toEqual([]); + expect(h.store.commitTabLayoutProjection).toHaveBeenCalledExactlyOnceWith({}, expect.any(Function)); + expect(h.broadcast).not.toHaveBeenCalled(); + expect(h.broadcastSessionOrder).toHaveBeenCalledExactlyOnceWith({ + changedOwnerOrders: { alice: [] }, + globalOrder: [], + globalChanged: true, + }); + }); + + it('removes a deleted legacy ID absent from an existing layout without bumping its version', async () => { + const h = createHarness({ + layouts: { alice: existing([{ kind: 'session', id: 'keep' }]) }, + order: ['gone', 'keep'], + live: [ + { id: 'gone', owner: 'alice', createdAt: 1 }, + { id: 'keep', owner: 'alice', createdAt: 2 }, + ], + }); + h.service.markRestorationComplete(); + + await h.service.runSessionDeletion([{ id: 'gone', owner: 'alice' }], async () => { + h.live.delete('gone'); + }); + + expect(h.layouts.alice).toEqual(existing([{ kind: 'session', id: 'keep' }])); + expect(h.order).toEqual(['keep']); + expect(h.store.commitTabLayoutProjection.mock.calls[0][0]).toEqual({}); + expect(h.broadcast).not.toHaveBeenCalled(); + expect(h.broadcastSessionOrder).toHaveBeenCalledExactlyOnceWith({ + changedOwnerOrders: { alice: ['keep'] }, + globalOrder: ['keep'], + globalChanged: true, + }); + }); + + it('removes legacy-only IDs from post-removal repair with no migrated layout or layout delta', async () => { + const h = createHarness({ + layouts: { bob: existing([{ kind: 'session', id: 'keep' }]) }, + order: ['alice-gone', 'bob-gone', 'keep'], + live: [{ id: 'keep', owner: 'bob', createdAt: 3 }], + }); + h.service.markRestorationComplete(); + + await h.service.sessionsRemoved([ + { id: 'alice-gone', owner: 'alice' }, + { id: 'bob-gone', owner: 'bob' }, + ]); + + expect(h.layouts.alice).toBeUndefined(); + expect(h.layouts.bob).toEqual(existing([{ kind: 'session', id: 'keep' }])); + expect(h.order).toEqual(['keep']); + expect(h.store.commitTabLayoutProjection).toHaveBeenCalledTimes(1); + expect(h.store.commitTabLayoutProjection.mock.calls[0][0]).toEqual({}); + expect(h.broadcast).not.toHaveBeenCalled(); + expect(h.broadcastSessionOrder).toHaveBeenCalledExactlyOnceWith({ + changedOwnerOrders: { alice: [], bob: ['keep'] }, + globalOrder: ['keep'], + globalChanged: true, + }); + }); + + it('removes a confirmed stale legacy-only ID even before owner layout migration', async () => { + const h = createHarness({ + order: ['stale'], + persisted: [{ id: 'stale', owner: 'alice', createdAt: 1 }], + }); + h.service.markRestorationComplete(); + const cleanup = vi.fn((ids: ReadonlySet) => { + delete h.persisted.stale; + return [...ids]; + }); + + await expect(h.service.runStaleSessionCleanup(new Set(), cleanup)).resolves.toEqual(['stale']); + + expect(h.layouts.alice).toBeUndefined(); + expect(h.order).toEqual([]); + expect(h.store.commitTabLayoutProjection).toHaveBeenCalledExactlyOnceWith({}, expect.any(Function)); + expect(h.broadcast).not.toHaveBeenCalled(); + expect(h.broadcastSessionOrder).toHaveBeenCalledExactlyOnceWith({ + changedOwnerOrders: { alice: [] }, + globalOrder: [], + globalChanged: true, + }); + }); + + it('does not prune or bump when restoration was skipped', async () => { + const h = createHarness({ layouts: { '@single': existing([{ kind: 'session', id: 'gone' }]) } }); + h.service.markRestorationSkipped(); + await h.service.sessionsRemoved([{ id: 'gone' }]); + await h.service.webviewDeleted('@single', 'also-gone'); + expect(h.store.commitTabLayoutProjection).not.toHaveBeenCalled(); + expect(h.layouts['@single'].version).toBe(7); + }); + + it('retains pinned stopped sessions and prunes saved webviews after restoration', async () => { + const h = createHarness({ + layouts: { + alice: existing([ + { kind: 'session', id: 'pinned' }, + { kind: 'webview', id: 'gone-web' }, + ]), + }, + persisted: [{ id: 'pinned', owner: 'alice', createdAt: 1, pinned: true }], + }); + h.service.markRestorationComplete(); + await h.service.webviewDeleted('alice', 'gone-web'); + expect(h.layouts.alice.ungrouped).toEqual([{ kind: 'session', id: 'pinned' }]); + expect(h.layouts.alice.version).toBe(8); + }); + + it('bridges a regular legacy reorder into the owner layout and canonical global projection', async () => { + const h = createHarness({ + layouts: { + alice: existing([ + { kind: 'session', id: 'a' }, + { kind: 'session', id: 'b' }, + ]), + }, + order: ['a', 'b'], + live: [ + { id: 'a', owner: 'alice', createdAt: 1 }, + { id: 'b', owner: 'alice', createdAt: 2 }, + ], + }); + + await expect(h.service.putLegacyOrder({ owner: 'alice', isAdmin: false }, ['b', 'a'])).resolves.toEqual({ + order: ['b', 'a'], + changedOwnerOrders: { alice: ['b', 'a'] }, + globalOrder: ['b', 'a'], + globalChanged: true, + }); + expect((await h.service.get('alice')).ungrouped.map((ref) => ref.id)).toEqual(['b', 'a']); + expect(h.layouts.alice.version).toBe(8); + expect(h.broadcast).toHaveBeenCalledExactlyOnceWith(SseEvent.TabLayoutChanged, { owner: 'alice', version: 8 }); + expect(h.broadcastSessionOrder).toHaveBeenCalledExactlyOnceWith({ + changedOwnerOrders: { alice: ['b', 'a'] }, + globalOrder: ['b', 'a'], + globalChanged: true, + }); + }); + + it('preserves an unmapped global slot while a legacy PUT reorders authoritative owner sessions', async () => { + const h = createHarness({ + layouts: { + alice: existing([ + { kind: 'session', id: 'unknown' }, + { kind: 'session', id: 'a' }, + { kind: 'session', id: 'b' }, + ]), + }, + order: ['unknown', 'a', 'b'], + live: [ + { id: 'a', owner: 'alice', createdAt: 1 }, + { id: 'b', owner: 'alice', createdAt: 2 }, + ], + }); + + const result = await h.service.putLegacyOrder({ owner: 'alice', isAdmin: false }, ['b', 'a']); + + expect(h.layouts.alice.ungrouped).toEqual([ + { kind: 'session', id: 'unknown' }, + { kind: 'session', id: 'b' }, + { kind: 'session', id: 'a' }, + ]); + expect(result.globalOrder).toEqual(['unknown', 'b', 'a']); + expect(h.order).toEqual(['unknown', 'b', 'a']); + expect(result.order).toEqual(['b', 'a']); + }); + + it('preserves an unmapped global slot during a GET order repair', async () => { + const h = createHarness({ + layouts: { + alice: existing([ + { kind: 'session', id: 'unknown' }, + { kind: 'session', id: 'b' }, + { kind: 'session', id: 'a' }, + ]), + }, + order: ['unknown', 'a', 'b'], + live: [ + { id: 'a', owner: 'alice', createdAt: 1 }, + { id: 'b', owner: 'alice', createdAt: 2 }, + ], + }); + + await h.service.get('alice'); + + expect(h.order).toEqual(['unknown', 'b', 'a']); + expect(h.broadcastSessionOrder).toHaveBeenCalledWith({ + changedOwnerOrders: { alice: ['b', 'a'] }, + globalOrder: ['unknown', 'b', 'a'], + globalChanged: true, + }); + }); + + it('removes an unmapped global id only when a trusted deletion explicitly excludes it', async () => { + const h = createHarness({ + layouts: { + alice: existing([ + { kind: 'session', id: 'unknown' }, + { kind: 'session', id: 'a' }, + ]), + }, + order: ['unknown', 'a'], + live: [{ id: 'a', owner: 'alice', createdAt: 1 }], + }); + h.service.markRestorationComplete(); + + await h.service.runSessionDeletion([{ id: 'unknown', owner: 'alice' }], async () => undefined); + + expect(h.layouts.alice.ungrouped).toEqual([{ kind: 'session', id: 'a' }]); + expect(h.order).toEqual(['a']); + }); + + it('reports an owner layout-order change even when the legacy global slice already matches', async () => { + const h = createHarness({ + layouts: { + alice: existing([ + { kind: 'session', id: 'a' }, + { kind: 'session', id: 'b' }, + ]), + }, + order: ['b', 'a'], + live: [ + { id: 'a', owner: 'alice', createdAt: 1 }, + { id: 'b', owner: 'alice', createdAt: 2 }, + ], + }); + + const result = await h.service.putLegacyOrder({ owner: 'alice', isAdmin: false }, ['b', 'a']); + + expect(result.changedOwnerOrders).toEqual({ alice: ['b', 'a'] }); + expect(result.globalChanged).toBe(false); + expect(h.broadcastSessionOrder).toHaveBeenCalledTimes(1); + }); + + it('reports an authoritative owner-order correction when a placement-only PUT repairs stale global order', async () => { + const h = createHarness({ + layouts: { + alice: existing([ + { kind: 'session', id: 'a' }, + { kind: 'session', id: 'b' }, + ]), + }, + order: ['b', 'a'], + live: [ + { id: 'a', owner: 'alice', createdAt: 1 }, + { id: 'b', owner: 'alice', createdAt: 2 }, + ], + }); + const desired = { + ...existing([], 7), + groups: [ + { + id: 'g', + name: 'Same order', + refs: [ + { kind: 'session' as const, id: 'a' }, + { kind: 'session' as const, id: 'b' }, + ], + }, + ], + }; + + const result = await h.service.put('alice', desired, 7); + + expect(result.status).toBe('updated'); + expect(h.order).toEqual(['a', 'b']); + expect(h.broadcastSessionOrder).toHaveBeenCalledExactlyOnceWith({ + changedOwnerOrders: { alice: ['a', 'b'] }, + globalOrder: ['a', 'b'], + globalChanged: true, + }); + }); + + it.each([ + ['foreign owner', ['b', 'a']], + ['unknown stored ref', ['unknown', 'a']], + ])('rejects a regular legacy request containing a %s without mutation or events', async (_label, requested) => { + const original = existing([ + { kind: 'session', id: 'a' }, + { kind: 'session', id: 'unknown' }, + ]); + const h = createHarness({ + layouts: { alice: original }, + order: ['a', 'b', 'unknown'], + live: [ + { id: 'a', owner: 'alice', createdAt: 1 }, + { id: 'b', owner: 'bob', createdAt: 2 }, + ], + }); + + await expect(h.service.putLegacyOrder({ owner: 'alice', isAdmin: false }, requested)).rejects.toThrow( + /not owned by layout owner/i + ); + expect(h.layouts.alice).toEqual(original); + expect(h.order).toEqual(['a', 'b', 'unknown']); + expect(h.store.commitTabLayoutProjection).not.toHaveBeenCalled(); + expect(h.broadcast).not.toHaveBeenCalled(); + expect(h.broadcastSessionOrder).not.toHaveBeenCalled(); + }); + + it('keeps containers and anchored slots fixed, materializes ranked children, and merges missing known sessions', async () => { + const h = createHarness({ + layouts: { + alice: { + ...existing([ + { kind: 'session', id: 'parent' }, + { kind: 'session', id: 'child' }, + { kind: 'session', id: 'c' }, + ]), + groups: [ + { + id: 'g', + name: 'Work', + refs: [ + { kind: 'session', id: 'a' }, + { kind: 'webview', id: 'w' }, + { kind: 'session', id: 'unknown' }, + { kind: 'session', id: 'b' }, + ], + }, + ], + }, + }, + order: ['a', 'b', 'parent', 'child', 'c'], + live: [ + { id: 'a', owner: 'alice', createdAt: 1 }, + { id: 'b', owner: 'alice', createdAt: 2 }, + { id: 'parent', owner: 'alice', createdAt: 3 }, + { id: 'child', owner: 'alice', createdAt: 4, parentSessionId: 'parent' }, + { id: 'c', owner: 'alice', createdAt: 5 }, + ], + webviews: [{ id: 'w', owner: 'alice' }], + }); + + const result = await h.service.putLegacyOrder({ owner: 'alice', isAdmin: false }, ['b', 'a', 'child', 'parent']); + + expect(result.order).toEqual(['b', 'a', 'child', 'parent', 'c']); + expect(h.layouts.alice.groups).toEqual([ + { + id: 'g', + name: 'Work', + refs: [ + { kind: 'session', id: 'b' }, + { kind: 'webview', id: 'w' }, + { kind: 'session', id: 'unknown' }, + { kind: 'session', id: 'a' }, + ], + }, + ]); + expect(h.layouts.alice.ungrouped).toEqual([ + { kind: 'session', id: 'child', placement: 'manual' }, + { kind: 'session', id: 'parent' }, + { kind: 'session', id: 'c' }, + ]); + expect(h.broadcastSessionOrder).toHaveBeenCalledExactlyOnceWith({ + changedOwnerOrders: { alice: ['b', 'a', 'child', 'parent', 'c'] }, + globalOrder: ['b', 'a', 'child', 'parent', 'c'], + globalChanged: true, + }); + expect(await h.service.get('alice')).toEqual(h.layouts.alice); + }); + + it('does not claim or disturb a known foreign session found in a stale owner layout', async () => { + const h = createHarness({ + layouts: { + alice: existing([ + { kind: 'session', id: 'a1' }, + { kind: 'session', id: 'b1' }, + { kind: 'session', id: 'a2' }, + ]), + bob: existing([ + { kind: 'session', id: 'b1' }, + { kind: 'session', id: 'b2' }, + ]), + }, + order: ['a1', 'b1', 'a2', 'b2'], + live: [ + { id: 'a1', owner: 'alice', createdAt: 1 }, + { id: 'b1', owner: 'bob', createdAt: 2 }, + { id: 'a2', owner: 'alice', createdAt: 3 }, + { id: 'b2', owner: 'bob', createdAt: 4 }, + ], + }); + + const result = await h.service.putLegacyOrder({ owner: 'alice', isAdmin: false }, ['a2', 'a1']); + + expect(result).toMatchObject({ + order: ['a2', 'a1'], + changedOwnerOrders: { alice: ['a2', 'a1'] }, + globalOrder: ['a2', 'b1', 'a1', 'b2'], + globalChanged: true, + }); + expect(h.layouts.alice.ungrouped.map((ref) => ref.id)).toEqual(['a2', 'a1']); + expect(h.layouts.bob.ungrouped.map((ref) => ref.id)).toEqual(['b1', 'b2']); + expect(h.broadcastSessionOrder).toHaveBeenCalledExactlyOnceWith({ + changedOwnerOrders: { alice: ['a2', 'a1'] }, + globalOrder: ['a2', 'b1', 'a1', 'b2'], + globalChanged: true, + }); + }); + + it('publishes layout and order together, suppresses duplicate order events, and rolls back store rejection', async () => { + const h = createHarness({ + layouts: { + alice: existing([ + { kind: 'session', id: 'a' }, + { kind: 'session', id: 'b' }, + ]), + }, + order: ['a', 'b'], + live: [ + { id: 'a', owner: 'alice', createdAt: 1 }, + { id: 'b', owner: 'alice', createdAt: 2 }, + ], + }); + const desired = { + ...existing([], 7), + groups: [ + { + id: 'g', + name: 'Grouped', + refs: [ + { kind: 'session' as const, id: 'a' }, + { kind: 'session' as const, id: 'b' }, + ], + }, + ], + }; + + await h.service.put('alice', desired, 7); + expect(h.order).toEqual(['a', 'b']); + expect(h.broadcast).toHaveBeenCalledTimes(1); + expect(h.broadcastSessionOrder).not.toHaveBeenCalled(); + await h.service.get('alice'); + expect(h.broadcastSessionOrder).not.toHaveBeenCalled(); + + const beforeLayout = structuredClone(h.layouts.alice); + const beforeOrder = [...h.order]; + h.store.commitTabLayoutProjection.mockImplementationOnce(() => { + throw new Error('publication failed'); + }); + h.broadcast.mockClear(); + await expect(h.service.put('alice', { ...desired, groups: [] }, 8)).rejects.toThrow('publication failed'); + expect(h.layouts.alice).toEqual(beforeLayout); + expect(h.order).toEqual(beforeOrder); + expect(h.broadcast).not.toHaveBeenCalled(); + expect(h.broadcastSessionOrder).not.toHaveBeenCalled(); + }); + + it('uses latest-state synchronous publication for overlapping owner preparations without a lost update', async () => { + const h = createHarness({ + layouts: { + alice: existing([ + { kind: 'session', id: 'a1' }, + { kind: 'session', id: 'a2' }, + ]), + bob: existing([ + { kind: 'session', id: 'b1' }, + { kind: 'session', id: 'b2' }, + ]), + }, + order: ['a1', 'b1', 'a2', 'b2'], + live: [ + { id: 'a1', owner: 'alice', createdAt: 1 }, + { id: 'b1', owner: 'bob', createdAt: 2 }, + { id: 'a2', owner: 'alice', createdAt: 3 }, + { id: 'b2', owner: 'bob', createdAt: 4 }, + ], + }); + + await Promise.all([ + h.service.putLegacyOrder({ owner: 'alice', isAdmin: false }, ['a2', 'a1']), + h.service.putLegacyOrder({ owner: 'bob', isAdmin: false }, ['b2', 'b1']), + ]); + + expect(h.order).toEqual(['a2', 'b2', 'a1', 'b1']); + expect(h.layouts.alice.ungrouped.map((ref) => ref.id)).toEqual(['a2', 'a1']); + expect(h.layouts.bob.ungrouped.map((ref) => ref.id)).toEqual(['b2', 'b1']); + }); + + it('admin publishes all changed owner ranks atomically and can change only cross-owner interleaving', async () => { + const makeHarness = () => + createHarness({ + layouts: { + alice: existing([ + { kind: 'session', id: 'a1' }, + { kind: 'session', id: 'a2' }, + ]), + bob: existing([ + { kind: 'session', id: 'b1' }, + { kind: 'session', id: 'b2' }, + ]), + }, + order: ['a1', 'b1', 'a2', 'b2'], + live: [ + { id: 'a1', owner: 'alice', createdAt: 1 }, + { id: 'b1', owner: 'bob', createdAt: 2 }, + { id: 'a2', owner: 'alice', createdAt: 3 }, + { id: 'b2', owner: 'bob', createdAt: 4 }, + ], + }); + const ranked = makeHarness(); + + await expect( + ranked.service.putLegacyOrder({ owner: 'admin', isAdmin: true }, ['a2', 'b2', 'a1', 'b1']) + ).resolves.toEqual({ + order: ['a2', 'b2', 'a1', 'b1'], + changedOwnerOrders: { alice: ['a2', 'a1'], bob: ['b2', 'b1'] }, + globalOrder: ['a2', 'b2', 'a1', 'b1'], + globalChanged: true, + }); + expect(Object.keys(ranked.store.commitTabLayoutProjection.mock.calls[0][0])).toEqual(['alice', 'bob']); + expect(ranked.layouts.alice.version).toBe(8); + expect(ranked.layouts.bob.version).toBe(8); + + const interleaved = makeHarness(); + await expect( + interleaved.service.putLegacyOrder({ owner: 'admin', isAdmin: true }, ['b1', 'a1', 'b2', 'a2']) + ).resolves.toEqual({ + order: ['b1', 'a1', 'b2', 'a2'], + changedOwnerOrders: {}, + globalOrder: ['b1', 'a1', 'b2', 'a2'], + globalChanged: true, + }); + expect(interleaved.store.commitTabLayoutProjection.mock.calls[0][0]).toEqual({}); + expect(interleaved.layouts.alice.version).toBe(7); + expect(interleaved.layouts.bob.version).toBe(7); + }); + + it('admin preserves requested owner slots while canonical grouped owner order wins', async () => { + const h = createHarness({ + layouts: { + alice: { + ...existing([{ kind: 'session', id: 'a2' }]), + groups: [{ id: 'fixed', name: 'Fixed', refs: [{ kind: 'session', id: 'a1' }] }], + }, + bob: existing([ + { kind: 'session', id: 'b1' }, + { kind: 'session', id: 'b2' }, + ]), + }, + order: ['a1', 'b1', 'a2', 'b2'], + live: [ + { id: 'a1', owner: 'alice', createdAt: 1 }, + { id: 'b1', owner: 'bob', createdAt: 2 }, + { id: 'a2', owner: 'alice', createdAt: 3 }, + { id: 'b2', owner: 'bob', createdAt: 4 }, + ], + }); + + const result = await h.service.putLegacyOrder({ owner: 'admin', isAdmin: true }, ['b2', 'a2', 'b1', 'a1']); + + expect(result.globalOrder).toEqual(['b2', 'a1', 'b1', 'a2']); + expect(result.order).toEqual(result.globalOrder); + expect(result.changedOwnerOrders).toEqual({ bob: ['b2', 'b1'] }); + expect(h.layouts.alice.version).toBe(7); + expect(h.layouts.bob.version).toBe(8); + }); + + it('admin publishes prototype-like owner keys as own layout entries', async () => { + const owner = '__proto__'; + const h = createHarness({ + layouts: { + [owner]: existing([ + { kind: 'session', id: 'p1' }, + { kind: 'session', id: 'p2' }, + ]), + }, + order: ['p1', 'p2'], + live: [ + { id: 'p1', owner, createdAt: 1 }, + { id: 'p2', owner, createdAt: 2 }, + ], + }); + + await h.service.putLegacyOrder({ owner: 'admin', isAdmin: true }, ['p2', 'p1']); + + const updates = h.store.commitTabLayoutProjection.mock.calls[0][0]; + expect(Object.hasOwn(updates, owner)).toBe(true); + expect(h.layouts[owner].version).toBe(8); + expect(h.layouts[owner].ungrouped.map((ref) => ref.id)).toEqual(['p2', 'p1']); + }); + + it('admin retries owner discovery when a new trusted owner appears while queues are pending', async () => { + const h = createHarness({ + layouts: { alice: existing([{ kind: 'session', id: 'a1' }]) }, + order: ['a1'], + live: [{ id: 'a1', owner: 'alice', createdAt: 1 }], + }); + let release!: () => void; + const blocked = new Promise((resolve) => { + release = resolve; + }); + h.readWebviews.mockImplementationOnce(async () => { + await blocked; + return []; + }); + const occupied = h.service.get('alice'); + await vi.waitFor(() => expect(h.readWebviews).toHaveBeenCalledOnce()); + + const admin = h.service.putLegacyOrder({ owner: 'admin', isAdmin: true }, ['b1', 'a1']); + h.live.set('b1', { id: 'b1', owner: 'bob', createdAt: 2 } as never); + release(); + + await occupied; + await expect(admin).resolves.toMatchObject({ globalOrder: ['b1', 'a1'] }); + expect(h.layouts.bob).toMatchObject({ version: 0, ungrouped: [{ kind: 'session', id: 'b1' }] }); + expect(Object.hasOwn(h.store.commitTabLayoutProjection.mock.calls.at(-1)![0], 'bob')).toBe(true); + }); +}); diff --git a/test/tab-layout-sse.test.ts b/test/tab-layout-sse.test.ts new file mode 100644 index 00000000..bef50067 --- /dev/null +++ b/test/tab-layout-sse.test.ts @@ -0,0 +1,305 @@ +/** @fileoverview Minimal tab-layout SSE payload routing to owner plus admins. */ +import type { FastifyReply } from 'fastify'; +import { describe, expect, it } from 'vitest'; +import type { SessionOrderProjectionChange } from '../src/tab-layout-service.js'; +import { CleanupManager } from '../src/utils/index.js'; +import { SseStreamManager } from '../src/web/sse-stream-manager.js'; +import { deriveTabLayoutSseHint } from '../src/web/tab-layout-sse.js'; + +function client() { + const writes: string[] = []; + return { + writes, + reply: { raw: { write: (chunk: string) => (writes.push(chunk), true) } } as unknown as FastifyReply, + }; +} + +function backpressuredClient(options: { throwAfterBackpressure?: boolean } = {}) { + const writes: string[] = []; + let firstWrite = true; + let onDrain: (() => void) | undefined; + const raw = { + write(chunk: string) { + if (!firstWrite && options.throwAfterBackpressure) throw new Error('client disconnected'); + writes.push(chunk); + if (firstWrite) { + firstWrite = false; + return false; + } + return true; + }, + once(event: string, callback: () => void) { + if (event === 'drain') onDrain = callback; + return raw; + }, + }; + return { + writes, + reply: { raw } as unknown as FastifyReply, + drain: () => { + const callback = onDrain; + onDrain = undefined; + callback?.(); + }, + }; +} + +function scriptedBackpressuredClient(outcomes: Array) { + const writes: string[] = []; + let onDrain: (() => void) | undefined; + const raw = { + write(chunk: string) { + const outcome = outcomes.shift() ?? true; + if (outcome instanceof Error) throw outcome; + writes.push(chunk); + return outcome; + }, + once(event: string, callback: () => void) { + if (event === 'drain') onDrain = callback; + return raw; + }, + }; + return { + writes, + reply: { raw } as unknown as FastifyReply, + drain: () => { + const callback = onDrain; + onDrain = undefined; + callback?.(); + }, + }; +} + +const orderChange = (order: string[]): SessionOrderProjectionChange => ({ + changedOwnerOrders: { alice: order }, + globalOrder: order, + globalChanged: true, +}); + +describe('tab layout SSE routing', () => { + it('derives an exact owner hint with fail-closed session scoping', () => { + expect(deriveTabLayoutSseHint({ owner: 'alice', version: 4 })).toEqual({ + username: 'alice', + sessionScoped: true, + }); + }); + + it('delivers the minimal event to the owner and admins, but not another user', () => { + const cleanup = new CleanupManager(); + const manager = new SseStreamManager({ getSessionStateWithRespawn: () => null }, cleanup); + const alice = client(); + const bob = client(); + const admin = client(); + manager.addClient(alice.reply, null, false, undefined, { username: 'alice', role: 'user' }); + manager.addClient(bob.reply, null, false, undefined, { username: 'bob', role: 'user' }); + manager.addClient(admin.reply, null, false, undefined, { username: 'root', role: 'admin' }); + + const payload = { owner: 'alice', version: 4 }; + manager.broadcast('tab:layoutChanged', payload, deriveTabLayoutSseHint(payload)); + + expect(alice.writes).toEqual(['event: tab:layoutChanged\ndata: {"owner":"alice","version":4}\n\n']); + expect(admin.writes).toEqual(alice.writes); + expect(bob.writes).toEqual([]); + cleanup.dispose(); + }); + + it('coalesces repeated layout invalidations and drains refresh, layout, then legacy order', () => { + const cleanup = new CleanupManager(); + const manager = new SseStreamManager({ getSessionStateWithRespawn: () => null }, cleanup); + const blockedAlice = backpressuredClient(); + const liveAlice = client(); + const bob = client(); + const admin = client(); + manager.addClient(blockedAlice.reply, null, false, undefined, { username: 'alice', role: 'user' }); + manager.addClient(liveAlice.reply, null, false, undefined, { username: 'alice', role: 'user' }); + manager.addClient(bob.reply, null, false, undefined, { username: 'bob', role: 'user' }); + manager.addClient(admin.reply, null, false, undefined, { username: 'root', role: 'admin' }); + + manager.broadcast('test:prime-backpressure', {}, { username: 'alice' }); + for (const target of [blockedAlice.writes, liveAlice.writes, bob.writes, admin.writes]) target.length = 0; + + const first = { owner: 'alice', version: 4 }; + const latest = { owner: 'alice', version: 5 }; + manager.broadcast('tab:layoutChanged', first, deriveTabLayoutSseHint(first)); + manager.broadcast('tab:layoutChanged', latest, deriveTabLayoutSseHint(latest)); + manager.broadcastSessionOrder(orderChange(['a2', 'a1'])); + + expect(blockedAlice.writes).toEqual([]); + expect(liveAlice.writes).toEqual([ + 'event: tab:layoutChanged\ndata: {"owner":"alice","version":4}\n\n', + 'event: tab:layoutChanged\ndata: {"owner":"alice","version":5}\n\n', + 'event: session:orderChanged\ndata: {"order":["a2","a1"]}\n\n', + ]); + expect(bob.writes).toEqual([]); + expect(admin.writes).toEqual(liveAlice.writes); + + blockedAlice.drain(); + + expect(blockedAlice.writes).toEqual([ + 'event: session:needsRefresh\ndata: {}\n\n', + 'event: tab:layoutChanged\ndata: {"owner":"alice","version":5}\n\n', + 'event: session:orderChanged\ndata: {"order":["a2","a1"]}\n\n', + ]); + cleanup.dispose(); + }); + + it('retains the latest invalidation for every admin-visible owner while isolating regular users', () => { + const cleanup = new CleanupManager(); + const manager = new SseStreamManager({ getSessionStateWithRespawn: () => null }, cleanup); + const blockedAlice = backpressuredClient(); + const blockedAdmin = backpressuredClient(); + const liveAlice = client(); + const liveAdmin = client(); + const bob = client(); + manager.addClient(blockedAlice.reply, null, false, undefined, { username: 'alice', role: 'user' }); + manager.addClient(blockedAdmin.reply, null, false, undefined, { username: 'root', role: 'admin' }); + manager.addClient(liveAlice.reply, null, false, undefined, { username: 'alice', role: 'user' }); + manager.addClient(liveAdmin.reply, null, false, undefined, { username: 'ops', role: 'admin' }); + manager.addClient(bob.reply, null, false, undefined, { username: 'bob', role: 'user' }); + + manager.broadcast('test:prime-admin', {}, { adminOnly: true }); + manager.broadcast('test:prime-alice', {}, { username: 'alice' }); + for (const target of [blockedAlice.writes, blockedAdmin.writes, liveAlice.writes, liveAdmin.writes, bob.writes]) { + target.length = 0; + } + + const alice4 = { owner: 'alice', version: 4 }; + const bob8 = { owner: 'bob', version: 8 }; + const alice5 = { owner: 'alice', version: 5 }; + const prototype9 = { owner: 'constructor', version: 9 }; + for (const payload of [alice4, bob8, alice5, prototype9]) { + manager.broadcast('tab:layoutChanged', payload, deriveTabLayoutSseHint(payload)); + } + const change = orderChange(['a2', 'a1']); + manager.broadcastSessionOrder(change); + + blockedAdmin.drain(); + blockedAlice.drain(); + + expect(blockedAdmin.writes).toEqual([ + 'event: session:needsRefresh\ndata: {}\n\n', + 'event: tab:layoutChanged\ndata: {"owner":"alice","version":5}\n\n', + 'event: tab:layoutChanged\ndata: {"owner":"bob","version":8}\n\n', + 'event: tab:layoutChanged\ndata: {"owner":"constructor","version":9}\n\n', + 'event: session:orderChanged\ndata: {"order":["a2","a1"]}\n\n', + ]); + expect(blockedAlice.writes).toEqual([ + 'event: session:needsRefresh\ndata: {}\n\n', + 'event: tab:layoutChanged\ndata: {"owner":"alice","version":5}\n\n', + 'event: session:orderChanged\ndata: {"order":["a2","a1"]}\n\n', + ]); + expect(liveAlice.writes).toEqual([ + 'event: tab:layoutChanged\ndata: {"owner":"alice","version":4}\n\n', + 'event: tab:layoutChanged\ndata: {"owner":"alice","version":5}\n\n', + 'event: session:orderChanged\ndata: {"order":["a2","a1"]}\n\n', + ]); + expect(bob.writes).toEqual(['event: tab:layoutChanged\ndata: {"owner":"bob","version":8}\n\n']); + expect(liveAdmin.writes).toEqual([ + 'event: tab:layoutChanged\ndata: {"owner":"alice","version":4}\n\n', + 'event: tab:layoutChanged\ndata: {"owner":"bob","version":8}\n\n', + 'event: tab:layoutChanged\ndata: {"owner":"alice","version":5}\n\n', + 'event: tab:layoutChanged\ndata: {"owner":"constructor","version":9}\n\n', + 'event: session:orderChanged\ndata: {"order":["a2","a1"]}\n\n', + ]); + cleanup.dispose(); + }); + + it('retains later owners and the final order when a layout recovery write re-enters backpressure', () => { + const cleanup = new CleanupManager(); + const manager = new SseStreamManager({ getSessionStateWithRespawn: () => null }, cleanup); + const admin = scriptedBackpressuredClient([false, true, false, true, true, true]); + manager.addClient(admin.reply, null, false, undefined, { username: 'root', role: 'admin' }); + manager.broadcast('test:prime-backpressure', {}, { adminOnly: true }); + admin.writes.length = 0; + + const alice = { owner: 'alice', version: 5 }; + const bob = { owner: 'bob', version: 8 }; + manager.broadcast('tab:layoutChanged', alice, deriveTabLayoutSseHint(alice)); + manager.broadcast('tab:layoutChanged', bob, deriveTabLayoutSseHint(bob)); + manager.broadcastSessionOrder(orderChange(['a2', 'a1'])); + + admin.drain(); + expect(admin.writes).toEqual([ + 'event: session:needsRefresh\ndata: {}\n\n', + 'event: tab:layoutChanged\ndata: {"owner":"alice","version":5}\n\n', + ]); + + admin.drain(); + expect(admin.writes).toEqual([ + 'event: session:needsRefresh\ndata: {}\n\n', + 'event: tab:layoutChanged\ndata: {"owner":"alice","version":5}\n\n', + 'event: session:needsRefresh\ndata: {}\n\n', + 'event: tab:layoutChanged\ndata: {"owner":"bob","version":8}\n\n', + 'event: session:orderChanged\ndata: {"order":["a2","a1"]}\n\n', + ]); + cleanup.dispose(); + }); + + it('drops remaining recovery state after a partial layout write failure without affecting healthy clients', () => { + const cleanup = new CleanupManager(); + const manager = new SseStreamManager({ getSessionStateWithRespawn: () => null }, cleanup); + const brokenAdmin = scriptedBackpressuredClient([false, true, true, new Error('client disconnected')]); + const liveAdmin = client(); + manager.addClient(brokenAdmin.reply, null, false, undefined, { username: 'root', role: 'admin' }); + manager.addClient(liveAdmin.reply, null, false, undefined, { username: 'ops', role: 'admin' }); + manager.broadcast('test:prime-backpressure', {}, { adminOnly: true }); + brokenAdmin.writes.length = 0; + liveAdmin.writes.length = 0; + + const alice = { owner: 'alice', version: 5 }; + const bob = { owner: 'bob', version: 8 }; + manager.broadcast('tab:layoutChanged', alice, deriveTabLayoutSseHint(alice)); + manager.broadcast('tab:layoutChanged', bob, deriveTabLayoutSseHint(bob)); + manager.broadcastSessionOrder(orderChange(['a2', 'a1'])); + + expect(() => brokenAdmin.drain()).not.toThrow(); + + expect(brokenAdmin.writes).toEqual([ + 'event: session:needsRefresh\ndata: {}\n\n', + 'event: tab:layoutChanged\ndata: {"owner":"alice","version":5}\n\n', + ]); + expect(manager.clientCount).toBe(1); + expect(liveAdmin.writes).toEqual([ + 'event: tab:layoutChanged\ndata: {"owner":"alice","version":5}\n\n', + 'event: tab:layoutChanged\ndata: {"owner":"bob","version":8}\n\n', + 'event: session:orderChanged\ndata: {"order":["a2","a1"]}\n\n', + ]); + cleanup.dispose(); + }); + + it('clears a queued layout invalidation on disconnect and ignores its stale drain callback', () => { + const cleanup = new CleanupManager(); + const manager = new SseStreamManager({ getSessionStateWithRespawn: () => null }, cleanup); + const alice = backpressuredClient(); + manager.addClient(alice.reply, null, false, undefined, { username: 'alice', role: 'user' }); + manager.broadcast('test:prime-backpressure', {}, { username: 'alice' }); + alice.writes.length = 0; + + const payload = { owner: 'alice', version: 4 }; + manager.broadcast('tab:layoutChanged', payload, deriveTabLayoutSseHint(payload)); + manager.removeClient(alice.reply); + alice.drain(); + + expect(alice.writes).toEqual([]); + expect(manager.clientCount).toBe(0); + cleanup.dispose(); + }); + + it('clears a queued layout invalidation when drain recovery fails', () => { + const cleanup = new CleanupManager(); + const manager = new SseStreamManager({ getSessionStateWithRespawn: () => null }, cleanup); + const alice = backpressuredClient({ throwAfterBackpressure: true }); + manager.addClient(alice.reply, null, false, undefined, { username: 'alice', role: 'user' }); + manager.broadcast('test:prime-backpressure', {}, { username: 'alice' }); + alice.writes.length = 0; + + const payload = { owner: 'alice', version: 4 }; + manager.broadcast('tab:layoutChanged', payload, deriveTabLayoutSseHint(payload)); + expect(() => alice.drain()).not.toThrow(); + alice.drain(); + + expect(alice.writes).toEqual([]); + expect(manager.clientCount).toBe(0); + cleanup.dispose(); + }); +}); diff --git a/test/tab-layout.test.ts b/test/tab-layout.test.ts new file mode 100644 index 00000000..25b2a70a --- /dev/null +++ b/test/tab-layout.test.ts @@ -0,0 +1,383 @@ +import { describe, expect, it } from 'vitest'; +import { + MAX_TAB_GROUP_NAME_LENGTH, + MAX_TAB_GROUPS, + MAX_TAB_REFS, + createGroup, + deleteGroup, + flattenOwnerSessionOrder, + flattenVisibleRefs, + followParent, + materializeOrphans, + moveRef, + normalizeTabLayout, + renameGroup, + reorderGroup, + setManualPlacement, + validateTabLayout, + type TabLayout, + type TabRefMetadata, +} from '../src/tab-layout.js'; + +const now = '2026-08-16T12:00:00.000Z'; +const ref = (id: string, kind: 'session' | 'webview' = 'session') => ({ kind, id }) as const; +const session = (id: string, order: number, parentSessionId?: string): TabRefMetadata => ({ + kind: 'session', + id, + order, + parentSessionId, + ownerValid: true, + visible: true, +}); +const webview = (id: string, order: number): TabRefMetadata => ({ + kind: 'webview', + id, + order, + ownerValid: true, + visible: true, +}); +const layout = (groups: TabLayout['groups'] = [], ungrouped: TabLayout['ungrouped'] = []): TabLayout => ({ + version: 3, + groups, + ungrouped, + updatedAt: now, +}); + +describe('tab layout validation and normalization', () => { + it('exports and enforces group, name, and raw ref ceilings at their boundaries', () => { + expect(MAX_TAB_GROUPS).toBe(32); + expect(MAX_TAB_GROUP_NAME_LENGTH).toBe(60); + expect(MAX_TAB_REFS).toBe(512); + + const groups = Array.from({ length: MAX_TAB_GROUPS }, (_, index) => ({ id: `g${index}`, name: 'x', refs: [] })); + expect(validateTabLayout(layout(groups)).groups).toHaveLength(MAX_TAB_GROUPS); + expect(() => validateTabLayout(layout([...groups, { id: 'overflow', name: 'x', refs: [] }]))).toThrow(/32/); + expect( + validateTabLayout(layout([{ id: 'g', name: ` ${'x'.repeat(60)} `, refs: [] }])).groups[0].name + ).toHaveLength(60); + expect(() => validateTabLayout(layout([{ id: 'g', name: 'x'.repeat(61), refs: [] }]))).toThrow(/60/); + + const refs = Array.from({ length: MAX_TAB_REFS }, (_, index) => ref(`s${index}`)); + expect(validateTabLayout(layout([], refs)).ungrouped).toHaveLength(MAX_TAB_REFS); + expect(() => validateTabLayout(layout([], [...refs, ref('overflow')]))).toThrow(/512/); + }); + + it('rejects malformed input deterministically and trims names', () => { + expect(() => validateTabLayout({ ...layout(), version: -1 })).toThrow(/version/); + expect(() => validateTabLayout(layout([{ id: 'g', name: ' ', refs: [] }]))).toThrow(/name/); + expect(() => + validateTabLayout( + layout([ + { id: 'g', name: 'x', refs: [] }, + { id: 'g', name: 'y', refs: [] }, + ]) + ) + ).toThrow(/duplicate group/); + expect(() => validateTabLayout(layout([], [{ kind: 'other', id: 'x' } as never]))).toThrow(/kind/); + expect(() => + validateTabLayout(layout([{ id: 'g', name: 'G', refs: [ref('duplicate')] }], [ref('duplicate')])) + ).toThrow(/duplicate ref/); + expect( + validateTabLayout(layout([{ id: 'g', name: 'G', refs: [ref('same')] }], [ref('same', 'webview')])).ungrouped + ).toEqual([ref('same', 'webview')]); + expect(renameGroup(layout([{ id: 'g', name: ' Before ', refs: [] }]), 'g', ' After ').groups[0].name).toBe( + 'After' + ); + }); + + it('deduplicates first occurrence, namespaces kinds, and repairs missing valid refs in stable order', () => { + const input = layout( + [{ id: 'g', name: 'G', refs: [ref('same'), ref('same'), ref('same', 'webview')] }], + [ref('same'), ref('later')] + ); + const normalized = normalizeTabLayout(input, [ + session('same', 1), + webview('same', 2), + session('later', 3), + session('new', 4), + ]); + expect(normalized.groups[0].refs).toEqual([ref('same'), ref('same', 'webview')]); + expect(normalized.ungrouped).toEqual([ref('later'), ref('new')]); + }); + + it('appends new roots and webviews and ignores metadata that is not owner-valid or visible', () => { + const metadata: TabRefMetadata[] = [ + session('root', 2), + webview('dash', 3), + { ...session('foreign', 0), ownerValid: false }, + { ...session('hidden-by-owner', 1), visible: false }, + ]; + expect(normalizeTabLayout(layout(), metadata).ungrouped).toEqual([ref('root'), ref('dash', 'webview')]); + }); + + it('preserves metadata-unknown stored refs while removing explicitly invalid refs', () => { + const input = layout([], [ref('unknown'), ref('unknown-web', 'webview'), ref('foreign')]); + const metadata: TabRefMetadata[] = [{ ...session('foreign', 0), ownerValid: false }, session('new', 1)]; + expect(normalizeTabLayout(input, metadata).ungrouped).toEqual([ + ref('unknown'), + ref('unknown-web', 'webview'), + ref('new'), + ]); + }); + + it('rejects a missing valid ref above the 512-ref ceiling without truncating the stored layout', () => { + const storedRefs = Array.from({ length: MAX_TAB_REFS }, (_, index) => ref(`s${index}`)); + const input = layout([], storedRefs); + const snapshot = structuredClone(input); + const metadata = [ + ...storedRefs.map((stored, index) => session(stored.id, index)), + session('missing-overflow', MAX_TAB_REFS), + ]; + + expect(() => normalizeTabLayout(input, metadata)).toThrow(/512/); + expect(input).toEqual(snapshot); + expect(input.ungrouped).toHaveLength(MAX_TAB_REFS); + }); + + it('does not mutate caller input', () => { + const input = layout([{ id: 'g', name: ' G ', refs: [ref('child')] }], [ref('parent')]); + const snapshot = structuredClone(input); + normalizeTabLayout(input, [session('parent', 0), session('child', 1, 'parent')]); + moveRef(input, ref('parent'), { groupId: 'g', index: 0 }, [session('parent', 0), session('child', 1, 'parent')]); + expect(input).toEqual(snapshot); + }); +}); + +describe('tab group operations', () => { + it('creates, renames, reorders, and deletes groups without deleting refs', () => { + let value = layout([{ id: 'a', name: 'A', refs: [ref('one')] }], [ref('loose')]); + value = createGroup(value, { id: 'b', name: ' Bee ', index: 0 }); + expect(value.groups.map((group) => [group.id, group.name])).toEqual([ + ['b', 'Bee'], + ['a', 'A'], + ]); + value = renameGroup(value, 'b', ' B '); + value = reorderGroup(value, 'a', 0); + expect(value.groups.map((group) => group.id)).toEqual(['a', 'b']); + value = deleteGroup(value, 'a'); + expect(value.groups.map((group) => group.id)).toEqual(['b']); + expect(value.ungrouped).toEqual([ref('loose'), ref('one')]); + }); + + it('moves refs within, between, and into ungrouped', () => { + const metadata = [session('a', 0), session('b', 1), session('c', 2)]; + let value = layout([{ id: 'g', name: 'G', refs: [ref('a'), ref('b')] }], [ref('c')]); + value = moveRef(value, ref('b'), { groupId: 'g', index: 0 }, metadata); + expect(value.groups[0].refs).toEqual([ref('b'), ref('a')]); + value = moveRef(value, ref('a'), { groupId: null, index: 1 }, metadata); + expect(value.ungrouped).toEqual([ref('c'), ref('a')]); + value = moveRef(value, ref('c'), { groupId: 'g', index: 1 }, metadata); + expect(value.groups[0].refs).toEqual([ref('b'), ref('c')]); + }); +}); + +describe('session lineage', () => { + it('normalizes nested lineage into preorder and preserves stable sibling order', () => { + const metadata = [ + session('root', 0), + session('child-b', 1, 'root'), + session('grandchild', 2, 'child-b'), + session('child-a', 3, 'root'), + ]; + const value = normalizeTabLayout( + layout([{ id: 'g', name: 'G', refs: [ref('root')] }], [ref('child-b'), ref('grandchild'), ref('child-a')]), + metadata + ); + expect(value.groups[0].refs).toEqual([ref('root'), ref('child-b'), ref('grandchild'), ref('child-a')]); + }); + + it('inserts a missing new child after the existing descendant subtree', () => { + const value = normalizeTabLayout(layout([], [ref('root'), ref('child'), ref('grandchild')]), [ + session('root', 0), + session('child', 1, 'root'), + session('grandchild', 2, 'child'), + session('new-child', 3, 'root'), + ]); + expect(value.ungrouped).toEqual([ref('root'), ref('child'), ref('grandchild'), ref('new-child')]); + }); + + it('moves a parent as one preorder block', () => { + const metadata = [ + session('root', 0), + session('child', 1, 'root'), + session('grandchild', 2, 'child'), + session('other', 3), + ]; + const input = layout( + [{ id: 'g', name: 'G', refs: [ref('other')] }], + [ref('root'), ref('child'), ref('grandchild')] + ); + const value = moveRef(input, ref('root'), { groupId: 'g', index: 0 }, metadata); + expect(value.groups[0].refs).toEqual([ref('root'), ref('child'), ref('grandchild'), ref('other')]); + expect(value.ungrouped).toEqual([]); + }); + + it('moves a parent subtree within one container without disturbing surrounding sibling order', () => { + const metadata = [ + session('before', 0), + session('root', 1), + session('child', 2, 'root'), + session('grandchild', 3, 'child'), + session('after', 4), + ]; + const input = layout([ + { + id: 'g', + name: 'G', + refs: [ref('before'), ref('root'), ref('child'), ref('grandchild'), ref('after')], + }, + ]); + + const value = moveRef(input, ref('root'), { groupId: 'g', index: 2 }, metadata); + expect(value.groups[0].refs).toEqual([ref('before'), ref('after'), ref('root'), ref('child'), ref('grandchild')]); + }); + + it('makes an independently moved child manual and followParent restores inheritance', () => { + const metadata = [session('root', 0), session('child', 1, 'root'), session('grandchild', 2, 'child')]; + let value = layout([{ id: 'g', name: 'G', refs: [ref('root'), ref('child'), ref('grandchild')] }]); + value = moveRef(value, ref('child'), { groupId: null, index: 0 }, metadata); + expect(value.ungrouped).toEqual([{ ...ref('child'), placement: 'manual' }, ref('grandchild')]); + value = followParent(value, ref('child'), metadata); + expect(value.groups[0].refs).toEqual([ref('root'), ref('child'), ref('grandchild')]); + expect(value.ungrouped).toEqual([]); + }); + + it('sets manual placement but rejects direct clearing outside followParent', () => { + const input = layout([], [ref('a')]); + expect(setManualPlacement(input, ref('a'), true).ungrouped[0]).toEqual({ ...ref('a'), placement: 'manual' }); + expect(() => setManualPlacement(setManualPlacement(input, ref('a'), true), ref('a'), false)).toThrow( + /followParent/ + ); + }); + + it('rejects stale follow-parent replay and keeps the child manual across later parent ID reuse', () => { + const materialized = layout([], [{ ...ref('child'), placement: 'manual' }]); + const staleMetadata = [session('child', 1, 'deleted-parent')]; + + expect(() => followParent(materialized, ref('child'), staleMetadata)).toThrow(/parent/); + expect(materialized.ungrouped).toEqual([{ ...ref('child'), placement: 'manual' }]); + + const afterIdReuse = normalizeTabLayout(materialized, [ + session('deleted-parent', 2), + session('child', 1, 'deleted-parent'), + ]); + expect(afterIdReuse.ungrouped).toEqual([{ ...ref('child'), placement: 'manual' }, ref('deleted-parent')]); + }); + + it('rejects follow-parent for roots and webviews', () => { + expect(() => + followParent(layout([], [{ ...ref('root'), placement: 'manual' }]), ref('root'), [session('root', 0)]) + ).toThrow(/parent/); + expect(() => + followParent(layout([], [{ ...ref('dash', 'webview'), placement: 'manual' }]), ref('dash', 'webview'), [ + webview('dash', 0), + ]) + ).toThrow(/session/); + }); + + it.each([ + { label: 'invalid', parent: { ...session('parent', 0), ownerValid: false } }, + { label: 'invisible', parent: { ...session('parent', 0), visible: false } }, + ])('rejects follow-parent when the parent is $label', ({ parent }) => { + const input = layout([], [ref('parent'), { ...ref('child'), placement: 'manual' }]); + expect(() => followParent(input, ref('child'), [parent, session('child', 1, 'parent')])).toThrow(/parent/); + }); + + it('breaks the repeated cycle edge deterministically at its current location', () => { + const value = normalizeTabLayout(layout([], [ref('a'), ref('b')]), [session('a', 0, 'b'), session('b', 1, 'a')]); + expect(value.ungrouped).toEqual([{ ...ref('b'), placement: 'manual' }, ref('a')]); + }); + + it('breaks a multi-node cross-container cycle deterministically', () => { + const metadata = [session('a', 0, 'b'), session('b', 1, 'c'), session('c', 2, 'a'), session('tail', 3)]; + const input = layout( + [ + { id: 'one', name: 'One', refs: [ref('a')] }, + { id: 'two', name: 'Two', refs: [ref('b')] }, + ], + [ref('c'), ref('tail')] + ); + + const first = normalizeTabLayout(input, metadata); + expect(first.groups.map((group) => group.refs)).toEqual([[], []]); + expect(first.ungrouped).toEqual([{ ...ref('c'), placement: 'manual' }, ref('b'), ref('a'), ref('tail')]); + expect(normalizeTabLayout(first, metadata)).toEqual(first); + }); + + it('materializes direct orphans and prevents a restored ID from re-adopting them', () => { + const metadata = [session('parent', 0), session('child', 1, 'parent'), session('grandchild', 2, 'child')]; + let value = layout([], [ref('parent'), ref('child'), ref('grandchild')]); + value = materializeOrphans(value, ['parent'], metadata); + value = normalizeTabLayout(value, metadata); + expect(value.ungrouped).toEqual([{ ...ref('child'), placement: 'manual' }, ref('grandchild'), ref('parent')]); + }); + + it('materializes a direct orphan in its grouped location amid surrounding refs', () => { + const metadata = [ + session('before', 0), + session('parent', 1), + session('child', 2, 'parent'), + session('grandchild', 3, 'child'), + session('after', 4), + ]; + const input = layout([ + { + id: 'g', + name: 'G', + refs: [ref('before'), ref('parent'), ref('child'), ref('grandchild'), ref('after')], + }, + ]); + + const materialized = materializeOrphans(input, ['parent'], metadata); + expect(materialized.groups[0].refs).toEqual([ + ref('before'), + { ...ref('child'), placement: 'manual' }, + ref('grandchild'), + ref('after'), + ]); + const restored = normalizeTabLayout(materialized, metadata); + expect(restored.groups[0].refs).toEqual([ + ref('before'), + { ...ref('child'), placement: 'manual' }, + ref('grandchild'), + ref('after'), + ]); + expect(restored.ungrouped).toEqual([ref('parent')]); + }); +}); + +describe('flatten projections', () => { + const value = layout( + [ + { id: 'collapsed', name: 'Collapsed', refs: [ref('s1'), ref('closed', 'webview'), ref('s2')] }, + { id: 'open', name: 'Open', refs: [ref('s3'), ref('dash', 'webview')] }, + ], + [ref('s4')] + ); + + it('projects every owner session in layout order, including collapse-hidden members', () => { + expect(flattenOwnerSessionOrder(value)).toEqual(['s1', 's2', 's3', 's4']); + }); + + it('projects renderable refs, omits unopened webviews, and keeps a collapsed highlight visible', () => { + expect( + flattenVisibleRefs(value, { + liveSessionIds: new Set(['s1', 's2', 's3', 's4']), + openWebviewIds: new Set(['dash']), + collapsedGroupIds: new Set(['collapsed']), + highlighted: ref('s2'), + }) + ).toEqual([ref('s2'), ref('s3'), ref('dash', 'webview'), ref('s4')]); + }); + + it('keeps a highlighted open webview visible inside a collapsed group', () => { + expect( + flattenVisibleRefs(value, { + liveSessionIds: new Set(['s1', 's2', 's3', 's4']), + openWebviewIds: new Set(['closed', 'dash']), + collapsedGroupIds: new Set(['collapsed']), + highlighted: ref('closed', 'webview'), + }) + ).toEqual([ref('closed', 'webview'), ref('s3'), ref('dash', 'webview'), ref('s4')]); + }); +});