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

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