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 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 sendCalls: string[] = [];
const forceKillCalls: string[] = []; const forceKillCalls: string[] = [];
const closeCalls: string[] = []; const closeCalls: string[] = [];
@@ -81,7 +88,7 @@ const terminal = {
createCalls.push(options); createCalls.push(options);
sessionCounter += 1; sessionCounter += 1;
return { return {
sessionId: `session-${sessionCounter}`, sessionId: options.sessionId ?? `session-${sessionCounter}`,
cols: 80, cols: 80,
rows: 24, rows: 24,
status: 'running' as const, status: 'running' as const,
@@ -243,6 +250,100 @@ describe('ProjectActionsButton lifecycle', () => {
await act(async () => { await Promise.resolve(); }); 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 () => { test('runs, stops, and reruns on the same action tab while cleaning old subscriptions once', async () => {
await renderButton(); await renderButton();
@@ -257,7 +358,7 @@ describe('ProjectActionsButton lifecycle', () => {
}); });
const firstTab = useTerminalStore.getState().getDirectoryState('/repo')?.tabs.find((tab) => tab.purpose.type === 'project-action'); 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'); expect(firstTab?.purpose.type).toBe('project-action');
const firstExecution = firstTab?.purpose.type === 'project-action' ? firstTab.purpose.executionId : null; const firstExecution = firstTab?.purpose.type === 'project-action' ? firstTab.purpose.executionId : null;
expect(firstExecution).not.toBeNull(); 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'); const rerunTab = useTerminalStore.getState().getDirectoryState('/repo')?.tabs.find((tab) => tab.purpose.type === 'project-action');
expect(rerunTab?.id).toBe(firstTab?.id); expect(rerunTab?.id).toBe(firstTab?.id);
expect(rerunTab?.terminalSessionId).toBe('session-2'); expect(rerunTab?.terminalSessionId).toBe(secondSessionId());
expect(rerunTab?.lifecycle).toBe('running'); expect(rerunTab?.lifecycle).toBe('running');
const secondExecution = rerunTab?.purpose.type === 'project-action' ? rerunTab.purpose.executionId : null; const secondExecution = rerunTab?.purpose.type === 'project-action' ? rerunTab.purpose.executionId : null;
expect(secondExecution).not.toBeNull(); expect(secondExecution).not.toBeNull();
expect(secondExecution).not.toBe(firstExecution); expect(secondExecution).not.toBe(firstExecution);
expect(createCalls).toHaveLength(2); expect(createCalls).toHaveLength(2);
expect(sendCalls).toEqual(['session-1:\x03']); expect(sendCalls).toEqual([`${firstSessionId()}:\x03`]);
expect(forceKillCalls).toEqual([]); expect(forceKillCalls).toEqual([]);
expect(closeCalls).toEqual(['session-1']); expect(closeCalls).toEqual([]);
expect(subscriptions.map((entry) => entry.closed)).toEqual([1, 1, 0]); 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) => ( const autoDiscoverTab = useTerminalStore.getState().getDirectoryState('/repo')?.tabs.find((tab) => (
tab.purpose.type === 'project-action' && tab.purpose.actionId === '__openchamber_auto_discover_preview__' 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 () => { await act(async () => {
emitToSession('session-1', { emitToSession(firstSessionId(), {
type: 'data', type: 'data',
data: 'Ready at http://127.0.0.1:4321\n', data: 'Ready at http://127.0.0.1:4321\n',
sequence: 1, sequence: 1,
@@ -490,7 +591,7 @@ describe('ProjectActionsButton lifecycle', () => {
expect(openExternalCalls).toEqual([]); expect(openExternalCalls).toEqual([]);
await act(async () => { await act(async () => {
emitToSession('session-1', { emitToSession(firstSessionId(), {
type: 'data', type: 'data',
data: 'Server listening at http://127.0.0.1:4000\n', data: 'Server listening at http://127.0.0.1:4000\n',
sequence: 1, sequence: 1,
@@ -42,6 +42,7 @@ import {
reconcileTerminalSessionAuthority, reconcileTerminalSessionAuthority,
stopProjectActionTerminalSession, stopProjectActionTerminalSession,
} from '@/lib/projectActionTerminal'; } from '@/lib/projectActionTerminal';
import { observeTerminalSessions } from '@/lib/terminalSessionObserver';
import type { TerminalTab } from '@/stores/useTerminalStore'; import type { TerminalTab } from '@/stores/useTerminalStore';
type UrlWatchEntry = { type UrlWatchEntry = {
@@ -350,13 +351,16 @@ export const ProjectActionsButton = ({
React.useEffect(() => { React.useEffect(() => {
const watchedDirectories = new Set(watchedTerminalDirectories); 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)) { for (const executionStateKey of Object.keys(streamCleanupByRunKeyRef.current)) {
const executionDirectory = executionStateKey.split('::', 1)[0] ?? ''; const executionDirectory = executionStateKey.split('::', 1)[0] ?? '';
if (!watchedDirectories.has(executionDirectory)) { if (!watchedDirectories.has(executionDirectory)) {
closeTrackedSubscription(executionStateKey); closeTrackedSubscription(executionStateKey);
} }
} }
}, [closeTrackedSubscription, watchedTerminalDirectories]); }, [clearExecutionUi, closeTrackedSubscription, watchedTerminalDirectories]);
const revealProjectActionTerminal = React.useCallback((hostDirectory: string, executionDirectory: string) => { const revealProjectActionTerminal = React.useCallback((hostDirectory: string, executionDirectory: string) => {
useUIStore.getState().openContextPanelTab(hostDirectory, { useUIStore.getState().openContextPanelTab(hostDirectory, {
@@ -391,23 +395,13 @@ export const ProjectActionsButton = ({
}, [loadActions]); }, [loadActions]);
React.useEffect(() => { React.useEffect(() => {
if (!terminal.listSessions) { const cleanups = watchedTerminalDirectories.map(executionDirectory => observeTerminalSessions(
return; terminal, executionDirectory, captureStartedActionMutationRevisions,
} result => reconcileServerSessions(executionDirectory, result.sessions, {
let cancelled = false; startedActionMutationRevisions: result.startedActionMutationRevisions,
for (const executionDirectory of watchedTerminalDirectories) { }),
void reconcileTerminalSessionAuthority(terminal, executionDirectory, { ));
captureStartedActionMutationRevisions, return () => { for (const close of cleanups) close(); };
}).then((result) => {
if (cancelled || !result) return;
reconcileServerSessions(executionDirectory, result.sessions, {
startedActionMutationRevisions: result.startedActionMutationRevisions,
});
});
}
return () => {
cancelled = true;
};
}, [captureStartedActionMutationRevisions, reconcileServerSessions, terminal, watchedTerminalDirectories]); }, [captureStartedActionMutationRevisions, reconcileServerSessions, terminal, watchedTerminalDirectories]);
React.useEffect(() => { React.useEffect(() => {
@@ -528,7 +522,7 @@ export const ProjectActionsButton = ({
actionId: entry.actionId, actionId: entry.actionId,
executionId: entry.executionId, executionId: entry.executionId,
lastSeenChunkId: null, lastSeenChunkId: null,
openedUrl: false, openedUrl: true,
tail: '', tail: '',
openInPreview: false, openInPreview: false,
announced: [], announced: [],
@@ -734,6 +728,7 @@ export const ProjectActionsButton = ({
} }
if (startingRunKeysRef.current.has(runKey)) return; if (startingRunKeysRef.current.has(runKey)) return;
startingRunKeysRef.current.add(runKey); startingRunKeysRef.current.add(runKey);
let requestedExecution: { directory: string; tabId: string; id: string } | null = null;
try { try {
const discovered = action.id === AUTO_DISCOVER_ACTION_ID 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 hasCustomOpenUrl = discovered.autoOpenUrl === true && (discovered.openUrl || '').trim().length > 0;
const revealTerminal = !hasCustomOpenUrl && action.id !== AUTO_DISCOVER_ACTION_ID; const revealTerminal = !hasCustomOpenUrl && action.id !== AUTO_DISCOVER_ACTION_ID;
const launchContextHostDirectory = contextHostDirectoryRef.current || normalizedDirectory; 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); const normalizedCommand = normalizeProjectActionCommand(discovered.command);
if (!normalizedCommand) { if (!normalizedCommand) {
throw new Error(t('projectActions.error.failedToRunAction')); throw new Error(t('projectActions.error.failedToRunAction'));
@@ -789,26 +784,23 @@ export const ProjectActionsButton = ({
} }
const priorTab = getActionTab(executionDirectory, discovered.id); const priorTab = getActionTab(executionDirectory, discovered.id);
const priorExecutionId = priorTab?.purpose.type === 'project-action' ? priorTab.purpose.executionId : null; let activeSessionId: string;
if (priorExecutionId) { let adoptedExecutionId: string;
clearExecutionUi(executionDirectory, discovered.id, priorExecutionId); if (priorTab?.lifecycle === 'running' && priorTab.terminalSessionId
} && priorTab.purpose.type === 'project-action' && priorTab.purpose.executionId) {
activeSessionId = priorTab.terminalSessionId;
const requestedExecutionId = allocateActionExecution(executionDirectory, tabId, discovered.id); adoptedExecutionId = priorTab.purpose.executionId;
if (!requestedExecutionId) { } else {
throw new Error(t('projectActions.error.failedToCreateTerminalSession')); 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);
setConnecting(executionDirectory, tabId, true, { expectedExecutionId: requestedExecutionId }); if (!requestedExecutionId) throw new Error(t('projectActions.error.failedToCreateTerminalSession'));
let activeSessionId: string | null = null; requestedExecution = { directory: executionDirectory, tabId, id: requestedExecutionId };
let adoptedExecutionId = requestedExecutionId; setConnecting(executionDirectory, tabId, true, { expectedExecutionId: requestedExecutionId });
try {
const created = await createProjectActionTerminalSession({ const created = await createProjectActionTerminalSession({
terminal, terminal,
previousSessionId: sessionId,
createOptions: { createOptions: {
cwd: executionDirectory, cwd: executionDirectory,
sessionId: tabId,
shell: terminalShell, shell: terminalShell,
loginShell: terminalLoginShell, loginShell: terminalLoginShell,
themeMode: currentTheme.metadata.variant === 'light' ? 'light' : 'dark', themeMode: currentTheme.metadata.variant === 'light' ? 'light' : 'dark',
@@ -820,40 +812,41 @@ export const ProjectActionsButton = ({
purpose: { type: 'project-action', actionId: discovered.id, executionId: requestedExecutionId }, purpose: { type: 'project-action', actionId: discovered.id, executionId: requestedExecutionId },
}); });
if (!matchesActionExecution(executionDirectory, tabId, 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; return;
} }
adoptedExecutionId = created.purpose?.type === 'project-action' ? created.purpose.executionId : requestedExecutionId; adoptedExecutionId = created.purpose?.type === 'project-action' ? created.purpose.executionId : requestedExecutionId;
setTabPurpose(executionDirectory, tabId, { type: 'project-action', actionId: discovered.id, executionId: adoptedExecutionId });
activeSessionId = created.sessionId; activeSessionId = created.sessionId;
setTabPurpose(executionDirectory, tabId, { type: 'project-action', actionId: discovered.id, executionId: adoptedExecutionId });
setTabSessionId(executionDirectory, tabId, activeSessionId, { expectedExecutionId: adoptedExecutionId }); setTabSessionId(executionDirectory, tabId, activeSessionId, { expectedExecutionId: adoptedExecutionId });
setTabLifecycle(executionDirectory, tabId, 'running', { expectedExecutionId: adoptedExecutionId });
} finally {
setConnecting(executionDirectory, tabId, false, { expectedExecutionId: adoptedExecutionId }); 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) { if (revealTerminal && launchContextHostDirectory) {
revealProjectActionTerminal(launchContextHostDirectory, executionDirectory); 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); const executionStateKey = executionKey(executionDirectory, discovered.id, adoptedExecutionId);
setConnecting(executionDirectory, tabId, true, { expectedExecutionId: adoptedExecutionId }); setConnecting(executionDirectory, tabId, true, { expectedExecutionId: adoptedExecutionId });
const subscription = terminal.connect( const subscription = terminal.connect(
activeSessionId, activeSessionId,
{ onEvent: (event) => { { onEvent: (event) => {
if (!matchesActionExecution(executionDirectory, tabId, adoptedExecutionId)) return;
if (event.purpose?.type === 'project-action' && event.purpose.executionId !== adoptedExecutionId) return;
if (event.type === 'snapshot') { if (event.type === 'snapshot') {
useTerminalStore.getState().replaceBuffer(executionDirectory, tabId, event.data ?? '', event.sequence ?? 0); useTerminalStore.getState().replaceBuffer(executionDirectory, tabId, event.data ?? '', event.sequence ?? 0);
useTerminalStore.getState().setConnecting(executionDirectory, tabId, false, { expectedExecutionId: adoptedExecutionId }); useTerminalStore.getState().setConnecting(executionDirectory, tabId, false, { expectedExecutionId: adoptedExecutionId });
@@ -880,6 +873,7 @@ export const ProjectActionsButton = ({
clearExecutionUi(executionDirectory, discovered.id, adoptedExecutionId); clearExecutionUi(executionDirectory, discovered.id, adoptedExecutionId);
} }
}, onError: (_error, fatal) => { }, onError: (_error, fatal) => {
if (!matchesActionExecution(executionDirectory, tabId, adoptedExecutionId)) return;
useTerminalStore.getState().setConnecting(executionDirectory, tabId, false, { expectedExecutionId: adoptedExecutionId }); useTerminalStore.getState().setConnecting(executionDirectory, tabId, false, { expectedExecutionId: adoptedExecutionId });
if (fatal) { if (fatal) {
useTerminalStore.getState().setTabLifecycle(executionDirectory, tabId, 'exited', { expectedExecutionId: adoptedExecutionId }); useTerminalStore.getState().setTabLifecycle(executionDirectory, tabId, 'exited', { expectedExecutionId: adoptedExecutionId });
@@ -893,6 +887,7 @@ export const ProjectActionsButton = ({
subscription.close(); subscription.close();
return; return;
} }
streamCleanupByRunKeyRef.current[executionStateKey]?.();
streamCleanupByRunKeyRef.current[executionStateKey] = subscription.close; streamCleanupByRunKeyRef.current[executionStateKey] = subscription.close;
window.clearTimeout(previewWaitTimeoutByRunKeyRef.current[executionStateKey]); window.clearTimeout(previewWaitTimeoutByRunKeyRef.current[executionStateKey]);
@@ -919,19 +914,6 @@ export const ProjectActionsButton = ({
}, AUTO_DISCOVER_PREVIEW_WAIT_TIMEOUT_MS); }, 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) { if (desktopForwardUrl) {
setTabPreviewUrl(executionDirectory, tabId, null, { locked: true, expectedExecutionId: adoptedExecutionId }); setTabPreviewUrl(executionDirectory, tabId, null, { locked: true, expectedExecutionId: adoptedExecutionId });
@@ -939,7 +921,7 @@ export const ProjectActionsButton = ({
toast.success(t('projectActions.toast.openedForwardedUrl')); toast.success(t('projectActions.toast.openedForwardedUrl'));
} else if (manualOpenUrl) { } else if (manualOpenUrl) {
setTabPreviewUrl(executionDirectory, tabId, manualOpenUrl, { locked: true, autoOpened: true, expectedExecutionId: adoptedExecutionId }); setTabPreviewUrl(executionDirectory, tabId, manualOpenUrl, { locked: true, autoOpened: true, expectedExecutionId: adoptedExecutionId });
openContextPreview(executionDirectory, manualOpenUrl); openContextPreview(launchContextHostDirectory, manualOpenUrl);
toast.success(t('projectActions.toast.openedActionUrl')); toast.success(t('projectActions.toast.openedActionUrl'));
} else if (hasCustomOpenUrl) { } else if (hasCustomOpenUrl) {
setTabPreviewUrl(executionDirectory, tabId, null, { locked: true, expectedExecutionId: adoptedExecutionId }); setTabPreviewUrl(executionDirectory, tabId, null, { locked: true, expectedExecutionId: adoptedExecutionId });
@@ -952,12 +934,11 @@ export const ProjectActionsButton = ({
} }
} catch (error) { } catch (error) {
const executionDirectory = executionDirectoryFor(action); if (requestedExecution && matchesActionExecution(requestedExecution.directory, requestedExecution.tabId, requestedExecution.id)) {
const currentTab = getActionTab(executionDirectory, action.id); const { directory: failedDirectory, tabId: failedTabId, id } = requestedExecution;
if (currentTab?.purpose.type === 'project-action' && currentTab.purpose.executionId) { clearExecutionUi(failedDirectory, action.id, id);
clearExecutionUi(executionDirectory, action.id, currentTab.purpose.executionId); setTabLifecycle(failedDirectory, failedTabId, 'exited', { expectedExecutionId: id });
setTabLifecycle(executionDirectory, currentTab.id, 'exited', { expectedExecutionId: currentTab.purpose.executionId }); setTabPurpose(failedDirectory, failedTabId, { type: 'project-action', actionId: action.id, executionId: null });
setTabPurpose(executionDirectory, currentTab.id, { type: 'project-action', actionId: action.id, executionId: null });
} }
if (error instanceof Error && error.message === 'PROJECT_ACTION_RUN_CANCELLED') { if (error instanceof Error && error.message === 'PROJECT_ACTION_RUN_CANCELLED') {
return; return;
@@ -335,6 +335,25 @@ describe('TerminalView project action tab indicator', () => {
expect(targetKey).toContain('/repo'); 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 () => { test('would fail if revisit attach skipped the active running project-action snapshot restore', async () => {
const state = useTerminalStore.getState().getDirectoryState('/repo'); const state = useTerminalStore.getState().getDirectoryState('/repo');
const actionTab = state?.tabs.find((tab) => tab.label === 'Build'); 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 { useInlineCommentDraftStore } from '@/stores/useInlineCommentDraftStore';
import { applyTerminalModifier, terminalControlCharacter, terminalSequenceForKey, type TerminalModifier as Modifier, type TerminalQuickKey as MobileKey } from '@/lib/terminalInput'; import { applyTerminalModifier, terminalControlCharacter, terminalSequenceForKey, type TerminalModifier as Modifier, type TerminalQuickKey as MobileKey } from '@/lib/terminalInput';
import { formatShortcutForDisplay } from '@/lib/shortcuts'; import { formatShortcutForDisplay } from '@/lib/shortcuts';
import { reconcileTerminalSessionAuthority } from '@/lib/projectActionTerminal'; import { observeTerminalSessions } from '@/lib/terminalSessionObserver';
type TerminalViewProps = { type TerminalViewProps = {
visible?: boolean; visible?: boolean;
@@ -200,29 +200,15 @@ export const TerminalView: React.FC<TerminalViewProps> = ({ visible, directory }
directoryRef.current = terminalDirectory; directoryRef.current = terminalDirectory;
}, [terminalDirectory]); }, [terminalDirectory]);
// The tab list is a per-client projection, so ask the server what actually // Only a visible panel requests discovery; failed reads preserve known state.
// 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.
React.useEffect(() => { React.useEffect(() => {
if (!terminalHydrated || !terminalDirectory || !terminal.listSessions) { if (!terminalHydrated || !terminalDirectory || !isTerminalVisible) return;
return; return observeTerminalSessions(terminal, terminalDirectory, captureStartedActionMutationRevisions, result => {
} reconcileServerSessions(terminalDirectory, result.sessions, {
let cancelled = false; startedActionMutationRevisions: result.startedActionMutationRevisions,
const directory = terminalDirectory;
void reconcileTerminalSessionAuthority(terminal, directory, {
captureStartedActionMutationRevisions,
})
.then((result) => {
if (cancelled || directoryRef.current !== directory || !result) return;
reconcileServerSessions(directory, result.sessions, {
startedActionMutationRevisions: result.startedActionMutationRevisions,
});
}); });
return () => { });
cancelled = true; }, [captureStartedActionMutationRevisions, isTerminalVisible, terminalHydrated, terminalDirectory, terminal, reconcileServerSessions]);
};
}, [captureStartedActionMutationRevisions, terminalHydrated, terminalDirectory, terminal, reconcileServerSessions]);
React.useEffect(() => { React.useEffect(() => {
if (!showQuickKeys && activeModifier !== null) { 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. // Mark active before connect so early events aren't dropped.
activeTerminalIdRef.current = terminalId; 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( const subscription = terminal.connect(
terminalId, terminalId,
{ {
onEvent: (event: TerminalStreamEvent) => { onEvent: (event: TerminalStreamEvent) => {
if (activeTerminalIdRef.current !== terminalId) { if (!ownsStream()) {
return; return;
} }
@@ -375,7 +364,7 @@ export const TerminalView: React.FC<TerminalViewProps> = ({ visible, directory }
} }
}, },
onError: (error, fatal) => { onError: (error, fatal) => {
if (activeTerminalIdRef.current !== terminalId) { if (!ownsStream()) {
return; return;
} }
@@ -398,10 +387,9 @@ export const TerminalView: React.FC<TerminalViewProps> = ({ visible, directory }
return; return;
} }
} }
setConnectionError( const superseded = error.code === 'SUPERSEDED';
t('terminalView.error.connectionFailed', { message: error.message }) setConnectionError(superseded ? null : t('terminalView.error.connectionFailed', { message: error.message }));
); setIsFatalError(!superseded);
setIsFatalError(true);
setConnecting(directory, tabId, false); setConnecting(directory, tabId, false);
setTabLifecycle(directory, tabId, 'exited'); setTabLifecycle(directory, tabId, 'exited');
setTabSessionId(directory, tabId, null); setTabSessionId(directory, tabId, null);
@@ -45,12 +45,12 @@ describe('project action terminal lifecycle', () => {
expect(normalizeProjectActionCommand(' printf "hi"\r\nexit\u0007 ')).toBe('printf "hi"\nexit'); 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 calls: string[] = [];
const terminal: TerminalAPI = { const terminal: TerminalAPI = {
createSession: async (options) => { createSession: async (options) => {
calls.push(`create:${JSON.stringify(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: () => {} }), connect: () => ({ close: () => {} }),
sendInput: async () => {}, sendInput: async () => {},
@@ -62,27 +62,24 @@ describe('project action terminal lifecycle', () => {
const created = await createProjectActionTerminalSession({ const created = await createProjectActionTerminalSession({
terminal, terminal,
previousSessionId: 'stale-session',
createOptions: { createOptions: {
cwd: '/repo', cwd: '/repo',
sessionId: 'tab-1',
}, },
command: 'echo hello', command: 'echo hello',
isRunStillExpected: () => true, isRunStillExpected: () => true,
purpose: { type: 'project-action', actionId: 'build', executionId: 'exec-1' }, 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([ expect(calls).toEqual([
'close:stale-session', 'create:{"cwd":"/repo","sessionId":"exec-1","mode":"command","command":"echo hello","purpose":{"type":"project-action","actionId":"build","executionId":"exec-1"}}',
'create:{"cwd":"/repo","sessionId":"tab-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 () => { test('rejects and closes a create response that does not echo command mode', async () => {
const closed: string[] = []; const closed: string[] = [];
const terminal: TerminalAPI = { 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: () => {} }), connect: () => ({ close: () => {} }),
sendInput: async () => {}, sendInput: async () => {},
resize: async () => {}, resize: async () => {},
@@ -93,22 +90,20 @@ describe('project action terminal lifecycle', () => {
await expect(createProjectActionTerminalSession({ await expect(createProjectActionTerminalSession({
terminal, terminal,
previousSessionId: null,
createOptions: { createOptions: {
cwd: '/repo', cwd: '/repo',
sessionId: 'tab-1',
}, },
command: 'echo hello', command: 'echo hello',
isRunStillExpected: () => true, isRunStillExpected: () => true,
purpose: { type: 'project-action', actionId: 'build', executionId: 'exec-1' }, purpose: { type: 'project-action', actionId: 'build', executionId: 'exec-1' },
})).rejects.toThrow('COMMAND_MODE_UNSUPPORTED'); })).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 () => { test('closes a newly created command session when stop removes the run during create', async () => {
const closed: string[] = []; const closed: string[] = [];
const terminal: TerminalAPI = { 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: () => {} }), connect: () => ({ close: () => {} }),
sendInput: async () => {}, sendInput: async () => {},
resize: async () => {}, resize: async () => {},
@@ -119,22 +114,20 @@ describe('project action terminal lifecycle', () => {
await expect(createProjectActionTerminalSession({ await expect(createProjectActionTerminalSession({
terminal, terminal,
previousSessionId: null,
createOptions: { createOptions: {
cwd: '/repo', cwd: '/repo',
sessionId: 'tab-1',
}, },
command: 'echo hello', command: 'echo hello',
isRunStillExpected: () => false, isRunStillExpected: () => false,
purpose: { type: 'project-action', actionId: 'build', executionId: 'exec-1' }, purpose: { type: 'project-action', actionId: 'build', executionId: 'exec-1' },
})).rejects.toThrow('PROJECT_ACTION_RUN_CANCELLED'); })).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 () => { test('rejects and closes a create response that does not echo project-action purpose', async () => {
const closed: string[] = []; const closed: string[] = [];
const terminal: TerminalAPI = { 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: () => {} }), connect: () => ({ close: () => {} }),
sendInput: async () => {}, sendInput: async () => {},
resize: async () => {}, resize: async () => {},
@@ -145,13 +138,12 @@ describe('project action terminal lifecycle', () => {
await expect(createProjectActionTerminalSession({ await expect(createProjectActionTerminalSession({
terminal, terminal,
previousSessionId: null, createOptions: { cwd: '/repo' },
createOptions: { cwd: '/repo', sessionId: 'tab-1' },
command: 'echo hello', command: 'echo hello',
isRunStillExpected: () => true, isRunStillExpected: () => true,
purpose: { type: 'project-action', actionId: 'build', executionId: 'exec-1' }, purpose: { type: 'project-action', actionId: 'build', executionId: 'exec-1' },
})).rejects.toThrow('PROJECT_ACTION_PURPOSE_UNSUPPORTED'); })).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 () => { test('reuses one in-flight authority listing per directory', async () => {
@@ -293,3 +285,19 @@ describe('project action terminal lifecycle', () => {
expect(finalized).toBe(0); 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'; import type { CreateTerminalOptions, TerminalAPI, TerminalServerSession, TerminalSession, TerminalSessionPurpose } from './api/types';
type TerminalActionMutationRevisions = ReadonlyMap<string, number>; type TerminalActionMutationRevisions = ReadonlyMap<string, number>;
@@ -10,11 +11,10 @@ const normalizeDirectory = (dir: string): string => {
return normalized; return normalized;
}; };
type ProjectActionTerminalCreateOptions = Omit<Extract<CreateTerminalOptions, { mode: 'command' }>, 'mode' | 'command'>; type ProjectActionTerminalCreateOptions = Omit<Extract<CreateTerminalOptions, { mode: 'command' }>, 'mode' | 'command' | 'sessionId'>;
type CreateProjectActionTerminalSessionOptions = { type CreateProjectActionTerminalSessionOptions = {
terminal: TerminalAPI; terminal: TerminalAPI;
previousSessionId: string | null;
createOptions: ProjectActionTerminalCreateOptions; createOptions: ProjectActionTerminalCreateOptions;
command: string; command: string;
isRunStillExpected: () => boolean; 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> => { const rejectCreatedSession = async (terminal: TerminalAPI, session: TerminalSession, requestedExecutionId: string, errorMessage: string): Promise<never> => {
await closeTerminalSession(terminal, sessionId); // A deduplicated response belongs to the peer that created it.
if (session.sessionId === requestedExecutionId) await closeTerminalSession(terminal, session.sessionId);
throw createProjectActionTerminalError(errorMessage); throw createProjectActionTerminalError(errorMessage);
}; };
@@ -93,33 +94,29 @@ type ReconcileTerminalSessionAuthorityResult = {
export const createProjectActionTerminalSession = async ({ export const createProjectActionTerminalSession = async ({
terminal, terminal,
previousSessionId,
createOptions, createOptions,
command, command,
isRunStillExpected, isRunStillExpected,
purpose, purpose,
}: CreateProjectActionTerminalSessionOptions): Promise<TerminalSession> => { }: CreateProjectActionTerminalSessionOptions): Promise<TerminalSession> => {
if (previousSessionId) {
await closeTerminalSession(terminal, previousSessionId);
}
const created = await terminal.createSession({ const created = await terminal.createSession({
...createOptions, ...createOptions,
sessionId: purpose.executionId,
mode: 'command', mode: 'command',
command: normalizeProjectActionCommand(command), command: normalizeProjectActionCommand(command),
purpose, purpose,
}); });
if (!isCommandTerminalSession(created)) { 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)) { 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()) { 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; return created;
@@ -228,12 +225,14 @@ export const reconcileTerminalSessionAuthority = (
} }
const normalizedDirectory = normalizeDirectory(directory); const normalizedDirectory = normalizeDirectory(directory);
const runtimeKey = getRuntimeKey();
const flightKey = `${runtimeKey}\u0000${normalizedDirectory}`;
let terminalFlights = reconcileFlightsByTerminal.get(terminal); let terminalFlights = reconcileFlightsByTerminal.get(terminal);
if (!terminalFlights) { if (!terminalFlights) {
terminalFlights = new Map(); terminalFlights = new Map();
reconcileFlightsByTerminal.set(terminal, terminalFlights); reconcileFlightsByTerminal.set(terminal, terminalFlights);
} }
const existing = terminalFlights.get(normalizedDirectory); const existing = terminalFlights.get(flightKey);
if (existing) { if (existing) {
return existing; return existing;
} }
@@ -241,16 +240,16 @@ export const reconcileTerminalSessionAuthority = (
const startedActionMutationRevisions = options.captureStartedActionMutationRevisions?.(normalizedDirectory) const startedActionMutationRevisions = options.captureStartedActionMutationRevisions?.(normalizedDirectory)
?? new Map<string, number>(); ?? new Map<string, number>();
const flight = terminal.listSessions(normalizedDirectory) const flight = terminal.listSessions(normalizedDirectory)
.then((sessions) => ({ sessions, startedActionMutationRevisions })) .then((sessions) => runtimeKey === getRuntimeKey() ? { sessions, startedActionMutationRevisions } : null)
.catch(() => null) .catch(() => null)
.finally(() => { .finally(() => {
if (terminalFlights.get(normalizedDirectory) === flight) { if (terminalFlights.get(flightKey) === flight) {
terminalFlights.delete(normalizedDirectory); terminalFlights.delete(flightKey);
if (terminalFlights.size === 0) { if (terminalFlights.size === 0) {
reconcileFlightsByTerminal.delete(terminal); reconcileFlightsByTerminal.delete(terminal);
} }
} }
}); });
terminalFlights.set(normalizedDirectory, flight); terminalFlights.set(flightKey, flight);
return flight; return flight;
}; };
+2 -2
View File
@@ -352,7 +352,7 @@ describe('terminal transport', () => {
transport.dispose(); 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 socket = new FakeSocket();
const transport = new TerminalTransport({ refreshAuth: async () => '', openSocket: () => socket }); const transport = new TerminalTransport({ refreshAuth: async () => '', openSocket: () => socket });
const purposes: Array<string | null> = []; 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: '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' } }); socket.emit({ t: 'restarted', v: 3, s: 'term-1', q: 2, history: 'prompt 2', purpose: { type: 'project-action', actionId: 'build' } });
await tick(); await tick();
expect(purposes).toEqual(['exec-1', 'exec-1']); expect(purposes).toEqual(['exec-1']);
transport.dispose(); transport.dispose();
}); });
+45 -56
View File
@@ -1,30 +1,16 @@
import type { CreateTerminalOptions, TerminalError, TerminalHandlers, TerminalServerSession, TerminalSession, TerminalSessionPurpose, TerminalShellOption, TerminalStreamEvent } from './api/types'; import type { CreateTerminalOptions, TerminalError, TerminalHandlers, TerminalServerSession, TerminalSession, TerminalSessionPurpose, TerminalShellOption, TerminalStreamEvent } from './api/types';
import { openRuntimeWebSocket } from './relay/runtime-socket'; 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 { runtimeFetch } from './runtime-fetch';
import { getRuntimeUrlResolver } from './runtime-url'; import { getRuntimeUrlResolver } from './runtime-url';
import { clearRuntimeUrlAuthToken, refreshRuntimeUrlAuthToken } from './runtime-auth'; import { clearRuntimeUrlAuthToken, refreshRuntimeUrlAuthToken } from './runtime-auth';
import { isTerminalShell } from './terminalShell'; import { isTerminalShell } from './terminalShell';
import { z } from 'zod'; import { z } from 'zod';
type Message = Record<string, unknown> & { type ClientMessage =
t: string; | { t: 'hello' | 'ping'; v: 3 }
s?: string; | { t: 'attach' | 'detach'; v: 3; s: string }
q?: number; | { t: 'write'; v: 3; s: string; d: string };
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 Subscriber = { handlers: TerminalHandlers; lastSequence: number }; type Subscriber = { handlers: TerminalHandlers; lastSequence: number };
type TerminalProjection = { type TerminalProjection = {
sequence: number; sequence: number;
@@ -87,7 +73,27 @@ const terminalServerSessionSchema = z.object({
purpose: terminalSessionPurposeSchema.optional(), 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 payload = encoder.encode(JSON.stringify(message));
const frame = new Uint8Array(payload.length + 1); const frame = new Uint8Array(payload.length + 1);
frame[0] = TAG; frame[0] = TAG;
@@ -95,15 +101,10 @@ const encode = (message: Message): Uint8Array => {
return frame; return frame;
}; };
const decode = async (data: unknown): Promise<Message | null> => { const decode = (data: RelayTunnelSocketMessageEvent['data']): TerminalMessage | null => {
let bytes: Uint8Array; let bytes = data instanceof ArrayBuffer ? new Uint8Array(data) : encoder.encode(data);
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;
if (bytes[0] === TAG) bytes = bytes.subarray(1); 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> => { 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 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 => { export const parseTerminalSessionPurpose = (value: TerminalSessionPurposeInput): TerminalSessionPurpose | undefined => {
return terminalSessionPurposeSchema.safeParse(value).data; return terminalSessionPurposeSchema.safeParse(value).data;
}; };
@@ -330,12 +319,12 @@ export class TerminalTransport {
} }
} }
private async handleMessage(raw: unknown): Promise<void> { private handleMessage(raw: RelayTunnelSocketMessageEvent['data']): void {
const message = await decode(raw); const message = decode(raw);
if (!message || message.t === 'hello' || message.t === 'pong') return; if (!message || message.t === 'hello' || message.t === 'pong') return;
if (message.t === 'error') { if (message.t === 'error') {
const error = new Error(typeof message.message === 'string' ? message.message : 'Terminal error') as TerminalError; const error: TerminalError = new Error(message.message ?? 'Terminal error');
if (typeof message.code === 'string') error.code = message.code; error.code = message.code;
const targets = message.s ? [message.s] : [...this.subscribers.keys()]; 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); for (const id of targets) for (const sub of this.subscribers.get(id) ?? []) sub.handlers.onError?.(error, message.fatal === true);
return; return;
@@ -347,12 +336,12 @@ export class TerminalTransport {
const projection: TerminalProjection = { const projection: TerminalProjection = {
sequence: message.q ?? 0, sequence: message.q ?? 0,
history: message.history ?? '', history: message.history ?? '',
status: parseTerminalStatus(message.status), status: message.status,
mode: parseTerminalMode(message.mode), mode: message.mode,
purpose: parseTerminalSessionPurpose(message.purpose), purpose: message.purpose,
exitCode: message.exitCode, exitCode: message.exitCode,
signal: message.signal ?? null, signal: message.signal ?? null,
runtime: parseTerminalRuntime(message.runtime), runtime: message.runtime,
ptyBackend: message.ptyBackend, ptyBackend: message.ptyBackend,
}; };
this.projections.set(message.s, projection); this.projections.set(message.s, projection);
@@ -362,23 +351,23 @@ export class TerminalTransport {
} }
return; return;
} }
if (typeof message.q !== 'number') return;
const previous = this.projections.get(message.s); const previous = this.projections.get(message.s);
if (previous && message.q > previous.sequence) { 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 : ''))) }); 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: typeof message.exitCode === 'number' ? message.exitCode : undefined, signal: typeof message.signal === 'number' ? message.signal : null }); 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: parseTerminalMode(message.mode) ?? previous.mode, purpose: parseTerminalSessionPurpose(message.purpose) ?? previous.purpose, exitCode: undefined, 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) { for (const sub of subscribers) {
if (message.q <= sub.lastSequence) continue; if (message.q <= sub.lastSequence) continue;
sub.lastSequence = message.q; 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 }); 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: typeof message.exitCode === 'number' ? message.exitCode : undefined, signal: typeof message.signal === 'number' ? message.signal : null }); 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: parseTerminalMode(message.mode) ?? previous?.mode, purpose: parseTerminalSessionPurpose(message.purpose) ?? previous?.purpose }); 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; if (!this.socket || this.socket.readyState !== SOCKET_OPEN) return false;
try { this.socket.send(encode(message)); return true; } catch { 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 projection while both are referentially unchanged, and the storage adapter skips a write
for an unchanged projection, so streaming output performs no persistence work. for an unchanged projection, so streaming output performs no persistence work.
- Consumers that react to output must subscribe to `buffers`, not `sessions`. - 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 - 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 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 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]!; const tab = useTerminalStore.getState().getDirectoryState('/repo')!.tabs[0]!;
expect(tab.lifecycle).toBe('starting'); expect(tab.lifecycle).toBe('starting');
expect(tab.purpose).toEqual({ type: 'project-action', actionId: 'build', executionId }); 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', () => { 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); 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, 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 toAdoptedActionLabel = (actionId: string): string => {
const trimmed = actionId.trim(); const trimmed = actionId.trim();
return trimmed || 'Action'; return trimmed || 'Action';
@@ -452,6 +473,7 @@ export const useTerminalStore = create<TerminalStore>()(
return state; return state;
} }
const tabs = [...(existing?.tabs ?? [])]; const tabs = [...(existing?.tabs ?? [])];
let buffers = state.buffers;
let tabsChanged = false; let tabsChanged = false;
let activationCandidate: { tabId: string; createdAt: number; index: number } | null = null; let activationCandidate: { tabId: string; createdAt: number; index: number } | null = null;
const listedActionIds = new Set<string>(); const listedActionIds = new Set<string>();
@@ -465,7 +487,7 @@ export const useTerminalStore = create<TerminalStore>()(
: null; : null;
if (placeholder && serverSessions.length > 0) tabs.length = 0; 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); let matchIndex = tabs.findIndex((tab) => tab.terminalSessionId === session.sessionId || tab.id === session.sessionId);
const sessionPurpose = session.purpose; const sessionPurpose = session.purpose;
const staleActionAuthority = sessionPurpose?.type === 'project-action' const staleActionAuthority = sessionPurpose?.type === 'project-action'
@@ -492,10 +514,21 @@ export const useTerminalStore = create<TerminalStore>()(
if (matchIndex >= 0) { if (matchIndex >= 0) {
const current = tabs[matchIndex]!; 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) ? toActionLifecycle(session.status)
: 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 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' const activatesRunningAction = sessionPurpose?.type === 'project-action'
&& session.status === 'running' && session.status === 'running'
&& (current.terminalSessionId !== session.sessionId || current.lifecycle !== 'running'); && (current.terminalSessionId !== session.sessionId || current.lifecycle !== 'running');
@@ -517,6 +550,7 @@ export const useTerminalStore = create<TerminalStore>()(
isConnecting: false, isConnecting: false,
createdAt: nextCreatedAt, createdAt: nextCreatedAt,
}; };
if (executionChanged) Object.assign(tabs[matchIndex], resetActionPreview);
tabsChanged = true; tabsChanged = true;
} }
if (activatesRunningAction) { if (activatesRunningAction) {
@@ -607,7 +641,7 @@ export const useTerminalStore = create<TerminalStore>()(
const newSessions = new Map(state.sessions); const newSessions = new Map(state.sessions);
newSessions.set(key, { tabs: reconciledTabs, activeTabId }); newSessions.set(key, { tabs: reconciledTabs, activeTabId });
return { sessions: newSessions }; return { sessions: newSessions, buffers };
}); });
}, },
@@ -715,6 +749,15 @@ export const useTerminalStore = create<TerminalStore>()(
return state; 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 nextTabs = existing.tabs.filter((t) => t.id !== tabId);
const closedBufferKey = bufferKey(key, tabId); const closedBufferKey = bufferKey(key, tabId);
const nextBuffers = state.buffers.has(closedBufferKey) const nextBuffers = state.buffers.has(closedBufferKey)
@@ -732,7 +775,7 @@ export const useTerminalStore = create<TerminalStore>()(
if (nextBuffers) { if (nextBuffers) {
nextState.buffers = nextBuffers; nextState.buffers = nextBuffers;
} }
return nextState; return { ...nextState, ...mutationState };
} }
let nextActive = existing.activeTabId; let nextActive = existing.activeTabId;
@@ -753,7 +796,7 @@ export const useTerminalStore = create<TerminalStore>()(
if (nextBuffers) { if (nextBuffers) {
nextState.buffers = nextBuffers; nextState.buffers = nextBuffers;
} }
return nextState; return { ...nextState, ...mutationState };
}); });
}, },
@@ -769,7 +812,14 @@ export const useTerminalStore = create<TerminalStore>()(
return state; return state;
} }
const nextTabs = [...existing.tabs]; 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 }; 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); const sessions = new Map(state.sessions);
sessions.set(key, { ...existing, tabs: nextTabs }); sessions.set(key, { ...existing, tabs: nextTabs });
if (purpose.type !== 'project-action') { if (purpose.type !== 'project-action') {
@@ -777,7 +827,7 @@ export const useTerminalStore = create<TerminalStore>()(
} }
const actionMutationRevisions = new Map(state.actionMutationRevisions); const actionMutationRevisions = new Map(state.actionMutationRevisions);
updateActionMutationRevision(actionMutationRevisions, key, purpose.actionId, state.nextActionMutationRevision); 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]!, ...nextTabs[idx]!,
purpose: { type: 'project-action', actionId, executionId }, purpose: { type: 'project-action', actionId, executionId },
lifecycle: 'starting', lifecycle: 'starting',
terminalSessionId: null,
...resetActionPreview,
isConnecting: false, isConnecting: false,
}; };
const sessions = new Map(state.sessions); const sessions = new Map(state.sessions);
sessions.set(key, { ...current, tabs: nextTabs }); sessions.set(key, { ...current, tabs: nextTabs });
const actionMutationRevisions = new Map(state.actionMutationRevisions); const actionMutationRevisions = new Map(state.actionMutationRevisions);
updateActionMutationRevision(actionMutationRevisions, key, actionId, state.nextActionMutationRevision); 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; return executionId;
}, },
@@ -884,10 +938,15 @@ export const useTerminalStore = create<TerminalStore>()(
return state; return state;
} }
const current = existing.tabs[idx]!;
if (current.lifecycle === lifecycle && !current.isConnecting) return state;
const nextTabs = [...existing.tabs]; const nextTabs = [...existing.tabs];
nextTabs[idx] = { ...nextTabs[idx], lifecycle, isConnecting: false }; nextTabs[idx] = { ...current, lifecycle, isConnecting: false };
newSessions.set(key, { ...existing, tabs: nextTabs }); 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 };
}); });
}, },
@@ -2,7 +2,7 @@
## Ownership ## Ownership
`runtime.js` owns terminal identity, PTY processes, launch mode, session purpose, ordered output, bounded scrollback, WebSocket attachments, and lifecycle routes. `shells.js` discovers executable shell families, resolves the persisted shell ID, and builds the per-shell argv for interactive versus command launches. Clients own tab arrangement and choose stable terminal IDs. Electron uses this same runtime in-process; VS Code returns an explicit unsupported error. `runtime.js` owns terminal identity, PTY processes, launch mode, session purpose, ordered output, bounded scrollback, WebSocket attachments, and lifecycle routes. `shells.js` discovers executable shell families, resolves the persisted shell ID, and builds the per-shell argv for interactive versus command launches. Clients own tab arrangement. Interactive terminals use stable IDs; project actions keep a stable UI tab and allocate a fresh terminal ID for each command execution. Electron uses this same runtime in-process; VS Code returns an explicit unsupported error.
## Protocol ## Protocol
@@ -33,7 +33,8 @@ HTTP remains the authenticated command plane for create, resize, appearance upda
- `GET /api/terminal/shells` reports shell IDs available on the active server using the same augmented PATH provided to spawned PTYs, plus whether each executable has a supported login-mode argument. `auto` preserves environment/platform fallback order; an explicit unavailable shell fails creation instead of silently running a different shell. Login mode is opt-in and uses only built-in arguments for known shells. Interactive shells still launch as before. Command-mode launches reuse the same environment and login support, but switch argv by shell family: POSIX and Fish use interactive `-c`, Nushell uses `-c`, PowerShell uses `-Command`, and cmd uses `/d /s /c`. Preference changes affect new sessions and explicit restarts, not running PTYs. - `GET /api/terminal/shells` reports shell IDs available on the active server using the same augmented PATH provided to spawned PTYs, plus whether each executable has a supported login-mode argument. `auto` preserves environment/platform fallback order; an explicit unavailable shell fails creation instead of silently running a different shell. Login mode is opt-in and uses only built-in arguments for known shells. Interactive shells still launch as before. Command-mode launches reuse the same environment and login support, but switch argv by shell family: POSIX and Fish use interactive `-c`, Nushell uses `-c`, PowerShell uses `-Command`, and cmd uses `/d /s /c`. Preference changes affect new sessions and explicit restarts, not running PTYs.
- PTY data and exit callbacks enter one FIFO queue. The runtime wires those listeners in the same synchronous turn that receives the PTY object. `node-pty` and `bun-pty` both expose the PTY before dispatching registered callbacks. If a backend emitted exit before listener registration, this layer could not recover it, so the wiring stays adjacent to PTY creation. - PTY data and exit callbacks enter one FIFO queue. The runtime wires those listeners in the same synchronous turn that receives the PTY object. `node-pty` and `bun-pty` both expose the PTY before dispatching registered callbacks. If a backend emitted exit before listener registration, this layer could not recover it, so the wiring stays adjacent to PTY creation.
- Scrollback is retained on the server and capped at 512 KiB with UTF-8-safe trimming. Device-status, device-attribute, cursor-position reply, and color-query exchanges are removed from replay history with incomplete control sequences carried across PTY chunks; live output remains byte-for-byte unchanged. - Scrollback is retained on the server and capped at 512 KiB with UTF-8-safe trimming. Device-status, device-attribute, cursor-position reply, and color-query exchanges are removed from replay history with incomplete control sequences carried across PTY chunks; live output remains byte-for-byte unchanged.
- Exited sessions remain attachable until explicit close or idle cleanup. - Exited sessions remain attachable until explicit close, idle cleanup, or a successful replacement of the same project action. Creating a replacement retires only exited records for the same resolved directory and action, after the new PTY starts. Failed creation preserves the old record and output. These replaced records do not exhaust the terminal capacity limit.
- Deduplicated create responses may describe another client's execution. Cancellation cleanup closes only the terminal ID allocated for the cancelled request; it never closes an adopted peer execution.
- Restarts are serialized per terminal. Each restart spawns and wires the replacement before terminating the old process, retaining the terminal ID. Command-mode sessions reject restart with HTTP 400 instead of silently turning into interactive shells with stale action metadata. - Restarts are serialized per terminal. Each restart spawns and wires the replacement before terminating the old process, retaining the terminal ID. Command-mode sessions reject restart with HTTP 400 instead of silently turning into interactive shells with stale action metadata.
- A delete that arrives while create is still pending leaves a cancellation tombstone. When the PTY arrives, the runtime terminates it immediately, never inserts the session into the live map, and returns a create error while the delete still succeeds. - A delete that arrives while create is still pending leaves a cancellation tombstone. When the PTY arrives, the runtime terminates it immediately, never inserts the session into the live map, and returns a create error while the delete still succeeds.
- Close uses SIGTERM with bounded SIGKILL escalation. Force-kill, idle cleanup, and runtime shutdown terminate process groups immediately where supported. Removal explicitly sends a fatal scoped closure and evicts client projections even when a PTY backend fails to emit `onExit`; attached terminals are not considered idle. - Close uses SIGTERM with bounded SIGKILL escalation. Force-kill, idle cleanup, and runtime shutdown terminate process groups immediately where supported. Removal explicitly sends a fatal scoped closure and evicts client projections even when a PTY backend fails to emit `onExit`; attached terminals are not considered idle.
+10 -1
View File
@@ -310,7 +310,11 @@ export function createTerminalRuntime({
applyAppearance(session, { themeMode, terminalBackground, terminalForeground }); applyAppearance(session, { themeMode, terminalBackground, terminalForeground });
return session; return session;
} }
if (!existing && sessions.size + pendingSessionCreates.size >= MAX_SESSIONS) throw new Error('Maximum terminal sessions reached'); const superseded = normalizedPurpose.type === 'project-action'
? [...sessions.values()].filter((session) => session.id !== id && session.status === 'exited'
&& path.resolve(session.cwd) === resolvedCwd && isPurposeActionMatch(getSessionPurpose(session), normalizedPurpose))
: [];
if (!existing && sessions.size - superseded.length + pendingSessionCreates.size >= MAX_SESSIONS) throw new Error('Maximum terminal sessions reached');
const pendingEntry = { cwd: resolvedCwd, shell: normalizedShell, loginShell, mode: launchMode.mode, command: launchMode.command, purpose: normalizedPurpose, cancelled: false, promise: null }; const pendingEntry = { cwd: resolvedCwd, shell: normalizedShell, loginShell, mode: launchMode.mode, command: launchMode.command, purpose: normalizedPurpose, cancelled: false, promise: null };
const creation = (async () => { const creation = (async () => {
const session = existing ?? { id, sequence: 0, history: '', pendingHistoryControlSequence: '', pendingThemeControlSequence: '', eventQueue: [], draining: false, createdAt: Date.now() }; const session = existing ?? { id, sequence: 0, history: '', pendingHistoryControlSequence: '', pendingThemeControlSequence: '', eventQueue: [], draining: false, createdAt: Date.now() };
@@ -320,6 +324,11 @@ export function createTerminalRuntime({
await terminateProcess(ptyProcess, true); await terminateProcess(ptyProcess, true);
throw new Error('Terminal session was closed during creation'); throw new Error('Terminal session was closed during creation');
} }
for (const previous of superseded) {
if (sessions.get(previous.id) !== previous || previous.status !== 'exited') continue;
sessions.delete(previous.id);
closeAttachments(previous.id, 'SUPERSEDED', 'Terminal replaced by a new action run');
}
sessions.set(id, session); sessions.set(id, session);
return session; return session;
})(); })();
@@ -191,6 +191,45 @@ describe('terminal runtime', () => {
return { routes, processes, runtime }; return { routes, processes, runtime };
}; };
it('replaces only exited action runs and keeps session capacity available across reruns', async () => {
const harness = createHarness();
try {
const create = harness.routes.post.get('/api/terminal/create');
await create({ body: { sessionId: 'interactive', cwd: '/repo' } }, createResponse());
for (let index = 0; index < 25; index += 1) {
const id = `execution-${index}`;
const response = createResponse();
await create({ body: { sessionId: id, cwd: '/repo', mode: 'command', command: 'echo hello', purpose: { type: 'project-action', actionId: 'build', executionId: id } } }, response);
expect(response.statusCode).toBe(200);
const listed = createResponse();
harness.routes.get.get('/api/terminal/sessions')({ query: { cwd: '/repo' } }, listed);
expect(listed.body.sessions.map(session => session.sessionId)).toEqual(['interactive', id]);
expect(harness.processes[0].killed).toBe(false);
harness.processes.at(-1).emitExit(0);
}
} finally { await harness.runtime.shutdown(); }
});
it('retains completed output when the replacement command fails to start', async () => {
let available = true;
const harness = createHarness({ fs: { promises: { stat: async () => ({ isDirectory: () => available }) } } });
try {
const create = harness.routes.post.get('/api/terminal/create');
const options = { cwd: '/repo', mode: 'command', command: 'echo hello', purpose: { type: 'project-action', actionId: 'build', executionId: 'old' } };
await create({ body: { ...options, sessionId: 'old' } }, createResponse());
harness.processes[0].emitData('old output');
harness.processes[0].emitExit(0);
available = false;
const response = createResponse();
await create({ body: { ...options, sessionId: 'new', purpose: { ...options.purpose, executionId: 'new' } } }, response);
expect(response.statusCode).toBe(400);
const listed = createResponse();
harness.routes.get.get('/api/terminal/sessions')({ query: { cwd: '/repo' } }, listed);
expect(listed.body.sessions.map(session => session.sessionId)).toEqual(['old']);
expect(harness.processes).toHaveLength(1);
} finally { await harness.runtime.shutdown(); }
});
it('rejects regular files as terminal working directories', async () => { it('rejects regular files as terminal working directories', async () => {
const postRoutes = new Map(); const postRoutes = new Map();
const app = { const app = {