mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-04 14:39:42 +02:00
feat(tabs): COD-359 add owner-scoped tab layouts
This commit is contained in:
@@ -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
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 ==========
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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',
|
||||
};
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
Reference in New Issue
Block a user