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

This commit is contained in:
Aamer Akhter
2026-08-23 14:46:10 -04:00
parent 6b0b6d10ad
commit 74194e4fc0
44 changed files with 5202 additions and 136 deletions
+1
View File
@@ -14,3 +14,4 @@ export type { InfraPort, ScheduledRun } from './infra-port.js';
export type { AuthPort } from './auth-port.js';
export type { OrchestratorPort } from './orchestrator-port.js';
export type { CronPort } from './cron-port.js';
export type { TabLayoutPort } from './tab-layout-port.js';
+1 -1
View File
@@ -7,7 +7,7 @@ import type { Session } from '../../session.js';
export interface SessionPort {
readonly sessions: ReadonlyMap<string, Session>;
addSession(session: Session): void;
addSession(session: Session): Promise<void>;
cleanupSession(sessionId: string, killMux?: boolean, reason?: string): Promise<void>;
setupSessionListeners(session: Session): Promise<void>;
persistSessionState(session: Session): void;
+8
View File
@@ -0,0 +1,8 @@
/** @fileoverview Owner-scoped tab-layout capabilities exposed to route modules. */
import type { TabLayoutService } from '../../tab-layout-service.js';
export type { LegacyOrderActor, LegacyOrderPutResult, SessionOrderProjectionChange } from '../../tab-layout-service.js';
export interface TabLayoutPort {
readonly tabLayouts: TabLayoutService;
}
+2 -1
View File
@@ -10,7 +10,7 @@
* @globals {function} scheduleBackground - scheduler.postTask wrapper (background priority)
* @globals {function} getEventCoords - Unified mouse/touch coordinate extractor
* @globals {function} escapeHtml - XSS-safe HTML escaping
* @globals {object} SSE_EVENTS - Centralized SSE event type constants (120 event types; must match backend src/web/sse-events.ts)
* @globals {object} SSE_EVENTS - Centralized SSE event type constants (156 event types; must match backend src/web/sse-events.ts)
* @globals {Array} BUILTIN_RESPAWN_PRESETS - Built-in respawn configuration presets
*
* @dependency None (first in load order)
@@ -969,6 +969,7 @@ const SSE_EVENTS = {
// Web tabs (dashboard URLs)
WEBVIEW_CHANGED: 'webview:changed',
TAB_LAYOUT_CHANGED: 'tab:layoutChanged',
};
// ═══════════════════════════════════════════════════════════════
+1
View File
@@ -26,3 +26,4 @@ export { registerAdminRoutes } from './admin-routes.js';
export { registerWsRoutes } from './ws-routes.js';
export { registerVoiceRoutes } from './voice-routes.js';
export { registerWebviewRoutes, tryWebviewRefererFallback } from './webview-routes.js';
export { registerTabLayoutRoutes } from './tab-layout-routes.js';
+1 -1
View File
@@ -411,7 +411,7 @@ export function registerRalphRoutes(
writeFileSync(promptPath, fullPrompt, 'utf-8');
// Register session
ctx.addSession(session);
await ctx.addSession(session);
ctx.store.incrementSessionsCreated();
ctx.persistSessionState(session);
await ctx.setupSessionListeners(session);
+23 -15
View File
@@ -47,7 +47,8 @@ import {
SessionWaitQuerySchema,
SessionWaitOutputQuerySchema,
} from '../schemas.js';
import { mergeSessionOrder } from '../../session-order.js';
import { ownerLayoutKey } from '../../tab-layout-persistence.js';
import { TabLayoutValidationError } from '../../tab-layout.js';
import {
sessionWaits,
resolveWaitSignals,
@@ -107,7 +108,7 @@ import {
setHistoryIndexRefresher,
setHistorySessionIndex,
} from '../session-history-index.js';
import type { SessionPort, EventPort, ConfigPort, InfraPort, AuthPort } from '../ports/index.js';
import type { SessionPort, EventPort, ConfigPort, InfraPort, AuthPort, TabLayoutPort } from '../ports/index.js';
import { RunSummaryTracker } from '../../run-summary.js';
import { MAX_INPUT_LENGTH, MAX_SESSION_NAME_LENGTH } from '../../config/terminal-limits.js';
@@ -641,7 +642,7 @@ async function injectAgentSkill(casePath: string): Promise<void> {
export function registerSessionRoutes(
app: FastifyInstance,
ctx: SessionPort & EventPort & ConfigPort & InfraPort & AuthPort
ctx: SessionPort & EventPort & ConfigPort & InfraPort & AuthPort & TabLayoutPort
): void {
// ═══════════════════════════════════════════════════════════════
// Auth
@@ -673,16 +674,23 @@ export function registerSessionRoutes(
return (list as Array<{ owner?: string }>).filter((s) => canAccessOwned(user, s.owner));
});
// ========== Session Tab Order (global sync, COD-131) ==========
// ========== Legacy Session Tab Order (temporary synchronized compatibility bridge) ==========
app.put('/api/session-order', async (req): Promise<ApiResponse<{ order: string[] }>> => {
const { order } = parseBody(SessionOrderUpdateSchema, req.body, 'Invalid session order');
// Server is authoritative but never drops ids it knows about that the
// pushing device hadn't loaded yet — those fall to the end (mergeSessionOrder).
const merged = mergeSessionOrder(order, ctx.store.getSessionOrder());
ctx.store.setSessionOrder(merged);
ctx.broadcast(SseEvent.SessionOrderChanged, { order: merged });
return { success: true, data: { order: merged } };
app.put('/api/session-order', async (req, reply): Promise<ApiResponse<{ order: string[] }>> => {
try {
const { order } = parseBody(SessionOrderUpdateSchema, req.body, 'Invalid session order');
const user = getAuthUser(req);
const result = await ctx.tabLayouts.putLegacyOrder(
{ owner: ownerLayoutKey(ownerFor(req)), isAdmin: user.role === 'admin' },
order
);
return { success: true, data: { order: result.order } };
} catch (error) {
if (error instanceof TabLayoutValidationError) {
return reply.code(400).send(createErrorResponse(ApiErrorCode.INVALID_INPUT, error.message));
}
throw error;
}
});
// ========== Session Creation ==========
@@ -930,7 +938,7 @@ export function registerSessionRoutes(
parentSessionId: resolveParentSessionId(ctx, req, body.parentSessionId, owner),
});
ctx.addSession(session);
await ctx.addSession(session);
ctx.store.incrementSessionsCreated();
ctx.persistSessionState(session);
await ctx.setupSessionListeners(session);
@@ -2643,7 +2651,7 @@ export function registerSessionRoutes(
allowedTools: runClaudeModeConfig.allowedTools,
owner: runOwner,
});
ctx.addSession(session);
await ctx.addSession(session);
ctx.store.incrementSessionsCreated();
ctx.persistSessionState(session);
await ctx.setupSessionListeners(session);
@@ -3057,7 +3065,7 @@ export function registerSessionRoutes(
}
}
ctx.addSession(session);
await ctx.addSession(session);
ctx.store.incrementSessionsCreated();
ctx.persistSessionState(session);
await ctx.setupSessionListeners(session);
+5 -3
View File
@@ -49,7 +49,7 @@ import {
import { SseEvent } from '../sse-events.js';
import { getInstallInfo, checkForUpdate, startUpdate, getUpdateStatusForApi } from '../self-update.js';
import { getRepositoryStatus } from '../repo-status.js';
import type { SessionPort, EventPort, ConfigPort, InfraPort, AuthPort } from '../ports/index.js';
import type { SessionPort, EventPort, ConfigPort, InfraPort, AuthPort, TabLayoutPort } from '../ports/index.js';
import { AUTH_COOKIE_NAME } from '../middleware/auth.js';
import { QR_AUTH_FAILURE_MAX } from '../../config/tunnel-config.js';
import { AUTH_SESSION_TTL_MS } from '../../config/auth-config.js';
@@ -129,7 +129,7 @@ export function resolveSpanUrl(hostHeader: string | undefined, fallbackPort = '3
export function registerSystemRoutes(
app: FastifyInstance,
ctx: SessionPort & EventPort & ConfigPort & InfraPort & AuthPort
ctx: SessionPort & EventPort & ConfigPort & InfraPort & AuthPort & TabLayoutPort
): void {
const windowStatesPath = dataPath('subagent-window-states.json');
const parentMapPath = dataPath('subagent-parents.json');
@@ -454,7 +454,9 @@ export function registerSystemRoutes(
app.post('/api/cleanup-state', async () => {
const activeSessionIds = new Set(ctx.sessions.keys());
const result = ctx.store.cleanupStaleSessions(activeSessionIds);
const result = await ctx.tabLayouts.runStaleSessionCleanup(activeSessionIds, (ids) =>
ctx.store.cleanupSessionsByIds(ids)
);
const lifecycleLog = getLifecycleLog();
for (const s of result.cleaned) {
lifecycleLog.log({ event: 'stale_cleaned', sessionId: s.id, name: s.name });
+50
View File
@@ -0,0 +1,50 @@
/** @fileoverview Authenticated owner-scoped tab-layout read/write API. */
import type { FastifyInstance } from 'fastify';
import { ownerLayoutKey } from '../../tab-layout-persistence.js';
import { TabLayoutValidationError } from '../../tab-layout.js';
import { ApiErrorCode, createErrorResponse } from '../../types.js';
import { ownerFor } from '../route-helpers.js';
import type { TabLayoutPort } from '../ports/index.js';
export const TAB_LAYOUT_BODY_LIMIT = 128 * 1024;
function parseWriteBody(body: unknown): { baseVersion: number; layout: unknown } {
if (body === null || typeof body !== 'object' || Array.isArray(body)) {
throw new TabLayoutValidationError('body must be an object');
}
const keys = Object.keys(body);
if (keys.length !== 2 || !Object.hasOwn(body, 'baseVersion') || !Object.hasOwn(body, 'layout')) {
throw new TabLayoutValidationError('body must contain exactly baseVersion and layout');
}
const input = body as { baseVersion?: unknown; layout?: unknown };
if (!Number.isSafeInteger(input.baseVersion) || (input.baseVersion as number) < 0 || input.layout === undefined) {
throw new TabLayoutValidationError('baseVersion must be a non-negative safe integer and layout is required');
}
return { baseVersion: input.baseVersion as number, layout: input.layout };
}
export function registerTabLayoutRoutes(app: FastifyInstance, ctx: TabLayoutPort): void {
app.get('/api/tab-layout', async (req) => ({
success: true,
data: { layout: await ctx.tabLayouts.get(ownerLayoutKey(ownerFor(req))) },
}));
app.put('/api/tab-layout', { bodyLimit: TAB_LAYOUT_BODY_LIMIT }, async (req, reply) => {
try {
const { baseVersion, layout } = parseWriteBody(req.body);
const result = await ctx.tabLayouts.put(ownerLayoutKey(ownerFor(req)), layout, baseVersion);
if (result.status === 'conflict') {
return reply.code(409).send({
...createErrorResponse(ApiErrorCode.CONFLICT, 'Tab layout version conflict'),
data: { layout: result.layout },
});
}
return { success: true, data: { layout: result.layout } };
} catch (error) {
if (error instanceof TabLayoutValidationError) {
return reply.code(400).send(createErrorResponse(ApiErrorCode.INVALID_INPUT, error.message));
}
throw error;
}
});
}
+30 -4
View File
@@ -53,7 +53,8 @@ import { AUTH_COOKIE_NAME } from '../middleware/auth.js';
import { canAccessOwned, getAuthUser, ownerFor, parseBody } from '../route-helpers.js';
import { WebviewCreateSchema, WebviewProbeSchema, WebviewUpdateSchema } from '../schemas.js';
import { SseEvent } from '../sse-events.js';
import type { EventPort } from '../ports/index.js';
import type { EventPort, TabLayoutPort } from '../ports/index.js';
import { ownerLayoutKey } from '../../tab-layout-persistence.js';
import {
buildDownstreamResponseHeaders,
buildProxyCorsHeaders,
@@ -98,14 +99,14 @@ function withWebviews<T>(fn: (list: Webview[]) => Promise<T> | T): Promise<T> {
return next;
}
export function registerWebviewRoutes(app: FastifyInstance, ctx: EventPort): void {
export function registerWebviewRoutes(app: FastifyInstance, ctx: EventPort & TabLayoutPort): void {
registerCrudRoutes(app, ctx);
registerProxyRoutes(app);
}
// ───────────────────────────── CRUD ─────────────────────────────
function registerCrudRoutes(app: FastifyInstance, ctx: EventPort): void {
function registerCrudRoutes(app: FastifyInstance, ctx: EventPort & TabLayoutPort): void {
app.get('/api/webviews', async (req) => {
const user = getAuthUser(req);
const all = await readWebviews(configDir());
@@ -145,6 +146,21 @@ function registerCrudRoutes(app: FastifyInstance, ctx: EventPort): void {
.send(createErrorResponse(ApiErrorCode.INVALID_INPUT, `Webview limit reached (max ${MAX_WEBVIEWS})`));
}
try {
await ctx.tabLayouts.webviewCreated(ownerLayoutKey(created.owner));
} catch (error) {
// The saved webview and its layout ref are one logical creation. If the
// layout rejects the new ref (for example at MAX_TAB_REFS), roll back the
// already-written JSON record and publish neither creation event.
await withWebviews(async (list) => {
const index = list.findIndex((webview) => webview.id === created.id);
if (index >= 0) {
list.splice(index, 1);
await writeWebviews(configDir(), list);
}
});
throw error;
}
ctx.broadcast(SseEvent.WebviewChanged, { action: 'created', id: created.id });
return { success: true, data: created };
});
@@ -187,9 +203,19 @@ function registerCrudRoutes(app: FastifyInstance, ctx: EventPort): void {
const index = list.findIndex((w) => w.id === id);
if (index === -1) return 'not-found' as const;
if (!canAccessOwned(user, list[index].owner)) return 'forbidden' as const;
const removed = list[index];
list.splice(index, 1);
await writeWebviews(configDir(), list);
return 'deleted' as const;
try {
await ctx.tabLayouts.webviewDeleted(ownerLayoutKey(removed.owner), id);
} catch (error) {
// Still inside withWebviews' mutex: restore the exact record at its
// original position without overwriting any concurrent mutation.
list.splice(index, 0, removed);
await writeWebviews(configDir(), list);
throw error;
}
return { status: 'deleted' as const, owner: removed.owner };
});
if (result === 'not-found') {
+88 -25
View File
@@ -50,6 +50,8 @@ import { RespawnController, RespawnConfig } from '../respawn-controller.js';
import type { TerminalMultiplexer } from '../mux-interface.js';
import { createMultiplexer } from '../mux-factory.js';
import { getStore } from '../state-store.js';
import { TabLayoutService } from '../tab-layout-service.js';
import { readWebviews } from '../webview-store.js';
import { extractCompletionPhrase } from '../ralph-config.js';
import { fileStreamManager } from '../file-stream-manager.js';
import {
@@ -81,6 +83,7 @@ import { applyWorkspaceHooks } from '../hooks-config.js';
import { PushSubscriptionStore } from '../push-store.js';
import webpush from 'web-push';
import { SseStreamManager } from './sse-stream-manager.js';
import { deriveTabLayoutSseHint } from './tab-layout-sse.js';
import {
type SessionListenerRefs,
createSessionListeners,
@@ -170,6 +173,7 @@ import {
registerWsRoutes,
registerVoiceRoutes,
registerWebviewRoutes,
registerTabLayoutRoutes,
tryWebviewRefererFallback,
} from './routes/index.js';
import { CronService } from '../cron/cron-service.js';
@@ -247,6 +251,7 @@ export class WebServer extends EventEmitter {
private cronService!: CronService;
private sse: SseStreamManager;
private store = getStore();
private tabLayouts!: TabLayoutService;
private port: number;
private host: string;
private https: boolean;
@@ -350,6 +355,17 @@ export class WebServer extends EventEmitter {
},
this.cleanup
);
this.tabLayouts = new TabLayoutService({
store: this.store,
sessions: this.sessions,
readWebviews: () => readWebviews(getDataDir()),
broadcast: this.broadcast.bind(this),
broadcastSessionOrder: (change) => {
this.cachedLightState = null;
this.sse.broadcastSessionOrder(change);
},
});
if (this.testMode) this.tabLayouts.markRestorationSkipped();
// Approvals Inbox → SSE. The singleton has no server reference; these
// callbacks are its only way out. Broadcasts carry sessionId, so the
@@ -595,6 +611,17 @@ export class WebServer extends EventEmitter {
}
}
/** Add a tentative session only after its owner layout accepts the creation. */
private async registerSessionWithLayout(session: Session): Promise<void> {
this.sessions.set(session.id, session);
try {
await this.tabLayouts.sessionCreated(session.owner ?? '@single');
} catch (error) {
this.sessions.delete(session.id);
throw error;
}
}
/**
* Build a route context object satisfying all 5 port interfaces.
* Single object with zero runtime cost — ISP enforced at the type level.
@@ -605,9 +632,8 @@ export class WebServer extends EventEmitter {
return {
// SessionPort
sessions: this.sessions as ReadonlyMap<string, Session>,
addSession: (session: Session) => {
this.sessions.set(session.id, session);
},
addSession: this.registerSessionWithLayout.bind(this),
tabLayouts: this.tabLayouts,
cleanupSession: this.cleanupSession.bind(this),
setupSessionListeners: this.setupSessionListeners.bind(this),
persistSessionState: this.persistSessionState.bind(this),
@@ -991,6 +1017,7 @@ export class WebServer extends EventEmitter {
registerAdminRoutes(this.app, ctx);
registerOrchestratorRoutes(this.app, ctx);
registerWebviewRoutes(this.app, ctx);
registerTabLayoutRoutes(this.app, ctx);
// Cron: build the service from the same context, recompute
// due times for any persisted jobs, then expose it to its routes.
@@ -1154,8 +1181,19 @@ export class WebServer extends EventEmitter {
}
}
private async _doCleanupSession(sessionId: string, killMux: boolean, reason?: string): Promise<void> {
private async _doCleanupSession(
sessionId: string,
killMux: boolean,
reason?: string,
coordinateLayout = true
): Promise<void> {
const session = this.sessions.get(sessionId);
const pinned = session?.pinned === true || this.store.getSession(sessionId)?.pinned === true;
if (coordinateLayout && session && killMux && !pinned) {
return this.tabLayouts.runSessionDeletion([{ id: sessionId, owner: session.owner }], () =>
this._doCleanupSession(sessionId, killMux, reason, false)
);
}
const lifecycleLog = getLifecycleLog();
lifecycleLog.log({
event: killMux ? 'deleted' : 'detached',
@@ -1857,7 +1895,7 @@ export class WebServer extends EventEmitter {
// mode) so the flag-off path stays byte-identical.
session = new Session({ workingDir: run.workingDir });
}
this.sessions.set(session.id, session);
await this.registerSessionWithLayout(session);
this.store.incrementSessionsCreated();
this.persistSessionState(session);
await this.setupSessionListeners(session);
@@ -1987,9 +2025,11 @@ export class WebServer extends EventEmitter {
* Called on startup and can be called via API endpoint.
* @returns Number of sessions cleaned up
*/
private cleanupStaleSessions(): number {
private async cleanupStaleSessions(): Promise<number> {
const activeSessionIds = new Set(this.sessions.keys());
const result = this.store.cleanupStaleSessions(activeSessionIds);
const result = await this.tabLayouts.runStaleSessionCleanup(activeSessionIds, (ids) =>
this.store.cleanupSessionsByIds(ids)
);
const lifecycleLog = getLifecycleLog();
for (const s of result.cleaned) {
lifecycleLog.log({ event: 'stale_cleaned', sessionId: s.id, name: s.name });
@@ -2013,11 +2053,13 @@ export class WebServer extends EventEmitter {
/** Shallow-filter the light-state blob to what a non-admin user may see. */
private filterLightStateForUser(base: Record<string, unknown>, username: string): Record<string, unknown> {
const ownedIds = new Set<string>();
const authoritativeOwners = new Map<string, string | undefined>();
for (const [id, session] of Object.entries(this.store.getSessions())) authoritativeOwners.set(id, session.owner);
for (const [id, session] of this.sessions) authoritativeOwners.set(id, session.owner);
const ownedIds = new Set([...authoritativeOwners].filter(([, owner]) => owner === username).map(([id]) => id));
const ownedClaudeIds = new Set<string>();
for (const [id, s] of this.sessions) {
for (const s of this.sessions.values()) {
if (s.owner === username) {
ownedIds.add(id);
if (s.claudeSessionId) ownedClaudeIds.add(s.claudeSessionId);
}
}
@@ -2035,6 +2077,9 @@ export class WebServer extends EventEmitter {
const filtered: Record<string, unknown> = {
...base,
sessions,
sessionOrder: Array.isArray(base.sessionOrder)
? (base.sessionOrder as string[]).filter((id) => ownedIds.has(id))
: [],
respawnStatus,
scheduledRuns: [], // legacy ScheduledRun has no owner yet → admin-only
subagents: bySession(base.subagents, 'sessionId'),
@@ -2071,6 +2116,7 @@ export class WebServer extends EventEmitter {
const result = {
version: APP_VERSION,
sessions: this.getLightSessionsState(),
sessionOrder: this.store.getSessionOrder(),
scheduledRuns: Array.from(this.scheduledRuns.values()),
respawnStatus,
globalStats: this.store.getAggregateStats(activeSessionTokens),
@@ -2079,7 +2125,6 @@ export class WebServer extends EventEmitter {
timestamp: now,
inputCjkForm: process.env.INPUT_CJK_FORM?.toUpperCase() === 'ON',
planUsage: getLatestPlanUsage(), // last-known plan-usage telemetry, for the header chip on fresh load
sessionOrder: this.store.getSessionOrder(), // global tab order, synced across devices (COD-131)
};
this.cachedLightState = { data: result, timestamp: now };
@@ -2118,6 +2163,11 @@ export class WebServer extends EventEmitter {
) {
return { adminOnly: true };
}
// Layout payloads contain only trusted routing metadata. Route them to that
// exact owner plus admins, never by resolving a client-supplied ref.
if (event.startsWith('tab:')) {
return deriveTabLayoutSseHint(data);
}
// Session-scoped families: resolve the owner from the payload's session id.
const SESSION_PREFIXES = [
'session:',
@@ -2363,26 +2413,26 @@ export class WebServer extends EventEmitter {
// This prevents race conditions where clients connect before state is ready
// CRITICAL: Skip in test mode to prevent tests from picking up user sessions
if (!this.testMode) {
await this.restoreMuxSessions();
const restored = await this.restoreMuxSessions();
await this.finalizeRestoredState(restored);
// Instance-scoped reaper: after restore, `docker rm -f` managed containers of
// THIS instance whose case is gone from docker-cases.json (best-effort, never
// touches another instance's containers). Runs after restore so containers
// still referenced by a restored session are preserved.
void import('../docker-hosts.js')
.then(({ reapOrphanedDockerContainers }) => reapOrphanedDockerContainers(getDataDir(), CODEMAN_INSTANCE))
.then((reaped) => {
if (reaped.length > 0)
console.log(`[Docker] reaped ${reaped.length} orphaned container(s): ${reaped.join(', ')}`);
})
.catch(() => {
/* best-effort — daemon may be absent */
});
if (restored) {
void import('../docker-hosts.js')
.then(({ reapOrphanedDockerContainers }) => reapOrphanedDockerContainers(getDataDir(), CODEMAN_INSTANCE))
.then((reaped) => {
if (reaped.length > 0)
console.log(`[Docker] reaped ${reaped.length} orphaned container(s): ${reaped.join(', ')}`);
})
.catch(() => {
/* best-effort — daemon may be absent */
});
}
}
// Clean up stale sessions from state file that don't have active mux sessions
this.cleanupStaleSessions();
// Bound disk use under heavy paste-image traffic: delete `paste-*` files
// older than 7 days from each live session's .claude-images/ hourly.
if (!this.testMode) {
@@ -2618,7 +2668,7 @@ export class WebServer extends EventEmitter {
return false;
}
private async restoreMuxSessions(): Promise<void> {
private async restoreMuxSessions(): Promise<boolean> {
try {
// Reconcile mux sessions to find which ones are still alive (also discovers unknown ones)
const { alive, dead, discovered } = await this.mux.reconcileSessions();
@@ -2897,11 +2947,24 @@ export class WebServer extends EventEmitter {
if (dead.length > 0) {
console.log(`[Server] Cleaned up ${dead.length} dead mux session(s)`);
}
return true;
} catch (err) {
console.error('[Server] Failed to restore mux sessions:', err);
return false;
}
}
/** Unlock destructive reconciliation only after mux restoration fully succeeds. */
private async finalizeRestoredState(restored: boolean): Promise<void> {
if (!restored) {
this.tabLayouts.markRestorationFailed();
return;
}
this.tabLayouts.markRestorationComplete();
await this.cleanupStaleSessions();
await this.tabLayouts.reconcileAfterRestoration();
}
/**
* Install Codeman's hooks into the workspaces of the sessions just recovered.
*
+15
View File
@@ -0,0 +1,15 @@
/** @fileoverview Trusted per-recipient payload selection for legacy session-order invalidations. */
import type { SessionOrderProjectionChange } from '../tab-layout-service.js';
import type { AuthUser } from '../types.js';
export function sessionOrderPayloadFor(
identity: AuthUser | undefined,
change: SessionOrderProjectionChange
): { order: string[] } | undefined {
if (!identity || identity.role === 'admin') {
return change.globalChanged ? { order: [...change.globalOrder] } : undefined;
}
if (!Object.hasOwn(change.changedOwnerOrders, identity.username)) return undefined;
const order = change.changedOwnerOrders[identity.username];
return Array.isArray(order) ? { order: [...order] } : undefined;
}
+6 -3
View File
@@ -5,7 +5,7 @@
* and referenced by the frontend (`SSE_EVENTS` in `constants.js`).
* Both files MUST be kept in sync.
*
* 155 event constants organized by category:
* 156 event constants organized by category:
* - **Core** (1): init
* - **Transport** (1): sse:heartbeat
* - **Session lifecycle** (23): created, updated, deleted, terminal, idle, working, ...
@@ -25,14 +25,14 @@
* - **Plan orchestration** (5): started, progress, subagent, completed, cancelled
* - **Tunnel** (7): started, stopped, progress, error, qrRotated, qrRegenerated, qrAuthUsed
* - **Image / attachments** (2): image:detected, attachment:detected
* - **Hooks** (8): idle_prompt, permission_prompt, elicitation_dialog, elicitation_complete, elicitation_response, stop, teammate_idle, task_completed
* - **Hooks** (9): idle_prompt, permission_prompt, elicitation_dialog, elicitation_complete, elicitation_response, stop, teammate_idle, task_completed, suppressed
* - **Approvals** (3): pending, updated, resolved (cross-session Approvals Inbox)
* - **Orchestrator** (12): stateChanged, planProgress, planReady, phase*, verification, task*, completed, error
* - **Clipboard** (1): write
* - **Cases** (4): created, linked, deleted, order-changed
* - **Docker cases** (8): exportComplete/Failed, importComplete, imageBuild*, containerRecreated
* - **Multi-user** (3): admin:usersChanged, auth:passwordChangeRequired, session:orderChanged
* - **Web tabs** (1): webview:changed
* - **Web tabs** (2): webview:changed, tab:layoutChanged
*
* Naming convention: `domain:action` (e.g., `session:created`, `respawn:stateChanged`)
*
@@ -449,6 +449,8 @@ export const SessionOrderChanged = 'session:orderChanged' as const;
* Payload: `{ action: 'created' | 'updated' | 'deleted', id }`. The client
* re-fetches the list rather than patching from the payload. */
export const WebviewChanged = 'webview:changed' as const;
/** Owner-scoped layout invalidation. Payload contains only `{ owner, version }`. */
export const TabLayoutChanged = 'tab:layoutChanged' as const;
// ─── Namespace Re-export ─────────────────────────────────────────────────────
@@ -665,4 +667,5 @@ export const SseEvent = {
// Web tabs (dashboard URLs)
WebviewChanged,
TabLayoutChanged,
} as const;
+85 -26
View File
@@ -17,9 +17,11 @@
import type { FastifyReply } from 'fastify';
import type { BackgroundTask } from '../session.js';
import type { SessionOrderProjectionChange } from '../tab-layout-service.js';
import type { AuthUser } from '../types.js';
import { CleanupManager, StaleExpirationMap } from '../utils/index.js';
import { SseEvent } from './sse-events.js';
import { sessionOrderPayloadFor } from './session-order-sse.js';
import {
TERMINAL_BATCH_INTERVAL,
TASK_UPDATE_BATCH_INTERVAL,
@@ -34,6 +36,7 @@ import {
// Appending SSE comment padding (ignored by EventSource) forces the proxy to flush.
// Pre-computed once at startup to avoid repeated string allocation.
const SSE_PADDING = ':' + 'p'.repeat(SSE_PADDING_SIZE) + '\n';
const UNROUTED_TAB_LAYOUT = Symbol('unrouted-tab-layout');
/** Dependencies injected by WebServer — keeps SseStreamManager decoupled from session/respawn state. */
interface SseStreamManagerDeps {
@@ -77,6 +80,10 @@ export class SseStreamManager {
private remoteSseClients: Set<FastifyReply> = new Set();
/** Clients with backpressure — skip writes until 'drain' fires */
private backpressuredClients: Set<FastifyReply> = new Set();
/** Latest already recipient-filtered legacy order frame awaiting a client's drain. */
private pendingSessionOrderFrames: Map<FastifyReply, string> = new Map();
/** Latest owner-filtered tab-layout invalidation per affected owner awaiting a client's drain. */
private pendingTabLayoutFrames: Map<FastifyReply, Map<string | symbol, string>> = new Map();
// ─── Tunnel State ───────────────────────────────────────
/** Cached tunnel active state — updated on TunnelStarted/TunnelStopped to avoid getUrl() on every broadcast */
@@ -144,10 +151,7 @@ export class SseStreamManager {
// If a previous reply registered the same id (reconnect), drop the old one.
const prev = this.sseClientsById.get(clientId);
if (prev && prev !== reply) {
this.sseClients.delete(prev);
this.remoteSseClients.delete(prev);
this.backpressuredClients.delete(prev);
this.sseClientIdentity.delete(prev);
this.removeClient(prev);
}
this.sseClientsById.set(clientId, reply);
}
@@ -157,6 +161,8 @@ export class SseStreamManager {
this.sseClients.delete(reply);
this.remoteSseClients.delete(reply);
this.backpressuredClients.delete(reply);
this.pendingSessionOrderFrames.delete(reply);
this.pendingTabLayoutFrames.delete(reply);
this.sseClientIdentity.delete(reply);
// Clear any clientId mappings pointing at this reply
for (const [id, r] of this.sseClientsById) {
@@ -199,8 +205,7 @@ export class SseStreamManager {
try {
reply.raw.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
} catch {
this.sseClients.delete(reply);
this.remoteSseClients.delete(reply);
this.removeClient(reply);
}
}
@@ -210,7 +215,44 @@ export class SseStreamManager {
try {
reply.raw.write(SSE_PADDING);
} catch {
/* client gone */
this.removeClient(reply);
}
}
private markBackpressured(reply: FastifyReply): void {
this.backpressuredClients.add(reply);
reply.raw.once('drain', () => this.flushBackpressuredClient(reply));
}
private flushBackpressuredClient(reply: FastifyReply): void {
if (!this.sseClients.has(reply)) return;
this.backpressuredClients.delete(reply);
try {
const drainPadding = this._isTunnelActive ? SSE_PADDING : '';
const recovered = reply.raw.write(`event: ${SseEvent.SessionNeedsRefresh}\ndata: {}\n\n${drainPadding}`);
if (!recovered) {
this.markBackpressured(reply);
return;
}
const pendingLayouts = this.pendingTabLayoutFrames.get(reply);
if (pendingLayouts) {
for (const [owner, pendingLayout] of pendingLayouts) {
pendingLayouts.delete(owner);
this.sendSSEPreformatted(reply, pendingLayout);
if (!this.sseClients.has(reply)) return;
if (this.backpressuredClients.has(reply)) {
if (pendingLayouts.size === 0) this.pendingTabLayoutFrames.delete(reply);
return;
}
}
this.pendingTabLayoutFrames.delete(reply);
}
const pendingOrder = this.pendingSessionOrderFrames.get(reply);
if (!pendingOrder) return;
this.pendingSessionOrderFrames.delete(reply);
this.sendSSEPreformatted(reply, pendingOrder);
} catch {
this.removeClient(reply);
}
}
@@ -224,24 +266,11 @@ export class SseStreamManager {
try {
const ok = reply.raw.write(message);
if (!ok) {
// Buffer is full — mark as backpressured, resume on drain
this.backpressuredClients.add(reply);
reply.raw.once('drain', () => {
this.backpressuredClients.delete(reply);
// Client may have missed terminal data during backpressure.
// Tell it to reload the active session's buffer to recover.
try {
const drainPadding = this._isTunnelActive ? SSE_PADDING : '';
reply.raw.write(`event: ${SseEvent.SessionNeedsRefresh}\ndata: {}\n\n${drainPadding}`);
} catch {
/* client gone */
}
});
// Buffer is full — mark as backpressured, resume on drain.
this.markBackpressured(reply);
}
} catch {
this.sseClients.delete(reply);
this.remoteSseClients.delete(reply);
this.backpressuredClients.delete(reply);
this.removeClient(reply);
}
}
@@ -276,6 +305,36 @@ export class SseStreamManager {
for (const [client] of this.sseClients) {
// Multi-user ownership routing (no-op for identity-less single-user clients).
if (!this.canDeliver(client, hint)) continue;
if (event === SseEvent.TabLayoutChanged && this.backpressuredClients.has(client)) {
const owner =
data !== null &&
typeof data === 'object' &&
Object.hasOwn(data, 'owner') &&
typeof (data as { owner?: unknown }).owner === 'string'
? (data as { owner: string }).owner
: (hint?.username ?? hint?.owner ?? UNROUTED_TAB_LAYOUT);
let pending = this.pendingTabLayoutFrames.get(client);
if (!pending) {
pending = new Map();
this.pendingTabLayoutFrames.set(client, pending);
}
pending.set(owner, message);
continue;
}
this.sendSSEPreformatted(client, message);
}
}
/** Dispatch the legacy order projection selected from each trusted client identity. */
broadcastSessionOrder(change: SessionOrderProjectionChange): void {
for (const [client] of this.sseClients) {
const payload = sessionOrderPayloadFor(this.sseClientIdentity.get(client), change);
if (!payload) continue;
const message = `event: ${SseEvent.SessionOrderChanged}\ndata: ${JSON.stringify(payload)}\n\n`;
if (this.backpressuredClients.has(client)) {
this.pendingSessionOrderFrames.set(client, message);
continue;
}
this.sendSSEPreformatted(client, message);
}
}
@@ -504,9 +563,7 @@ export class SseStreamManager {
// Remove dead clients
for (const client of deadClients) {
this.sseClients.delete(client);
this.remoteSseClients.delete(client);
this.backpressuredClients.delete(client);
this.removeClient(client);
}
if (deadClients.length > 0) {
@@ -553,6 +610,8 @@ export class SseStreamManager {
this.sseClients.clear();
this.remoteSseClients.clear();
this.backpressuredClients.clear();
this.pendingSessionOrderFrames.clear();
this.pendingTabLayoutFrames.clear();
// Clear per-session batch timers
for (const timer of this.terminalBatchTimers.values()) {
+6
View File
@@ -0,0 +1,6 @@
/** @fileoverview Trusted owner routing metadata for tab-layout invalidations. */
import type { SseRoutingHint } from './sse-stream-manager.js';
export function deriveTabLayoutSseHint(data: unknown): SseRoutingHint {
return { username: (data as { owner?: string }).owner, sessionScoped: true };
}