fix(terminal): reconcile project action executions across clients (#3362)

This commit is contained in:
Bohdan Triapitsyn
2026-09-05 12:05:39 +03:00
committed by GitHub
parent 4e0eed717d
commit d37ce34a2e
16 changed files with 640 additions and 216 deletions
@@ -60,6 +60,13 @@ interface MockedDetectedDevServer {
}
const createCalls: CreateTerminalOptions[] = [];
const createdSessionId = (index: number): string => {
const id = createCalls[index]?.sessionId;
if (!id) throw new Error('missing requested terminal ID');
return id;
};
const firstSessionId = () => createdSessionId(0);
const secondSessionId = () => createdSessionId(1);
const sendCalls: string[] = [];
const forceKillCalls: string[] = [];
const closeCalls: string[] = [];
@@ -81,7 +88,7 @@ const terminal = {
createCalls.push(options);
sessionCounter += 1;
return {
sessionId: `session-${sessionCounter}`,
sessionId: options.sessionId ?? `session-${sessionCounter}`,
cols: 80,
rows: 24,
status: 'running' as const,
@@ -243,6 +250,100 @@ describe('ProjectActionsButton lifecycle', () => {
await act(async () => { await Promise.resolve(); });
};
test('adopting a saved URL action does not auto-open a different output URL', async () => {
mockedActionsState.actions = [{ id: 'build', name: 'Build', command: 'echo hello', autoOpenUrl: true, openUrl: 'http://localhost:4000' }];
const originalList = terminal.listSessions;
Object.assign(terminal, { listSessions: async () => [{
sessionId: 'peer-run', cwd: '/repo', status: 'running', createdAt: 1, mode: 'command',
purpose: { type: 'project-action', actionId: 'build', executionId: 'peer-execution' },
}] });
try {
await renderButton();
await act(async () => { emitToSession('peer-run', { type: 'snapshot', sequence: 1, status: 'running', data: 'Local: http://localhost:5173/\n' }); });
expect(openExternalCalls).toEqual([]);
} finally { terminal.listSessions = originalList; }
});
test('a second run keeps its tab and displays fresh output under a new terminal ID', async () => {
const originalCreate = terminal.createSession;
Object.assign(terminal, { createSession: async (options: CreateTerminalOptions) => {
createCalls.push(options);
return { sessionId: options.sessionId, cols: 80, rows: 24, status: 'running', mode: 'command', purpose: options.purpose };
} });
try {
await renderButton();
const button = host.querySelector('button');
if (!button) throw new Error('missing action button');
await act(async () => { button.click(); });
const tab = useTerminalStore.getState().getDirectoryState('/repo')?.tabs.find((tab) => tab.purpose.type === 'project-action');
if (!tab?.terminalSessionId) throw new Error('missing action terminal');
const originalSessionId = tab.terminalSessionId;
await act(async () => {
emitToSession(originalSessionId, { type: 'snapshot', status: 'running', sequence: 5, data: 'old output' });
emitToSession(originalSessionId, { type: 'exit', sequence: 6 });
});
await act(async () => { button.click(); });
expect(secondSessionId()).not.toBe(firstSessionId());
await act(async () => {
emitToSession(secondSessionId(), { type: 'snapshot', status: 'running', sequence: 1, data: 'new output' });
});
const buffer = useTerminalStore.getState().getBuffer('/repo', tab.id);
expect(buffer.chunks.map(chunk => chunk.data).join('')).toBe('new output');
} finally { terminal.createSession = originalCreate; }
});
test('a stale rerun adopts the server run without closing it', async () => {
await renderButton();
const button = host.querySelector('button');
if (!button) throw new Error('missing action button');
await act(async () => { button.click(); });
await act(async () => { emitToSession(firstSessionId(), { type: 'exit', sequence: 2 }); });
const originalList = terminal.listSessions;
const serverSession = {
sessionId: firstSessionId(), cwd: '/repo', status: 'running', createdAt: 1,
mode: 'command', purpose: { type: 'project-action', actionId: 'build', executionId: 'other-client-new-run' },
};
Object.assign(terminal, { listSessions: async () => [serverSession] });
try {
await act(async () => { button.click(); });
expect(closeCalls).toEqual([]);
const tab = useTerminalStore.getState().getDirectoryState('/repo')?.tabs.find((tab) => tab.purpose.type === 'project-action');
expect(tab?.purpose).toEqual(serverSession.purpose);
} finally {
terminal.listSessions = originalList;
}
});
test('parent action manual URL opens in the current worktree panel', async () => {
effectiveDirectory = '/repo-worktree';
mockedActionsState.actions = [{ id: 'build', name: 'Build', command: 'echo hello', runIn: 'parent', autoOpenUrl: true, openUrl: 'http://localhost:4000' }];
await renderButton({ projectPath: '/repo', directory: '/repo-worktree' });
const button = host.querySelector('button');
if (!button) throw new Error('missing action button');
await act(async () => { button.click(); });
expect(openContextPreviewCalls).toEqual([{ directory: '/repo-worktree', url: 'http://localhost:4000/' }]);
});
test('replaced executions cannot be cleared by their still-open old stream', async () => {
await renderButton();
const button = host.querySelector('button');
if (!button) throw new Error('missing action button');
await act(async () => { button.click(); });
await act(async () => {
useTerminalStore.getState().reconcileServerSessions('/repo', [{
sessionId: 'other-session', cwd: '/repo', status: 'running', createdAt: 2,
mode: 'command', purpose: { type: 'project-action', actionId: 'build', executionId: 'new-execution' },
}]);
});
await act(async () => { emitToSession(firstSessionId(), { type: 'exit', sequence: 10 }); });
const tab = useTerminalStore.getState().getDirectoryState('/repo')?.tabs.find((tab) => tab.purpose.type === 'project-action');
expect(tab?.purpose).toEqual({ type: 'project-action', actionId: 'build', executionId: 'new-execution' });
expect(tab?.lifecycle).toBe('running');
});
test('runs, stops, and reruns on the same action tab while cleaning old subscriptions once', async () => {
await renderButton();
@@ -257,7 +358,7 @@ describe('ProjectActionsButton lifecycle', () => {
});
const firstTab = useTerminalStore.getState().getDirectoryState('/repo')?.tabs.find((tab) => tab.purpose.type === 'project-action');
expect(firstTab?.terminalSessionId).toBe('session-1');
expect(firstTab?.terminalSessionId).toBe(firstSessionId());
expect(firstTab?.purpose.type).toBe('project-action');
const firstExecution = firstTab?.purpose.type === 'project-action' ? firstTab.purpose.executionId : null;
expect(firstExecution).not.toBeNull();
@@ -279,16 +380,16 @@ describe('ProjectActionsButton lifecycle', () => {
const rerunTab = useTerminalStore.getState().getDirectoryState('/repo')?.tabs.find((tab) => tab.purpose.type === 'project-action');
expect(rerunTab?.id).toBe(firstTab?.id);
expect(rerunTab?.terminalSessionId).toBe('session-2');
expect(rerunTab?.terminalSessionId).toBe(secondSessionId());
expect(rerunTab?.lifecycle).toBe('running');
const secondExecution = rerunTab?.purpose.type === 'project-action' ? rerunTab.purpose.executionId : null;
expect(secondExecution).not.toBeNull();
expect(secondExecution).not.toBe(firstExecution);
expect(createCalls).toHaveLength(2);
expect(sendCalls).toEqual(['session-1:\x03']);
expect(sendCalls).toEqual([`${firstSessionId()}:\x03`]);
expect(forceKillCalls).toEqual([]);
expect(closeCalls).toEqual(['session-1']);
expect(closeCalls).toEqual([]);
expect(subscriptions.map((entry) => entry.closed)).toEqual([1, 1, 0]);
});
@@ -372,10 +473,10 @@ describe('ProjectActionsButton lifecycle', () => {
const autoDiscoverTab = useTerminalStore.getState().getDirectoryState('/repo')?.tabs.find((tab) => (
tab.purpose.type === 'project-action' && tab.purpose.actionId === '__openchamber_auto_discover_preview__'
));
expect(autoDiscoverTab?.terminalSessionId).toBe('session-1');
expect(autoDiscoverTab?.terminalSessionId).toBe(firstSessionId());
await act(async () => {
emitToSession('session-1', {
emitToSession(firstSessionId(), {
type: 'data',
data: 'Ready at http://127.0.0.1:4321\n',
sequence: 1,
@@ -490,7 +591,7 @@ describe('ProjectActionsButton lifecycle', () => {
expect(openExternalCalls).toEqual([]);
await act(async () => {
emitToSession('session-1', {
emitToSession(firstSessionId(), {
type: 'data',
data: 'Server listening at http://127.0.0.1:4000\n',
sequence: 1,
@@ -42,6 +42,7 @@ import {
reconcileTerminalSessionAuthority,
stopProjectActionTerminalSession,
} from '@/lib/projectActionTerminal';
import { observeTerminalSessions } from '@/lib/terminalSessionObserver';
import type { TerminalTab } from '@/stores/useTerminalStore';
type UrlWatchEntry = {
@@ -350,13 +351,16 @@ export const ProjectActionsButton = ({
React.useEffect(() => {
const watchedDirectories = new Set(watchedTerminalDirectories);
for (const watch of Object.values(urlWatchByRunKeyRef.current)) {
if (!watchedDirectories.has(watch.directory)) clearExecutionUi(watch.directory, watch.actionId, watch.executionId);
}
for (const executionStateKey of Object.keys(streamCleanupByRunKeyRef.current)) {
const executionDirectory = executionStateKey.split('::', 1)[0] ?? '';
if (!watchedDirectories.has(executionDirectory)) {
closeTrackedSubscription(executionStateKey);
}
}
}, [closeTrackedSubscription, watchedTerminalDirectories]);
}, [clearExecutionUi, closeTrackedSubscription, watchedTerminalDirectories]);
const revealProjectActionTerminal = React.useCallback((hostDirectory: string, executionDirectory: string) => {
useUIStore.getState().openContextPanelTab(hostDirectory, {
@@ -391,23 +395,13 @@ export const ProjectActionsButton = ({
}, [loadActions]);
React.useEffect(() => {
if (!terminal.listSessions) {
return;
}
let cancelled = false;
for (const executionDirectory of watchedTerminalDirectories) {
void reconcileTerminalSessionAuthority(terminal, executionDirectory, {
captureStartedActionMutationRevisions,
}).then((result) => {
if (cancelled || !result) return;
reconcileServerSessions(executionDirectory, result.sessions, {
startedActionMutationRevisions: result.startedActionMutationRevisions,
});
});
}
return () => {
cancelled = true;
};
const cleanups = watchedTerminalDirectories.map(executionDirectory => observeTerminalSessions(
terminal, executionDirectory, captureStartedActionMutationRevisions,
result => reconcileServerSessions(executionDirectory, result.sessions, {
startedActionMutationRevisions: result.startedActionMutationRevisions,
}),
));
return () => { for (const close of cleanups) close(); };
}, [captureStartedActionMutationRevisions, reconcileServerSessions, terminal, watchedTerminalDirectories]);
React.useEffect(() => {
@@ -528,7 +522,7 @@ export const ProjectActionsButton = ({
actionId: entry.actionId,
executionId: entry.executionId,
lastSeenChunkId: null,
openedUrl: false,
openedUrl: true,
tail: '',
openInPreview: false,
announced: [],
@@ -734,6 +728,7 @@ export const ProjectActionsButton = ({
}
if (startingRunKeysRef.current.has(runKey)) return;
startingRunKeysRef.current.add(runKey);
let requestedExecution: { directory: string; tabId: string; id: string } | null = null;
try {
const discovered = action.id === AUTO_DISCOVER_ACTION_ID
@@ -760,7 +755,7 @@ export const ProjectActionsButton = ({
const hasCustomOpenUrl = discovered.autoOpenUrl === true && (discovered.openUrl || '').trim().length > 0;
const revealTerminal = !hasCustomOpenUrl && action.id !== AUTO_DISCOVER_ACTION_ID;
const launchContextHostDirectory = contextHostDirectoryRef.current || normalizedDirectory;
const { executionDirectory, key, tabId, sessionId } = await getOrCreateActionTab(discovered);
const { executionDirectory, key, tabId } = await getOrCreateActionTab(discovered);
const normalizedCommand = normalizeProjectActionCommand(discovered.command);
if (!normalizedCommand) {
throw new Error(t('projectActions.error.failedToRunAction'));
@@ -789,26 +784,23 @@ export const ProjectActionsButton = ({
}
const priorTab = getActionTab(executionDirectory, discovered.id);
const priorExecutionId = priorTab?.purpose.type === 'project-action' ? priorTab.purpose.executionId : null;
if (priorExecutionId) {
clearExecutionUi(executionDirectory, discovered.id, priorExecutionId);
}
const requestedExecutionId = allocateActionExecution(executionDirectory, tabId, discovered.id);
if (!requestedExecutionId) {
throw new Error(t('projectActions.error.failedToCreateTerminalSession'));
}
setConnecting(executionDirectory, tabId, true, { expectedExecutionId: requestedExecutionId });
let activeSessionId: string | null = null;
let adoptedExecutionId = requestedExecutionId;
try {
let activeSessionId: string;
let adoptedExecutionId: string;
if (priorTab?.lifecycle === 'running' && priorTab.terminalSessionId
&& priorTab.purpose.type === 'project-action' && priorTab.purpose.executionId) {
activeSessionId = priorTab.terminalSessionId;
adoptedExecutionId = priorTab.purpose.executionId;
} else {
const priorExecutionId = priorTab?.purpose.type === 'project-action' ? priorTab.purpose.executionId : null;
if (priorExecutionId) clearExecutionUi(executionDirectory, discovered.id, priorExecutionId);
const requestedExecutionId = allocateActionExecution(executionDirectory, tabId, discovered.id);
if (!requestedExecutionId) throw new Error(t('projectActions.error.failedToCreateTerminalSession'));
requestedExecution = { directory: executionDirectory, tabId, id: requestedExecutionId };
setConnecting(executionDirectory, tabId, true, { expectedExecutionId: requestedExecutionId });
const created = await createProjectActionTerminalSession({
terminal,
previousSessionId: sessionId,
createOptions: {
cwd: executionDirectory,
sessionId: tabId,
shell: terminalShell,
loginShell: terminalLoginShell,
themeMode: currentTheme.metadata.variant === 'light' ? 'light' : 'dark',
@@ -820,40 +812,41 @@ export const ProjectActionsButton = ({
purpose: { type: 'project-action', actionId: discovered.id, executionId: requestedExecutionId },
});
if (!matchesActionExecution(executionDirectory, tabId, requestedExecutionId)) {
await terminal.close(created.sessionId).catch(() => undefined);
if (created.sessionId === requestedExecutionId) await terminal.close(created.sessionId).catch(() => undefined);
return;
}
adoptedExecutionId = created.purpose?.type === 'project-action' ? created.purpose.executionId : requestedExecutionId;
setTabPurpose(executionDirectory, tabId, { type: 'project-action', actionId: discovered.id, executionId: adoptedExecutionId });
activeSessionId = created.sessionId;
setTabPurpose(executionDirectory, tabId, { type: 'project-action', actionId: discovered.id, executionId: adoptedExecutionId });
setTabSessionId(executionDirectory, tabId, activeSessionId, { expectedExecutionId: adoptedExecutionId });
setTabLifecycle(executionDirectory, tabId, 'running', { expectedExecutionId: adoptedExecutionId });
} finally {
setConnecting(executionDirectory, tabId, false, { expectedExecutionId: adoptedExecutionId });
}
if (!activeSessionId) {
throw new Error(t('projectActions.error.failedToCreateTerminalSession'));
}
if (!matchesActionExecution(executionDirectory, tabId, adoptedExecutionId)) {
try {
await terminal.close(activeSessionId);
} catch {
// noop
}
return;
}
if (revealTerminal && launchContextHostDirectory) {
revealProjectActionTerminal(launchContextHostDirectory, executionDirectory);
}
urlWatchByRunKeyRef.current[key] = {
hostDirectory: launchContextHostDirectory,
directory: executionDirectory,
tabId,
actionId: discovered.id,
executionId: adoptedExecutionId,
lastSeenChunkId: null,
openedUrl: Boolean(desktopForwardUrl) || Boolean(manualOpenUrl) || hasCustomOpenUrl,
tail: '',
openInPreview: discovered.id === AUTO_DISCOVER_ACTION_ID,
announced: [],
offering: false,
};
const executionStateKey = executionKey(executionDirectory, discovered.id, adoptedExecutionId);
setConnecting(executionDirectory, tabId, true, { expectedExecutionId: adoptedExecutionId });
const subscription = terminal.connect(
activeSessionId,
{ onEvent: (event) => {
if (!matchesActionExecution(executionDirectory, tabId, adoptedExecutionId)) return;
if (event.purpose?.type === 'project-action' && event.purpose.executionId !== adoptedExecutionId) return;
if (event.type === 'snapshot') {
useTerminalStore.getState().replaceBuffer(executionDirectory, tabId, event.data ?? '', event.sequence ?? 0);
useTerminalStore.getState().setConnecting(executionDirectory, tabId, false, { expectedExecutionId: adoptedExecutionId });
@@ -880,6 +873,7 @@ export const ProjectActionsButton = ({
clearExecutionUi(executionDirectory, discovered.id, adoptedExecutionId);
}
}, onError: (_error, fatal) => {
if (!matchesActionExecution(executionDirectory, tabId, adoptedExecutionId)) return;
useTerminalStore.getState().setConnecting(executionDirectory, tabId, false, { expectedExecutionId: adoptedExecutionId });
if (fatal) {
useTerminalStore.getState().setTabLifecycle(executionDirectory, tabId, 'exited', { expectedExecutionId: adoptedExecutionId });
@@ -893,6 +887,7 @@ export const ProjectActionsButton = ({
subscription.close();
return;
}
streamCleanupByRunKeyRef.current[executionStateKey]?.();
streamCleanupByRunKeyRef.current[executionStateKey] = subscription.close;
window.clearTimeout(previewWaitTimeoutByRunKeyRef.current[executionStateKey]);
@@ -919,19 +914,6 @@ export const ProjectActionsButton = ({
}, AUTO_DISCOVER_PREVIEW_WAIT_TIMEOUT_MS);
}
urlWatchByRunKeyRef.current[key] = {
hostDirectory: launchContextHostDirectory,
directory: executionDirectory,
tabId,
actionId: discovered.id,
executionId: adoptedExecutionId,
lastSeenChunkId: null,
openedUrl: Boolean(desktopForwardUrl) || Boolean(manualOpenUrl) || hasCustomOpenUrl,
tail: '',
openInPreview: discovered.id === AUTO_DISCOVER_ACTION_ID,
announced: [],
offering: false,
};
if (desktopForwardUrl) {
setTabPreviewUrl(executionDirectory, tabId, null, { locked: true, expectedExecutionId: adoptedExecutionId });
@@ -939,7 +921,7 @@ export const ProjectActionsButton = ({
toast.success(t('projectActions.toast.openedForwardedUrl'));
} else if (manualOpenUrl) {
setTabPreviewUrl(executionDirectory, tabId, manualOpenUrl, { locked: true, autoOpened: true, expectedExecutionId: adoptedExecutionId });
openContextPreview(executionDirectory, manualOpenUrl);
openContextPreview(launchContextHostDirectory, manualOpenUrl);
toast.success(t('projectActions.toast.openedActionUrl'));
} else if (hasCustomOpenUrl) {
setTabPreviewUrl(executionDirectory, tabId, null, { locked: true, expectedExecutionId: adoptedExecutionId });
@@ -952,12 +934,11 @@ export const ProjectActionsButton = ({
}
} catch (error) {
const executionDirectory = executionDirectoryFor(action);
const currentTab = getActionTab(executionDirectory, action.id);
if (currentTab?.purpose.type === 'project-action' && currentTab.purpose.executionId) {
clearExecutionUi(executionDirectory, action.id, currentTab.purpose.executionId);
setTabLifecycle(executionDirectory, currentTab.id, 'exited', { expectedExecutionId: currentTab.purpose.executionId });
setTabPurpose(executionDirectory, currentTab.id, { type: 'project-action', actionId: action.id, executionId: null });
if (requestedExecution && matchesActionExecution(requestedExecution.directory, requestedExecution.tabId, requestedExecution.id)) {
const { directory: failedDirectory, tabId: failedTabId, id } = requestedExecution;
clearExecutionUi(failedDirectory, action.id, id);
setTabLifecycle(failedDirectory, failedTabId, 'exited', { expectedExecutionId: id });
setTabPurpose(failedDirectory, failedTabId, { type: 'project-action', actionId: action.id, executionId: null });
}
if (error instanceof Error && error.message === 'PROJECT_ACTION_RUN_CANCELLED') {
return;
@@ -335,6 +335,25 @@ describe('TerminalView project action tab indicator', () => {
expect(targetKey).toContain('/repo');
});
test('replacing an exited action does not show a connection failure', async () => {
const store = useTerminalStore.getState();
const actionTab = store.getDirectoryState('/repo')?.tabs.find(tab => tab.label === 'Build');
if (!actionTab) throw new Error('action tab missing');
store.setTabSessionId('/repo', actionTab.id, 'srv-build');
store.setActiveTab('/repo', actionTab.id);
connectBehavior = (_sessionId, handlers) => {
void Promise.resolve().then(() => {
handlers.onError?.(Object.assign(new Error('Terminal replaced by a new action run'), { code: 'SUPERSEDED' }), true);
});
return { close: () => undefined };
};
await act(async () => root.render(React.createElement(TerminalView, { visible: true })));
await flushEffects();
expect(host.textContent).not.toContain('terminalView.error.connectionFailed');
expect(store.getActiveTab('/repo')?.terminalSessionId).toBeNull();
expect(store.getActiveTab('/repo')?.lifecycle).toBe('exited');
});
test('would fail if revisit attach skipped the active running project-action snapshot restore', async () => {
const state = useTerminalStore.getState().getDirectoryState('/repo');
const actionTab = state?.tabs.find((tab) => tab.label === 'Build');
@@ -23,7 +23,7 @@ import { PROJECT_ACTION_ICONS } from '@/lib/projectActions';
import { useInlineCommentDraftStore } from '@/stores/useInlineCommentDraftStore';
import { applyTerminalModifier, terminalControlCharacter, terminalSequenceForKey, type TerminalModifier as Modifier, type TerminalQuickKey as MobileKey } from '@/lib/terminalInput';
import { formatShortcutForDisplay } from '@/lib/shortcuts';
import { reconcileTerminalSessionAuthority } from '@/lib/projectActionTerminal';
import { observeTerminalSessions } from '@/lib/terminalSessionObserver';
type TerminalViewProps = {
visible?: boolean;
@@ -200,29 +200,15 @@ export const TerminalView: React.FC<TerminalViewProps> = ({ visible, directory }
directoryRef.current = terminalDirectory;
}, [terminalDirectory]);
// The tab list is a per-client projection, so ask the server what actually
// exists for this directory and adopt sessions no local tab references
// (another device, a fresh browser tab, or a reload with cleared storage).
// A failed listing changes nothing: adoption is additive only.
// Only a visible panel requests discovery; failed reads preserve known state.
React.useEffect(() => {
if (!terminalHydrated || !terminalDirectory || !terminal.listSessions) {
return;
}
let cancelled = false;
const directory = terminalDirectory;
void reconcileTerminalSessionAuthority(terminal, directory, {
captureStartedActionMutationRevisions,
})
.then((result) => {
if (cancelled || directoryRef.current !== directory || !result) return;
reconcileServerSessions(directory, result.sessions, {
startedActionMutationRevisions: result.startedActionMutationRevisions,
});
if (!terminalHydrated || !terminalDirectory || !isTerminalVisible) return;
return observeTerminalSessions(terminal, terminalDirectory, captureStartedActionMutationRevisions, result => {
reconcileServerSessions(terminalDirectory, result.sessions, {
startedActionMutationRevisions: result.startedActionMutationRevisions,
});
return () => {
cancelled = true;
};
}, [captureStartedActionMutationRevisions, terminalHydrated, terminalDirectory, terminal, reconcileServerSessions]);
});
}, [captureStartedActionMutationRevisions, isTerminalVisible, terminalHydrated, terminalDirectory, terminal, reconcileServerSessions]);
React.useEffect(() => {
if (!showQuickKeys && activeModifier !== null) {
@@ -306,12 +292,15 @@ export const TerminalView: React.FC<TerminalViewProps> = ({ visible, directory }
// Mark active before connect so early events aren't dropped.
activeTerminalIdRef.current = terminalId;
const ownsStream = () => activeTerminalIdRef.current === terminalId
&& useTerminalStore.getState().getDirectoryState(directory)?.tabs
.some(tab => tab.id === tabId && tab.terminalSessionId === terminalId);
const subscription = terminal.connect(
terminalId,
{
onEvent: (event: TerminalStreamEvent) => {
if (activeTerminalIdRef.current !== terminalId) {
if (!ownsStream()) {
return;
}
@@ -375,7 +364,7 @@ export const TerminalView: React.FC<TerminalViewProps> = ({ visible, directory }
}
},
onError: (error, fatal) => {
if (activeTerminalIdRef.current !== terminalId) {
if (!ownsStream()) {
return;
}
@@ -398,10 +387,9 @@ export const TerminalView: React.FC<TerminalViewProps> = ({ visible, directory }
return;
}
}
setConnectionError(
t('terminalView.error.connectionFailed', { message: error.message })
);
setIsFatalError(true);
const superseded = error.code === 'SUPERSEDED';
setConnectionError(superseded ? null : t('terminalView.error.connectionFailed', { message: error.message }));
setIsFatalError(!superseded);
setConnecting(directory, tabId, false);
setTabLifecycle(directory, tabId, 'exited');
setTabSessionId(directory, tabId, null);
@@ -45,12 +45,12 @@ describe('project action terminal lifecycle', () => {
expect(normalizeProjectActionCommand(' printf "hi"\r\nexit\u0007 ')).toBe('printf "hi"\nexit');
});
test('closes the previous session before creating a command-mode run', async () => {
test('creates a command-mode run under its execution ID', async () => {
const calls: string[] = [];
const terminal: TerminalAPI = {
createSession: async (options) => {
calls.push(`create:${JSON.stringify(options)}`);
return { sessionId: 'tab-1', cols: 80, rows: 24, status: 'running', mode: 'command', purpose: { type: 'project-action', actionId: 'build', executionId: 'exec-1' } };
return { sessionId: 'exec-1', cols: 80, rows: 24, status: 'running', mode: 'command', purpose: { type: 'project-action', actionId: 'build', executionId: 'exec-1' } };
},
connect: () => ({ close: () => {} }),
sendInput: async () => {},
@@ -62,27 +62,24 @@ describe('project action terminal lifecycle', () => {
const created = await createProjectActionTerminalSession({
terminal,
previousSessionId: 'stale-session',
createOptions: {
cwd: '/repo',
sessionId: 'tab-1',
},
command: 'echo hello',
isRunStillExpected: () => true,
purpose: { type: 'project-action', actionId: 'build', executionId: 'exec-1' },
});
expect(created).toEqual({ sessionId: 'tab-1', cols: 80, rows: 24, status: 'running', mode: 'command', purpose: { type: 'project-action', actionId: 'build', executionId: 'exec-1' } });
expect(created).toEqual({ sessionId: 'exec-1', cols: 80, rows: 24, status: 'running', mode: 'command', purpose: { type: 'project-action', actionId: 'build', executionId: 'exec-1' } });
expect(calls).toEqual([
'close:stale-session',
'create:{"cwd":"/repo","sessionId":"tab-1","mode":"command","command":"echo hello","purpose":{"type":"project-action","actionId":"build","executionId":"exec-1"}}',
'create:{"cwd":"/repo","sessionId":"exec-1","mode":"command","command":"echo hello","purpose":{"type":"project-action","actionId":"build","executionId":"exec-1"}}',
]);
});
test('rejects and closes a create response that does not echo command mode', async () => {
const closed: string[] = [];
const terminal: TerminalAPI = {
createSession: async () => ({ sessionId: 'tab-1', cols: 80, rows: 24, status: 'running' }),
createSession: async () => ({ sessionId: 'exec-1', cols: 80, rows: 24, status: 'running' }),
connect: () => ({ close: () => {} }),
sendInput: async () => {},
resize: async () => {},
@@ -93,22 +90,20 @@ describe('project action terminal lifecycle', () => {
await expect(createProjectActionTerminalSession({
terminal,
previousSessionId: null,
createOptions: {
cwd: '/repo',
sessionId: 'tab-1',
},
command: 'echo hello',
isRunStillExpected: () => true,
purpose: { type: 'project-action', actionId: 'build', executionId: 'exec-1' },
})).rejects.toThrow('COMMAND_MODE_UNSUPPORTED');
expect(closed).toEqual(['tab-1']);
expect(closed).toEqual(['exec-1']);
});
test('closes a newly created command session when stop removes the run during create', async () => {
const closed: string[] = [];
const terminal: TerminalAPI = {
createSession: async () => ({ sessionId: 'tab-1', cols: 80, rows: 24, status: 'running', mode: 'command', purpose: { type: 'project-action', actionId: 'build', executionId: 'exec-1' } }),
createSession: async () => ({ sessionId: 'exec-1', cols: 80, rows: 24, status: 'running', mode: 'command', purpose: { type: 'project-action', actionId: 'build', executionId: 'exec-1' } }),
connect: () => ({ close: () => {} }),
sendInput: async () => {},
resize: async () => {},
@@ -119,22 +114,20 @@ describe('project action terminal lifecycle', () => {
await expect(createProjectActionTerminalSession({
terminal,
previousSessionId: null,
createOptions: {
cwd: '/repo',
sessionId: 'tab-1',
},
command: 'echo hello',
isRunStillExpected: () => false,
purpose: { type: 'project-action', actionId: 'build', executionId: 'exec-1' },
})).rejects.toThrow('PROJECT_ACTION_RUN_CANCELLED');
expect(closed).toEqual(['tab-1']);
expect(closed).toEqual(['exec-1']);
});
test('rejects and closes a create response that does not echo project-action purpose', async () => {
const closed: string[] = [];
const terminal: TerminalAPI = {
createSession: async () => ({ sessionId: 'tab-1', cols: 80, rows: 24, status: 'running', mode: 'command' }),
createSession: async () => ({ sessionId: 'exec-1', cols: 80, rows: 24, status: 'running', mode: 'command' }),
connect: () => ({ close: () => {} }),
sendInput: async () => {},
resize: async () => {},
@@ -145,13 +138,12 @@ describe('project action terminal lifecycle', () => {
await expect(createProjectActionTerminalSession({
terminal,
previousSessionId: null,
createOptions: { cwd: '/repo', sessionId: 'tab-1' },
createOptions: { cwd: '/repo' },
command: 'echo hello',
isRunStillExpected: () => true,
purpose: { type: 'project-action', actionId: 'build', executionId: 'exec-1' },
})).rejects.toThrow('PROJECT_ACTION_PURPOSE_UNSUPPORTED');
expect(closed).toEqual(['tab-1']);
expect(closed).toEqual(['exec-1']);
});
test('reuses one in-flight authority listing per directory', async () => {
@@ -293,3 +285,19 @@ describe('project action terminal lifecycle', () => {
expect(finalized).toBe(0);
});
});
test('cancelling a local request does not close a run adopted from another client', async () => {
const closed: string[] = [];
const terminal: TerminalAPI = {
createSession: async () => ({ sessionId: 'other-client', cols: 80, rows: 24, status: 'running', mode: 'command', purpose: { type: 'project-action', actionId: 'build', executionId: 'other-execution' } }),
connect: () => ({ close() {} }),
sendInput: async () => {}, resize: async () => {},
close: async id => { closed.push(id); },
};
await expect(createProjectActionTerminalSession({
terminal, createOptions: { cwd: '/repo' }, command: 'echo hello',
purpose: { type: 'project-action', actionId: 'build', executionId: 'requested-execution' },
isRunStillExpected: () => false,
})).rejects.toThrow('PROJECT_ACTION_RUN_CANCELLED');
expect(closed).toEqual([]);
});
+16 -17
View File
@@ -1,3 +1,4 @@
import { getRuntimeKey } from './runtime-switch';
import type { CreateTerminalOptions, TerminalAPI, TerminalServerSession, TerminalSession, TerminalSessionPurpose } from './api/types';
type TerminalActionMutationRevisions = ReadonlyMap<string, number>;
@@ -10,11 +11,10 @@ const normalizeDirectory = (dir: string): string => {
return normalized;
};
type ProjectActionTerminalCreateOptions = Omit<Extract<CreateTerminalOptions, { mode: 'command' }>, 'mode' | 'command'>;
type ProjectActionTerminalCreateOptions = Omit<Extract<CreateTerminalOptions, { mode: 'command' }>, 'mode' | 'command' | 'sessionId'>;
type CreateProjectActionTerminalSessionOptions = {
terminal: TerminalAPI;
previousSessionId: string | null;
createOptions: ProjectActionTerminalCreateOptions;
command: string;
isRunStillExpected: () => boolean;
@@ -46,8 +46,9 @@ const closeTerminalSession = async (terminal: TerminalAPI, sessionId: string): P
}
};
const rejectCreatedSession = async (terminal: TerminalAPI, sessionId: string, errorMessage: string): Promise<never> => {
await closeTerminalSession(terminal, sessionId);
const rejectCreatedSession = async (terminal: TerminalAPI, session: TerminalSession, requestedExecutionId: string, errorMessage: string): Promise<never> => {
// A deduplicated response belongs to the peer that created it.
if (session.sessionId === requestedExecutionId) await closeTerminalSession(terminal, session.sessionId);
throw createProjectActionTerminalError(errorMessage);
};
@@ -93,33 +94,29 @@ type ReconcileTerminalSessionAuthorityResult = {
export const createProjectActionTerminalSession = async ({
terminal,
previousSessionId,
createOptions,
command,
isRunStillExpected,
purpose,
}: CreateProjectActionTerminalSessionOptions): Promise<TerminalSession> => {
if (previousSessionId) {
await closeTerminalSession(terminal, previousSessionId);
}
const created = await terminal.createSession({
...createOptions,
sessionId: purpose.executionId,
mode: 'command',
command: normalizeProjectActionCommand(command),
purpose,
});
if (!isCommandTerminalSession(created)) {
await rejectCreatedSession(terminal, created.sessionId, COMMAND_MODE_UNSUPPORTED_ERROR);
await rejectCreatedSession(terminal, created, purpose.executionId, COMMAND_MODE_UNSUPPORTED_ERROR);
}
if (!isMatchingProjectActionPurpose(created.purpose, purpose.actionId)) {
await rejectCreatedSession(terminal, created.sessionId, PROJECT_ACTION_PURPOSE_UNSUPPORTED_ERROR);
await rejectCreatedSession(terminal, created, purpose.executionId, PROJECT_ACTION_PURPOSE_UNSUPPORTED_ERROR);
}
if (!isRunStillExpected()) {
await rejectCreatedSession(terminal, created.sessionId, PROJECT_ACTION_RUN_CANCELLED_ERROR);
await rejectCreatedSession(terminal, created, purpose.executionId, PROJECT_ACTION_RUN_CANCELLED_ERROR);
}
return created;
@@ -228,12 +225,14 @@ export const reconcileTerminalSessionAuthority = (
}
const normalizedDirectory = normalizeDirectory(directory);
const runtimeKey = getRuntimeKey();
const flightKey = `${runtimeKey}\u0000${normalizedDirectory}`;
let terminalFlights = reconcileFlightsByTerminal.get(terminal);
if (!terminalFlights) {
terminalFlights = new Map();
reconcileFlightsByTerminal.set(terminal, terminalFlights);
}
const existing = terminalFlights.get(normalizedDirectory);
const existing = terminalFlights.get(flightKey);
if (existing) {
return existing;
}
@@ -241,16 +240,16 @@ export const reconcileTerminalSessionAuthority = (
const startedActionMutationRevisions = options.captureStartedActionMutationRevisions?.(normalizedDirectory)
?? new Map<string, number>();
const flight = terminal.listSessions(normalizedDirectory)
.then((sessions) => ({ sessions, startedActionMutationRevisions }))
.then((sessions) => runtimeKey === getRuntimeKey() ? { sessions, startedActionMutationRevisions } : null)
.catch(() => null)
.finally(() => {
if (terminalFlights.get(normalizedDirectory) === flight) {
terminalFlights.delete(normalizedDirectory);
if (terminalFlights.get(flightKey) === flight) {
terminalFlights.delete(flightKey);
if (terminalFlights.size === 0) {
reconcileFlightsByTerminal.delete(terminal);
}
}
});
terminalFlights.set(normalizedDirectory, flight);
terminalFlights.set(flightKey, flight);
return flight;
};
+2 -2
View File
@@ -352,7 +352,7 @@ describe('terminal transport', () => {
transport.dispose();
});
test('preserves valid snapshot purpose and safely drops malformed snapshot purpose', async () => {
test('preserves valid snapshot purpose and rejects a malformed restarted frame', async () => {
const socket = new FakeSocket();
const transport = new TerminalTransport({ refreshAuth: async () => '', openSocket: () => socket });
const purposes: Array<string | null> = [];
@@ -368,7 +368,7 @@ describe('terminal transport', () => {
socket.emit({ t: 'snapshot', v: 3, s: 'term-1', q: 1, history: 'prompt', status: 'running', purpose: { type: 'project-action', actionId: 'build', executionId: 'exec-1' } });
socket.emit({ t: 'restarted', v: 3, s: 'term-1', q: 2, history: 'prompt 2', purpose: { type: 'project-action', actionId: 'build' } });
await tick();
expect(purposes).toEqual(['exec-1', 'exec-1']);
expect(purposes).toEqual(['exec-1']);
transport.dispose();
});
+45 -56
View File
@@ -1,30 +1,16 @@
import type { CreateTerminalOptions, TerminalError, TerminalHandlers, TerminalServerSession, TerminalSession, TerminalSessionPurpose, TerminalShellOption, TerminalStreamEvent } from './api/types';
import { openRuntimeWebSocket } from './relay/runtime-socket';
import type { RelayTunnelWebSocket } from './relay/tunnel-client';
import type { RelayTunnelSocketMessageEvent, RelayTunnelWebSocket } from './relay/tunnel-client';
import { runtimeFetch } from './runtime-fetch';
import { getRuntimeUrlResolver } from './runtime-url';
import { clearRuntimeUrlAuthToken, refreshRuntimeUrlAuthToken } from './runtime-auth';
import { isTerminalShell } from './terminalShell';
import { z } from 'zod';
type Message = Record<string, unknown> & {
t: string;
s?: string;
q?: number;
d?: string;
r?: string;
history?: string;
status?: TerminalStreamEvent['status'];
exitCode?: number;
signal?: number | null;
runtime?: TerminalStreamEvent['runtime'];
ptyBackend?: string;
mode?: TerminalSession['mode'];
purpose?: TerminalSessionPurposeInput;
message?: string;
code?: string;
fatal?: boolean;
};
type ClientMessage =
| { t: 'hello' | 'ping'; v: 3 }
| { t: 'attach' | 'detach'; v: 3; s: string }
| { t: 'write'; v: 3; s: string; d: string };
type Subscriber = { handlers: TerminalHandlers; lastSequence: number };
type TerminalProjection = {
sequence: number;
@@ -87,7 +73,27 @@ const terminalServerSessionSchema = z.object({
purpose: terminalSessionPurposeSchema.optional(),
});
const encode = (message: Message): Uint8Array => {
const terminalMessageMetadata = {
mode: terminalModeSchema.optional(),
purpose: terminalSessionPurposeSchema.optional(),
};
const terminalMessageSchema = z.discriminatedUnion('t', [
z.object({ t: z.literal('hello') }),
z.object({ t: z.literal('pong') }),
z.object({ t: z.literal('error'), s: z.string().optional(), message: z.string().optional(), code: z.string().optional(), fatal: z.boolean().optional() }),
z.object({
t: z.literal('snapshot'), s: z.string(), q: z.number().int().nonnegative().default(0),
history: z.string().default(''), status: terminalStatusSchema,
exitCode: z.number().nullish().transform(value => value ?? undefined), signal: z.number().nullable().optional(),
runtime: terminalRuntimeSchema.optional(), ptyBackend: z.string().optional(), ...terminalMessageMetadata,
}),
z.object({ t: z.literal('output'), s: z.string(), q: z.number().int().nonnegative(), d: z.string(), r: z.string().optional() }),
z.object({ t: z.literal('exit'), s: z.string(), q: z.number().int().nonnegative(), exitCode: z.number().nullish().transform(value => value ?? undefined), signal: z.number().nullable().optional() }),
z.object({ t: z.literal('restarted'), s: z.string(), q: z.number().int().nonnegative(), history: z.string().default(''), ...terminalMessageMetadata }),
]);
type TerminalMessage = z.infer<typeof terminalMessageSchema>;
const encode = (message: ClientMessage): Uint8Array => {
const payload = encoder.encode(JSON.stringify(message));
const frame = new Uint8Array(payload.length + 1);
frame[0] = TAG;
@@ -95,15 +101,10 @@ const encode = (message: Message): Uint8Array => {
return frame;
};
const decode = async (data: unknown): Promise<Message | null> => {
let bytes: Uint8Array;
if (data instanceof ArrayBuffer) bytes = new Uint8Array(data);
else if (data instanceof Uint8Array) bytes = data;
else if (typeof Blob !== 'undefined' && data instanceof Blob) bytes = new Uint8Array(await data.arrayBuffer());
else if (typeof data === 'string') bytes = encoder.encode(data);
else return null;
const decode = (data: RelayTunnelSocketMessageEvent['data']): TerminalMessage | null => {
let bytes = data instanceof ArrayBuffer ? new Uint8Array(data) : encoder.encode(data);
if (bytes[0] === TAG) bytes = bytes.subarray(1);
try { return JSON.parse(decoder.decode(bytes)) as Message; } catch { return null; }
try { return terminalMessageSchema.safeParse(JSON.parse(decoder.decode(bytes))).data ?? null; } catch { return null; }
};
const responseError = async (response: Response, fallback: string): Promise<Error> => {
@@ -121,18 +122,6 @@ const trimProjection = (value: string): string => {
const terminalSessionListSchema = z.object({ sessions: z.array(z.unknown()) });
const parseTerminalMode = (value: TerminalSession['mode'] | null | undefined): TerminalSession['mode'] | undefined => {
return terminalModeSchema.safeParse(value).data;
};
const parseTerminalStatus = (value: TerminalStreamEvent['status'] | null | undefined): TerminalStreamEvent['status'] => {
return terminalStatusSchema.safeParse(value).data ?? 'running';
};
const parseTerminalRuntime = (value: TerminalStreamEvent['runtime'] | null | undefined): TerminalStreamEvent['runtime'] | undefined => {
return terminalRuntimeSchema.safeParse(value).data;
};
export const parseTerminalSessionPurpose = (value: TerminalSessionPurposeInput): TerminalSessionPurpose | undefined => {
return terminalSessionPurposeSchema.safeParse(value).data;
};
@@ -330,12 +319,12 @@ export class TerminalTransport {
}
}
private async handleMessage(raw: unknown): Promise<void> {
const message = await decode(raw);
private handleMessage(raw: RelayTunnelSocketMessageEvent['data']): void {
const message = decode(raw);
if (!message || message.t === 'hello' || message.t === 'pong') return;
if (message.t === 'error') {
const error = new Error(typeof message.message === 'string' ? message.message : 'Terminal error') as TerminalError;
if (typeof message.code === 'string') error.code = message.code;
const error: TerminalError = new Error(message.message ?? 'Terminal error');
error.code = message.code;
const targets = message.s ? [message.s] : [...this.subscribers.keys()];
for (const id of targets) for (const sub of this.subscribers.get(id) ?? []) sub.handlers.onError?.(error, message.fatal === true);
return;
@@ -347,12 +336,12 @@ export class TerminalTransport {
const projection: TerminalProjection = {
sequence: message.q ?? 0,
history: message.history ?? '',
status: parseTerminalStatus(message.status),
mode: parseTerminalMode(message.mode),
purpose: parseTerminalSessionPurpose(message.purpose),
status: message.status,
mode: message.mode,
purpose: message.purpose,
exitCode: message.exitCode,
signal: message.signal ?? null,
runtime: parseTerminalRuntime(message.runtime),
runtime: message.runtime,
ptyBackend: message.ptyBackend,
};
this.projections.set(message.s, projection);
@@ -362,23 +351,23 @@ export class TerminalTransport {
}
return;
}
if (typeof message.q !== 'number') return;
const previous = this.projections.get(message.s);
if (previous && message.q > previous.sequence) {
if (message.t === 'output') this.projections.set(message.s, { ...previous, sequence: message.q, history: trimProjection(previous.history + (typeof message.r === 'string' ? message.r : (typeof message.d === 'string' ? message.d : ''))) });
else if (message.t === 'exit') this.projections.set(message.s, { ...previous, sequence: message.q, status: 'exited', exitCode: typeof message.exitCode === 'number' ? message.exitCode : undefined, signal: typeof message.signal === 'number' ? message.signal : null });
else if (message.t === 'restarted') this.projections.set(message.s, { ...previous, sequence: message.q, history: message.history ?? '', status: 'running', mode: parseTerminalMode(message.mode) ?? previous.mode, purpose: parseTerminalSessionPurpose(message.purpose) ?? previous.purpose, exitCode: undefined, signal: null });
if (message.t === 'output') this.projections.set(message.s, { ...previous, sequence: message.q, history: trimProjection(previous.history + (message.r ?? message.d)) });
else if (message.t === 'exit') this.projections.set(message.s, { ...previous, sequence: message.q, status: 'exited', exitCode: message.exitCode, signal: message.signal ?? null });
else if (message.t === 'restarted') this.projections.set(message.s, { ...previous, sequence: message.q, history: message.history ?? '', status: 'running', mode: message.mode ?? previous.mode, purpose: message.purpose ?? previous.purpose, exitCode: undefined, signal: null });
}
for (const sub of subscribers) {
if (message.q <= sub.lastSequence) continue;
sub.lastSequence = message.q;
if (message.t === 'output') sub.handlers.onEvent({ type: 'data', sequence: message.q, data: typeof message.d === 'string' ? message.d : '', replayData: typeof message.r === 'string' ? message.r : undefined });
else if (message.t === 'exit') sub.handlers.onEvent({ type: 'exit', sequence: message.q, exitCode: typeof message.exitCode === 'number' ? message.exitCode : undefined, signal: typeof message.signal === 'number' ? message.signal : null });
else if (message.t === 'restarted') sub.handlers.onEvent({ type: 'snapshot', sequence: message.q, data: message.history ?? '', status: 'running', mode: parseTerminalMode(message.mode) ?? previous?.mode, purpose: parseTerminalSessionPurpose(message.purpose) ?? previous?.purpose });
if (message.t === 'output') sub.handlers.onEvent({ type: 'data', sequence: message.q, data: message.d, replayData: message.r });
else if (message.t === 'exit') sub.handlers.onEvent({ type: 'exit', sequence: message.q, exitCode: message.exitCode, signal: message.signal ?? null });
else if (message.t === 'restarted') sub.handlers.onEvent({ type: 'snapshot', sequence: message.q, data: message.history ?? '', status: 'running', mode: message.mode ?? previous?.mode, purpose: message.purpose ?? previous?.purpose });
}
}
private send(message: Message): boolean {
private send(message: ClientMessage): boolean {
if (!this.socket || this.socket.readyState !== SOCKET_OPEN) return false;
try { this.socket.send(encode(message)); return true; } catch { return false; }
}
@@ -0,0 +1,93 @@
import { afterEach, beforeEach, expect, test } from 'bun:test';
import { Window } from 'happy-dom';
import type { TerminalAPI, TerminalServerSession } from './api/types';
import { observeTerminalSessions } from './terminalSessionObserver';
import { useTerminalStore } from '@/stores/useTerminalStore';
let browser: Window;
const descriptors = new Map<string, PropertyDescriptor | undefined>();
const cleanups: Array<() => void> = [];
const tick = () => new Promise(resolve => setTimeout(resolve, 0));
beforeEach(() => {
browser = new Window({ url: 'http://localhost' });
for (const [key, value] of Object.entries({ window: browser, document: browser.document, navigator: browser.navigator })) {
descriptors.set(key, Object.getOwnPropertyDescriptor(globalThis, key));
Object.defineProperty(globalThis, key, { value, configurable: true });
}
useTerminalStore.getState().clearAll();
});
afterEach(async () => {
for (const close of cleanups.splice(0)) close();
await browser.happyDOM.close();
for (const [key, descriptor] of descriptors) {
if (descriptor) Object.defineProperty(globalThis, key, descriptor);
else Reflect.deleteProperty(globalThis, key);
}
});
const createTerminal = () => {
let records: TerminalServerSession[] = [];
let failed = false;
const reads: string[] = [];
const terminal: TerminalAPI = {
listSessions: async directory => {
reads.push(directory);
if (failed) throw new Error('offline');
return records;
},
createSession: async () => { throw new Error('unused'); },
connect: () => ({ close() {} }), sendInput: async () => {}, resize: async () => {}, close: async () => {},
};
return { terminal, reads, setRecords: (next: TerminalServerSession[]) => { records = next; }, fail: (value: boolean) => { failed = value; } };
};
const running: TerminalServerSession = { sessionId: 'peer-run', cwd: '/repo', status: 'running', createdAt: 1, mode: 'command', purpose: { type: 'project-action', actionId: 'build', executionId: 'peer-run' } };
test('visible consumers share one loop and discover a later peer run without interaction', async () => {
const source = createTerminal();
const first: TerminalServerSession[][] = [];
const second: TerminalServerSession[][] = [];
cleanups.push(observeTerminalSessions(source.terminal, '/repo', () => new Map(), result => first.push(result.sessions)));
cleanups.push(observeTerminalSessions(source.terminal, '/repo', () => new Map(), result => second.push(result.sessions)));
await tick();
expect(source.reads).toEqual(['/repo']);
source.setRecords([running]);
await new Promise(resolve => setTimeout(resolve, 5100));
expect(source.reads).toEqual(['/repo', '/repo']);
expect(first.at(-1)).toEqual([running]);
expect(second.at(-1)).toEqual([running]);
}, 10000);
test('hidden and offline scopes stop reads, wake on recovery, and preserve state on failure', async () => {
const source = createTerminal();
source.setRecords([running]);
const store = useTerminalStore.getState();
cleanups.push(observeTerminalSessions(source.terminal, '/repo', store.captureStartedActionMutationRevisions, result => {
store.reconcileServerSessions('/repo', result.sessions, { startedActionMutationRevisions: result.startedActionMutationRevisions });
}));
await tick();
Object.defineProperty(browser.document, 'visibilityState', { value: 'hidden', configurable: true });
browser.document.dispatchEvent(new browser.Event('visibilitychange'));
browser.dispatchEvent(new browser.Event('focus'));
await tick();
expect(source.reads).toHaveLength(1);
Object.defineProperty(browser.document, 'visibilityState', { value: 'visible', configurable: true });
Object.defineProperty(browser.navigator, 'onLine', { value: false, configurable: true });
browser.document.dispatchEvent(new browser.Event('visibilitychange'));
await tick();
expect(source.reads).toHaveLength(1);
source.fail(true);
Object.defineProperty(browser.navigator, 'onLine', { value: true, configurable: true });
browser.dispatchEvent(new browser.Event('online'));
await tick();
expect(source.reads).toHaveLength(2);
expect(store.getActiveTab('/repo')?.lifecycle).toBe('running');
source.fail(false);
source.setRecords([]);
browser.dispatchEvent(new browser.Event('focus'));
await tick();
expect(store.getActiveTab('/repo')?.lifecycle).toBe('exited');
for (const close of cleanups.splice(0)) close();
browser.dispatchEvent(new browser.Event('focus'));
await tick();
expect(source.reads).toHaveLength(3);
});
@@ -0,0 +1,80 @@
import type { TerminalAPI } from './api/types';
import { reconcileTerminalSessionAuthority } from './projectActionTerminal';
import { getRuntimeKey, subscribeRuntimeEndpointChanged } from './runtime-switch';
const REFRESH_INTERVAL_MS = 5_000;
type AuthorityResult = NonNullable<Awaited<ReturnType<typeof reconcileTerminalSessionAuthority>>>;
type RevisionCapture = (directory: string) => ReadonlyMap<string, number>;
type Listener = (result: AuthorityResult) => void;
type Observation = { listeners: Set<Listener>; refresh: () => void; close: () => void };
const observations = new WeakMap<TerminalAPI, Map<string, Observation>>();
/** One visible-demand loop per adapter/directory, shared by the header and panel. */
export const observeTerminalSessions = (
terminal: TerminalAPI,
directory: string,
captureStartedActionMutationRevisions: RevisionCapture,
listener: Listener,
): (() => void) => {
if (!terminal.listSessions) return () => {};
let directories = observations.get(terminal);
if (!directories) {
directories = new Map();
observations.set(terminal, directories);
}
let observation = directories.get(directory);
if (!observation) {
const listeners = new Set<Listener>();
let closed = false;
let inFlight = false;
let timer: ReturnType<typeof setTimeout> | null = null;
let generation = 0;
const active = () => document.visibilityState !== 'hidden' && navigator.onLine !== false;
const clearTimer = () => { if (timer !== null) clearTimeout(timer); timer = null; };
const refresh = () => {
clearTimer();
if (closed || inFlight || !active()) return;
inFlight = true;
const startedGeneration = generation;
const runtimeKey = getRuntimeKey();
void reconcileTerminalSessionAuthority(terminal, directory, { captureStartedActionMutationRevisions })
.then(result => {
if (closed || generation !== startedGeneration || runtimeKey !== getRuntimeKey() || !result) return;
for (const notify of listeners) notify(result);
})
.finally(() => {
inFlight = false;
if (!closed && active()) timer = setTimeout(refresh, REFRESH_INTERVAL_MS);
});
};
const runtimeChanged = () => { generation += 1; refresh(); };
window.addEventListener('focus', refresh);
window.addEventListener('online', refresh);
window.addEventListener('offline', clearTimer);
document.addEventListener('visibilitychange', refresh);
const stopRuntimeListener = subscribeRuntimeEndpointChanged(runtimeChanged);
observation = {
listeners,
refresh,
close: () => {
closed = true;
clearTimer();
window.removeEventListener('focus', refresh);
window.removeEventListener('online', refresh);
window.removeEventListener('offline', clearTimer);
document.removeEventListener('visibilitychange', refresh);
stopRuntimeListener();
},
};
directories.set(directory, observation);
}
observation.listeners.add(listener);
observation.refresh();
return () => {
observation.listeners.delete(listener);
if (observation.listeners.size > 0) return;
observation.close();
directories.delete(directory);
if (directories.size === 0) observations.delete(terminal);
};
};
+12
View File
@@ -124,6 +124,18 @@ Invariants to preserve when editing:
projection while both are referentially unchanged, and the storage adapter skips a write
for an unchanged projection, so streaming output performs no persistence work.
- Consumers that react to output must subscribe to `buffers`, not `sessions`.
- Action tab IDs remain stable while each command execution receives a fresh terminal ID.
Starting or adopting a different execution resets its buffer sequence and preview together;
reconnecting to the same execution and observing its exit preserve scrollback.
- Reconciliation selects one record per action before updating tabs. A running execution wins
over retained exited records independently of listing order. An in-progress stop remains
stopping until the same execution exits or explicit termination failure restores running.
- `terminalSessionObserver` shares one five-second refresh loop per terminal adapter and
demanded directory. Only visible, online headers/panels demand refreshes; focus and online
recovery refresh immediately. Failed reads preserve state, the last consumer stops the loop,
and responses from a replaced runtime cannot publish into the new runtime.
- Passive action adoption may restore output but has no launch-time authority to open browser
tabs. Preview navigation belongs to the initiating host directory even for a parent action.
- Server session listings capture the directory's per-action mutation revisions when the
request starts. Coalesced callers share that first snapshot. A response cannot replace or
remove an action execution mutated after its request began, while a fresh successful empty
@@ -331,7 +331,7 @@ describe('terminal state reconciliation', () => {
const tab = useTerminalStore.getState().getDirectoryState('/repo')!.tabs[0]!;
expect(tab.lifecycle).toBe('starting');
expect(tab.purpose).toEqual({ type: 'project-action', actionId: 'build', executionId });
expect(tab.terminalSessionId).toBe('srv-old');
expect(tab.terminalSessionId).toBeNull();
});
test('an old listed action session does not overwrite a newer running session after setTabSessionId', () => {
@@ -556,3 +556,49 @@ describe('directoryMayHaveActiveProjectAction', () => {
expect(directoryMayHaveActiveProjectAction(undefined)).toBe(false);
});
});
test('a retained exited run cannot replace the live run of the same action', () => {
const live: TerminalServerSession = { sessionId: 'a', cwd: '/repo', status: 'running', createdAt: 1, mode: 'command', purpose: { type: 'project-action', actionId: 'build', executionId: 'live' } };
const old: TerminalServerSession = { sessionId: 'b', cwd: '/repo', status: 'exited', createdAt: 2, mode: 'command', purpose: { type: 'project-action', actionId: 'build', executionId: 'old' } };
for (const sessions of [[live, old], [old, live]]) {
useTerminalStore.getState().clearAll();
useTerminalStore.getState().reconcileServerSessions('/repo', sessions);
const tabs = useTerminalStore.getState().getDirectoryState('/repo')?.tabs;
expect(tabs?.find((tab) => tab.purpose.type === 'project-action')?.terminalSessionId).toBe('a');
}
});
test('a fresh running listing does not cancel an in-progress stop of the same execution', () => {
useTerminalStore.getState().clearAll();
const session: TerminalServerSession = { sessionId: 'run', cwd: '/repo', status: 'running', createdAt: 1, purpose: { type: 'project-action', actionId: 'build', executionId: 'run' } };
const store = useTerminalStore.getState();
store.reconcileServerSessions('/repo', [session]);
store.setTabLifecycle('/repo', 'run', 'stopping');
store.reconcileServerSessions('/repo', [session], { startedActionMutationRevisions: store.captureStartedActionMutationRevisions('/repo') });
expect(store.getActiveTab('/repo')?.lifecycle).toBe('stopping');
});
test('adopting a replacement resets its buffer while reconnecting to the same run preserves it', () => {
useTerminalStore.getState().clearAll();
const store = useTerminalStore.getState();
const session: TerminalServerSession = { sessionId: 'run', cwd: '/repo', status: 'running', createdAt: 1, purpose: { type: 'project-action', actionId: 'build', executionId: 'run' } };
store.reconcileServerSessions('/repo', [session]);
store.replaceBuffer('/repo', 'run', 'old output', 5);
store.reconcileServerSessions('/repo', [session]);
expect(store.getBuffer('/repo', 'run').lastSequence).toBe(5);
store.reconcileServerSessions('/repo', [{ ...session, sessionId: 'replacement', purpose: { type: 'project-action', actionId: 'build', executionId: 'replacement' } }]);
store.replaceBuffer('/repo', 'run', 'new output', 1);
expect(store.getBuffer('/repo', 'run').chunks.map(chunk => chunk.data).join('')).toBe('new output');
});
test('a listing started before closing an action cannot resurrect its tab', () => {
useTerminalStore.getState().clearAll();
const store = useTerminalStore.getState();
const session: TerminalServerSession = { sessionId: 'run', cwd: '/repo', status: 'exited', createdAt: 1, purpose: { type: 'project-action', actionId: 'build', executionId: 'run' } };
store.reconcileServerSessions('/repo', [session]);
const startedActionMutationRevisions = store.captureStartedActionMutationRevisions('/repo');
store.closeTab('/repo', 'run');
store.reconcileServerSessions('/repo', [session], { startedActionMutationRevisions });
expect(store.getDirectoryState('/repo')?.tabs.some(tab => tab.purpose.type === 'project-action')).toBe(false);
});
+68 -9
View File
@@ -195,6 +195,27 @@ const toActionPurposeFromSession = (
executionId: status === 'running' ? purpose.executionId : null,
});
const authoritativeTerminalSessions = (sessions: TerminalServerSession[]): TerminalServerSession[] => {
const actions = new Map<string, TerminalServerSession>();
const terminals: TerminalServerSession[] = [];
for (const session of sessions) {
if (session.purpose?.type !== 'project-action') {
terminals.push(session);
continue;
}
const previous = actions.get(session.purpose.actionId);
if (!previous
|| (session.status === 'running' && previous.status !== 'running')
|| (session.status === previous.status && (
(session.createdAt ?? 0) > (previous.createdAt ?? 0)
|| (session.createdAt === previous.createdAt && session.sessionId > previous.sessionId)
))) actions.set(session.purpose.actionId, session);
}
return [...terminals, ...actions.values()];
};
const resetActionPreview = { previewUrl: null, previewAutoOpened: false, previewUrlLocked: false };
const toAdoptedActionLabel = (actionId: string): string => {
const trimmed = actionId.trim();
return trimmed || 'Action';
@@ -452,6 +473,7 @@ export const useTerminalStore = create<TerminalStore>()(
return state;
}
const tabs = [...(existing?.tabs ?? [])];
let buffers = state.buffers;
let tabsChanged = false;
let activationCandidate: { tabId: string; createdAt: number; index: number } | null = null;
const listedActionIds = new Set<string>();
@@ -465,7 +487,7 @@ export const useTerminalStore = create<TerminalStore>()(
: null;
if (placeholder && serverSessions.length > 0) tabs.length = 0;
for (const session of serverSessions) {
for (const session of authoritativeTerminalSessions(serverSessions)) {
let matchIndex = tabs.findIndex((tab) => tab.terminalSessionId === session.sessionId || tab.id === session.sessionId);
const sessionPurpose = session.purpose;
const staleActionAuthority = sessionPurpose?.type === 'project-action'
@@ -492,10 +514,21 @@ export const useTerminalStore = create<TerminalStore>()(
if (matchIndex >= 0) {
const current = tabs[matchIndex]!;
const nextLifecycle = current.purpose.type === 'project-action' || sessionPurpose?.type === 'project-action'
let nextLifecycle = current.purpose.type === 'project-action' || sessionPurpose?.type === 'project-action'
? toActionLifecycle(session.status)
: session.status;
if (current.lifecycle === 'stopping' && session.status === 'running'
&& current.purpose.type === 'project-action' && nextPurpose.type === 'project-action'
&& current.purpose.executionId === nextPurpose.executionId) nextLifecycle = 'stopping';
const nextCreatedAt = session.createdAt ?? current.createdAt;
const executionChanged = sessionPurpose?.type === 'project-action'
&& (current.terminalSessionId !== session.sessionId
|| (nextPurpose.type === 'project-action' && nextPurpose.executionId !== null
&& (current.purpose.type !== 'project-action' || current.purpose.executionId !== nextPurpose.executionId)));
if (executionChanged) {
const keyToDrop = bufferKey(key, current.id);
buffers = dropBufferKeys(buffers, (entry) => entry === keyToDrop) ?? buffers;
}
const activatesRunningAction = sessionPurpose?.type === 'project-action'
&& session.status === 'running'
&& (current.terminalSessionId !== session.sessionId || current.lifecycle !== 'running');
@@ -517,6 +550,7 @@ export const useTerminalStore = create<TerminalStore>()(
isConnecting: false,
createdAt: nextCreatedAt,
};
if (executionChanged) Object.assign(tabs[matchIndex], resetActionPreview);
tabsChanged = true;
}
if (activatesRunningAction) {
@@ -607,7 +641,7 @@ export const useTerminalStore = create<TerminalStore>()(
const newSessions = new Map(state.sessions);
newSessions.set(key, { tabs: reconciledTabs, activeTabId });
return { sessions: newSessions };
return { sessions: newSessions, buffers };
});
},
@@ -715,6 +749,15 @@ export const useTerminalStore = create<TerminalStore>()(
return state;
}
const closedPurpose = existing.tabs[idx].purpose;
let actionMutationRevisions = state.actionMutationRevisions;
let nextActionMutationRevision = state.nextActionMutationRevision;
if (closedPurpose.type === 'project-action') {
actionMutationRevisions = new Map(actionMutationRevisions);
updateActionMutationRevision(actionMutationRevisions, key, closedPurpose.actionId, nextActionMutationRevision);
nextActionMutationRevision += 1;
}
const mutationState = { actionMutationRevisions, nextActionMutationRevision };
const nextTabs = existing.tabs.filter((t) => t.id !== tabId);
const closedBufferKey = bufferKey(key, tabId);
const nextBuffers = state.buffers.has(closedBufferKey)
@@ -732,7 +775,7 @@ export const useTerminalStore = create<TerminalStore>()(
if (nextBuffers) {
nextState.buffers = nextBuffers;
}
return nextState;
return { ...nextState, ...mutationState };
}
let nextActive = existing.activeTabId;
@@ -753,7 +796,7 @@ export const useTerminalStore = create<TerminalStore>()(
if (nextBuffers) {
nextState.buffers = nextBuffers;
}
return nextState;
return { ...nextState, ...mutationState };
});
},
@@ -769,7 +812,14 @@ export const useTerminalStore = create<TerminalStore>()(
return state;
}
const nextTabs = [...existing.tabs];
const executionChanged = purpose.type === 'project-action' && purpose.executionId !== null
&& (current.purpose.type !== 'project-action' || current.purpose.executionId !== purpose.executionId);
nextTabs[idx] = { ...current, purpose };
if (executionChanged) Object.assign(nextTabs[idx], resetActionPreview);
const keyToDrop = bufferKey(key, tabId);
const buffers = executionChanged
? dropBufferKeys(state.buffers, (entry) => entry === keyToDrop) ?? state.buffers
: state.buffers;
const sessions = new Map(state.sessions);
sessions.set(key, { ...existing, tabs: nextTabs });
if (purpose.type !== 'project-action') {
@@ -777,7 +827,7 @@ export const useTerminalStore = create<TerminalStore>()(
}
const actionMutationRevisions = new Map(state.actionMutationRevisions);
updateActionMutationRevision(actionMutationRevisions, key, purpose.actionId, state.nextActionMutationRevision);
return { sessions, actionMutationRevisions, nextActionMutationRevision: state.nextActionMutationRevision + 1 };
return { sessions, buffers, actionMutationRevisions, nextActionMutationRevision: state.nextActionMutationRevision + 1 };
});
},
@@ -796,13 +846,17 @@ export const useTerminalStore = create<TerminalStore>()(
...nextTabs[idx]!,
purpose: { type: 'project-action', actionId, executionId },
lifecycle: 'starting',
terminalSessionId: null,
...resetActionPreview,
isConnecting: false,
};
const sessions = new Map(state.sessions);
sessions.set(key, { ...current, tabs: nextTabs });
const actionMutationRevisions = new Map(state.actionMutationRevisions);
updateActionMutationRevision(actionMutationRevisions, key, actionId, state.nextActionMutationRevision);
return { sessions, actionMutationRevisions, nextActionMutationRevision: state.nextActionMutationRevision + 1 };
const keyToDrop = bufferKey(key, tabId);
const buffers = dropBufferKeys(state.buffers, (entry) => entry === keyToDrop) ?? state.buffers;
return { sessions, buffers, actionMutationRevisions, nextActionMutationRevision: state.nextActionMutationRevision + 1 };
});
return executionId;
},
@@ -884,10 +938,15 @@ export const useTerminalStore = create<TerminalStore>()(
return state;
}
const current = existing.tabs[idx]!;
if (current.lifecycle === lifecycle && !current.isConnecting) return state;
const nextTabs = [...existing.tabs];
nextTabs[idx] = { ...nextTabs[idx], lifecycle, isConnecting: false };
nextTabs[idx] = { ...current, lifecycle, isConnecting: false };
newSessions.set(key, { ...existing, tabs: nextTabs });
return { sessions: newSessions };
if (current.purpose.type !== 'project-action' || current.lifecycle === lifecycle) return { sessions: newSessions };
const actionMutationRevisions = new Map(state.actionMutationRevisions);
updateActionMutationRevision(actionMutationRevisions, key, current.purpose.actionId, state.nextActionMutationRevision);
return { sessions: newSessions, actionMutationRevisions, nextActionMutationRevision: state.nextActionMutationRevision + 1 };
});
},