feat(tabs): COD-359 add owner-scoped tab layouts

This commit is contained in:
Aamer Akhter
2026-08-23 14:46:10 -04:00
parent 6b0b6d10ad
commit 74194e4fc0
44 changed files with 5202 additions and 136 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"aicodeman": minor
---
Add the owner-scoped tab-layout model, persistence, API, lifecycle repair, and synchronized legacy ordering foundation.
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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);
+60 -14
View File
@@ -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<string>): {
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<string>): {
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<string, TabLayout> {
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<Record<string, TabLayout>>,
projectOrder: (latest: readonly string[]) => readonly string[]
): { layouts: Record<string, TabLayout>; 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();
+81
View File
@@ -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<string, TabRefMetadata>();
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;
}
+144
View File
@@ -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<Record<string, TabLayout>>;
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<string, TabLayout>;
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<string, TabLayoutSessionRecord> {
const sessions = new Map<string, TabLayoutSessionRecord>();
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<string, TabLayoutSessionRecord>
): 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<string>();
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 };
}
+663
View File
@@ -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<string, TabLayoutSessionLike>;
readWebviews(): Promise<readonly TabLayoutWebviewRecord[]>;
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<string, string[]>;
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<string>;
}
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<TabRef, 'kind' | 'id'>): 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<string, Promise<void>>();
constructor(private readonly deps: TabLayoutServiceDeps) {}
private async withOwner<T>(owner: string, task: () => Promise<T>): Promise<T> {
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<T>(owners: readonly string[], task: () => Promise<T>, index = 0): Promise<T> {
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<void> {
if (this.restorationState !== 'complete') return;
const { persisted, live } = this.sessionRecords();
const webviews = await this.deps.readWebviews();
const owners = new Set<string>();
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<string, TabLayoutSessionRecord>();
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<string>();
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<Record<string, TabLayout>>,
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<PreparedOwnerLayout> {
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<TabLayout> {
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<TabLayout> {
return this.withOwner(owner, () => this.getUnlocked(owner));
}
async put(owner: string, desired: unknown, baseVersion: number): Promise<TabLayoutPutResult> {
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<LegacyOrderPutResult> {
return actor.isAdmin ? this.putAdminLegacyOrder(requested) : this.putOwnerLegacyOrder(actor.owner, requested);
}
private async putOwnerLegacyOrder(owner: string, requested: readonly string[]): Promise<LegacyOrderPutResult> {
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<LegacyOrderPutResult> {
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<LegacyOrderPutResult | null> => {
if (!sameOrder(owners, discoverOwners())) return null;
const normalized = normalizeSessionOrder(requested);
const knownOwners = new Map<string, string>();
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<string, TabLayout> = Object.create(null) as Record<string, TabLayout>;
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<TabLayout> {
return this.get(owner);
}
/** Reconcile one completed saved-webview creation into one versioned mutation. */
async webviewCreated(owner: string): Promise<TabLayout> {
return this.get(owner);
}
async sessionsRemoved(removed: readonly RemovedTabLayoutSession[]): Promise<void> {
if (this.restorationState !== 'complete' || removed.length === 0) return;
const byOwner = new Map<string, string[]>();
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<string, TabLayout> = Object.create(null) as Record<string, TabLayout>;
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<T>(removed: readonly RemovedTabLayoutSession[], action: () => Promise<T>): Promise<T> {
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<T>(
activeSessionIds: ReadonlySet<string>,
action: (ids: ReadonlySet<string>) => T | Promise<T>
): Promise<T> {
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<string, string[]>();
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<string, string[]>();
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<string>;
}> = [];
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<string, TabLayoutSessionRecord>();
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<void> {
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);
});
}
}
+547
View File
@@ -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<string>;
openWebviewIds: ReadonlySet<string>;
collapsedGroupIds?: ReadonlySet<string>;
highlighted?: TabRef;
}
export class TabLayoutValidationError extends Error {
constructor(message: string) {
super(message);
this.name = 'TabLayoutValidationError';
}
}
const keyOf = (ref: Pick<TabRef, 'kind' | 'id'>): string => `${ref.kind}\u0000${ref.id}`;
function assertRecord(value: unknown, label: string): asserts value is Record<string, unknown> {
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<string>();
const refKeys = new Set<string>();
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<string, TabRefMetadata>();
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<string | null, TabRef[]>): 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<string>();
const dedupedByContainer = new Map<string | null, TabRef[]>();
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<string>();
const state = new Map<string, 'visiting' | 'done'>();
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<string, string>();
const children = new Map<string, string[]>();
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<string>();
const output = new Map<string | null, TabRef[]>();
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<string> {
const valid = validMetadata(metadata);
const stored = new Map(locations(layout).map((item) => [keyOf(item.ref), item.ref]));
const children = new Map<string, string[]>();
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<string>();
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<string | null, TabRef[]>();
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<string>();
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;
}
+3
View File
@@ -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<string, CronJobRun>;
/** Global tab order shared across devices (ordered list of sessionIds) — COD-131 */
sessionOrder?: string[];
/** Owner-scoped authoritative grouped tab layouts. */
tabLayouts?: Record<string, TabLayout>;
}
// ========== Default Configuration ==========
+1
View File
@@ -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';
+1 -1
View File
@@ -7,7 +7,7 @@ import type { Session } from '../../session.js';
export interface SessionPort {
readonly sessions: ReadonlyMap<string, Session>;
addSession(session: Session): void;
addSession(session: Session): Promise<void>;
cleanupSession(sessionId: string, killMux?: boolean, reason?: string): Promise<void>;
setupSessionListeners(session: Session): Promise<void>;
persistSessionState(session: Session): void;
+8
View File
@@ -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;
}
+2 -1
View File
@@ -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',
};
// ═══════════════════════════════════════════════════════════════
+1
View File
@@ -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';
+1 -1
View File
@@ -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);
+23 -15
View File
@@ -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<void> {
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<ApiResponse<{ order: string[] }>> => {
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<ApiResponse<{ order: string[] }>> => {
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);
+5 -3
View File
@@ -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 });
+50
View File
@@ -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;
}
});
}
+30 -4
View File
@@ -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<T>(fn: (list: Webview[]) => Promise<T> | T): Promise<T> {
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') {
+88 -25
View File
@@ -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<void> {
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<string, Session>,
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<void> {
private async _doCleanupSession(
sessionId: string,
killMux: boolean,
reason?: string,
coordinateLayout = true
): Promise<void> {
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<number> {
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<string, unknown>, username: string): Record<string, unknown> {
const ownedIds = new Set<string>();
const authoritativeOwners = new Map<string, string | undefined>();
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<string>();
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<string, unknown> = {
...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<void> {
private async restoreMuxSessions(): Promise<boolean> {
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<void> {
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.
*
+15
View File
@@ -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;
}
+6 -3
View File
@@ -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;
+85 -26
View File
@@ -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<FastifyReply> = new Set();
/** Clients with backpressure — skip writes until 'drain' fires */
private backpressuredClients: Set<FastifyReply> = new Set();
/** Latest already recipient-filtered legacy order frame awaiting a client's drain. */
private pendingSessionOrderFrames: Map<FastifyReply, string> = new Map();
/** Latest owner-filtered tab-layout invalidation per affected owner awaiting a client's drain. */
private pendingTabLayoutFrames: Map<FastifyReply, Map<string | symbol, string>> = 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()) {
+6
View File
@@ -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 };
}
+40
View File
@@ -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<string, FakeSession>();
let rejectRegistration!: (error: Error) => void;
const addSession = vi.fn(
() =>
new Promise<void>((_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);
+180 -1
View File
@@ -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<string, FakeSession>;
store: { getSessionOrder(): string[]; setSessionOrder(order: string[]): void };
cachedLightState: unknown;
cachedSessionsList: unknown;
getLightState(identity?: { username: string; role: 'admin' | 'user' }): Record<string, unknown>;
};
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<string, { id: string; createdAt: number; owner?: string }>;
store: {
getState(): { sessionOrder?: string[]; tabLayouts?: Record<string, TabLayout> };
getSessionOrder(): string[];
getTabLayout(owner: string): TabLayout | null;
getTabLayouts(): Record<string, TabLayout>;
commitTabLayoutProjection: (...args: unknown[]) => unknown;
save(): void;
};
sse: {
addClient(reply: unknown, sessionFilter: Set<string> | 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);
+23 -1
View File
@@ -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,
+26
View File
@@ -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<void>((_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',
+49 -34
View File
@@ -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<LocalHarness> {
async function buildHarness(authUser = { username: 'alice', role: 'user' as const }): Promise<LocalHarness> {
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<typeof registerSessionRoutes>[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<typeof vi.fn>).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,
});
});
});
+6 -1
View File
@@ -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<string>, action: (ids: ReadonlySet<string>) => 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();
});
});
+106
View File
@@ -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();
});
});
+73 -1
View File
@@ -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);
});
@@ -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<string, { id: string; owner?: string }>();
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<void>;
};
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);')
);
});
});
+31 -2
View File
@@ -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<unknown> };
};
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);
+244
View File
@@ -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<string, string[]>,
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();
});
});
+134
View File
@@ -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<string, TabLayout> } = {}): 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);
});
});
+14
View File
@@ -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', () => {
+180
View File
@@ -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> = {}): TabRefMetadata => ({
kind: 'session',
id,
ownerValid: true,
visible: true,
order: 0,
...overrides,
});
const layout = (overrides: Partial<TabLayout> = {}): 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__']);
});
});
+264
View File
@@ -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> = {}): 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();
});
});
+62
View File
@@ -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<never> };
restoreMuxSessions(): Promise<boolean>;
finalizeRestoredState(restored: boolean): Promise<void>;
};
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<void>;
};
cleanupStaleSessions(): Promise<number>;
finalizeRestoredState(restored: boolean): Promise<void>;
};
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']);
});
});
File diff suppressed because it is too large Load Diff
+305
View File
@@ -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<boolean | Error>) {
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();
});
});
+383
View File
@@ -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')]);
});
});