fix: Project action terminal lifecycle (#3287)

* fix(terminal): make command sessions own action lifecycle

* fix(ui): reconcile project action terminal state

* feat(ui): show running project actions in terminal tabs

* feat(ui): run project actions from linked worktrees

* fix(ui): guard project action reconciliation

* fix(ui): scope project action preview fallback

* fix(ui): default project actions to worktrees

* fix(ui): reveal project action terminals

* fix(ui): retain terminal output after snapshot replay

* fix(ui): restore running action terminals on revisit
This commit is contained in:
Matt Visnovsky
2026-09-05 12:04:36 +03:00
committed by GitHub
parent 58dfc789a2
commit 4e0eed717d
53 changed files with 5194 additions and 608 deletions
+25 -2
View File
@@ -23,8 +23,14 @@ export interface TerminalSession {
cols: number;
rows: number;
status: 'running' | 'exited' | 'error';
mode?: 'interactive' | 'command';
purpose?: TerminalSessionPurpose;
}
export type TerminalSessionPurpose =
| { type: 'terminal' }
| { type: 'project-action'; actionId: string; executionId: string };
export type TerminalShell = 'auto' | 'bash' | 'zsh' | 'sh' | 'fish' | 'pwsh' | 'powershell' | 'cmd' | 'dash' | 'ksh' | 'nu';
export interface TerminalShellOption {
@@ -46,13 +52,15 @@ export interface TerminalStreamEvent {
runtime?: 'node' | 'bun';
ptyBackend?: string;
mode?: 'interactive' | 'command';
purpose?: TerminalSessionPurpose;
}
export interface TerminalError extends Error {
code?: string;
}
export interface CreateTerminalOptions {
interface BaseCreateTerminalOptions {
cwd: string;
sessionId?: string;
cols?: number;
@@ -62,8 +70,21 @@ export interface CreateTerminalOptions {
terminalForeground?: string;
shell?: TerminalShell;
loginShell?: boolean;
purpose?: TerminalSessionPurpose;
}
interface InteractiveCreateTerminalOptions extends BaseCreateTerminalOptions {
mode?: 'interactive';
}
interface CommandCreateTerminalOptions extends BaseCreateTerminalOptions {
mode: 'command';
command: string;
}
export type CreateTerminalOptions = InteractiveCreateTerminalOptions | CommandCreateTerminalOptions;
export type RestartTerminalOptions = InteractiveCreateTerminalOptions;
export interface ResizeTerminalPayload {
sessionId: string;
cols: number;
@@ -85,6 +106,8 @@ export interface TerminalServerSession {
cwd: string;
status: 'running' | 'exited';
createdAt: number | null;
mode?: 'interactive' | 'command';
purpose?: TerminalSessionPurpose;
}
export interface TerminalAPI {
@@ -99,7 +122,7 @@ export interface TerminalAPI {
resize(payload: ResizeTerminalPayload): Promise<void>;
updateAppearance?(sessionId: string, appearance: Pick<CreateTerminalOptions, 'themeMode' | 'terminalBackground' | 'terminalForeground'>): Promise<void>;
close(sessionId: string): Promise<void>;
restartSession?(currentSessionId: string, options: CreateTerminalOptions): Promise<TerminalSession>;
restartSession?(currentSessionId: string, options: RestartTerminalOptions): Promise<TerminalSession>;
forceKill?(options: ForceKillOptions): Promise<void>;
}
@@ -449,6 +449,11 @@ export const settingsDict = {
'settings.projects.actions.field.actionNamePlaceholder': 'Aktionsname',
'settings.projects.actions.field.command': 'Befehl',
'settings.projects.actions.field.commandPlaceholder': 'z. B. bun run lint',
'settings.projects.actions.runIn.label': 'Ausführen in',
'settings.projects.actions.runIn.info': 'Legt fest, wo diese Aktion ausgeführt wird, wenn sie aus einem verknüpften Worktree gestartet wird.',
'settings.projects.actions.runIn.project': 'Übergeordneter Checkout',
'settings.projects.actions.runIn.worktree': 'Aktueller Worktree',
'settings.projects.actions.runIn.aria': 'Arbeitsverzeichnis dieser Aktion',
'settings.projects.actions.field.autoOpenUrl': 'URL automatisch öffnen',
'settings.projects.actions.field.autoOpenUrlForAria': 'URL für {title} automatisch öffnen',
'settings.projects.actions.field.autoOpenUrlDescription': 'URL aus der Ausgabe oder benutzerdefinierte URL unten öffnen',
@@ -470,6 +470,11 @@ export const settingsDict = {
'settings.projects.actions.field.actionNamePlaceholder': 'Action name',
'settings.projects.actions.field.command': 'Command',
'settings.projects.actions.field.commandPlaceholder': 'e.g. bun run lint',
'settings.projects.actions.runIn.label': 'Run in',
'settings.projects.actions.runIn.info': 'Choose where this action runs when started from a linked worktree.',
'settings.projects.actions.runIn.project': 'Parent checkout',
'settings.projects.actions.runIn.worktree': 'Current worktree',
'settings.projects.actions.runIn.aria': 'Working directory for this action',
'settings.projects.actions.field.autoOpenUrl': 'Auto-open URL',
'settings.projects.actions.field.autoOpenUrlForAria': 'Auto-open URL for {title}',
'settings.projects.actions.field.autoOpenUrlDescription': 'Open URL from output or custom URL below',
@@ -438,6 +438,11 @@ export const settingsDict = {
"settings.projects.actions.field.actionNamePlaceholder": "Nombre de la acción",
"settings.projects.actions.field.command": "Comando",
"settings.projects.actions.field.commandPlaceholder": "p. ej. bun run lint",
"settings.projects.actions.runIn.label": "Ejecutar en",
"settings.projects.actions.runIn.info": "Elige dónde se ejecuta esta acción cuando se inicia desde un worktree vinculado.",
"settings.projects.actions.runIn.project": "Checkout principal",
"settings.projects.actions.runIn.worktree": "Worktree actual",
"settings.projects.actions.runIn.aria": "Directorio de trabajo de esta acción",
"settings.projects.actions.field.autoOpenUrl": "Abrir URL automáticamente",
"settings.projects.actions.field.autoOpenUrlForAria": "Abrir URL automáticamente para {title}",
"settings.projects.actions.field.autoOpenUrlDescription": "Abrir URL desde la salida o la URL personalizada de abajo",
@@ -361,6 +361,11 @@ export const settingsDict = {
'settings.projects.actions.field.actionNamePlaceholder': 'Nom de l\'action',
'settings.projects.actions.field.command': 'Commande',
'settings.projects.actions.field.commandPlaceholder': 'p. ex. bun install',
'settings.projects.actions.runIn.label': 'Exécuter dans',
'settings.projects.actions.runIn.info': 'Choisissez où cette action s\'exécute lorsqu\'elle est lancée depuis un worktree lié.',
'settings.projects.actions.runIn.project': 'Checkout parent',
'settings.projects.actions.runIn.worktree': 'Worktree courant',
'settings.projects.actions.runIn.aria': 'Répertoire d\'exécution de cette action',
'settings.projects.actions.field.autoOpenUrl': 'Ouverture automatique de lURL',
'settings.projects.actions.field.autoOpenUrlForAria': 'Ouverture automatique de lURL pour {title}',
'settings.projects.actions.field.autoOpenUrlDescription': 'Ouvrir lURL détectée dans la sortie, ou lURL personnalisée ci-dessous',
@@ -471,6 +471,11 @@ export const settingsDict = {
'settings.projects.actions.field.actionNamePlaceholder': 'アクション名',
'settings.projects.actions.field.command': 'コマンド',
'settings.projects.actions.field.commandPlaceholder': '例: bun run lint',
'settings.projects.actions.runIn.label': '実行場所',
'settings.projects.actions.runIn.info': 'リンクされたワークツリーから起動したときにこのアクションを実行する場所を選択します。',
'settings.projects.actions.runIn.project': '親チェックアウト',
'settings.projects.actions.runIn.worktree': '現在のワークツリー',
'settings.projects.actions.runIn.aria': 'このアクションの作業ディレクトリ',
'settings.projects.actions.field.autoOpenUrl': 'URL を自動開く',
'settings.projects.actions.field.autoOpenUrlForAria': '{title} の URL を自動開く',
'settings.projects.actions.field.autoOpenUrlDescription': '出力または以下のカスタム URL から URL を開く',
@@ -438,6 +438,11 @@ export const settingsDict = {
'settings.projects.actions.field.actionNamePlaceholder': '작업 이름',
'settings.projects.actions.field.command': '명령어',
'settings.projects.actions.field.commandPlaceholder': '예: bun run lint',
'settings.projects.actions.runIn.label': '실행 위치',
'settings.projects.actions.runIn.info': '연결된 워크트리에서 시작할 때 이 작업을 실행할 위치를 선택합니다.',
'settings.projects.actions.runIn.project': '상위 체크아웃',
'settings.projects.actions.runIn.worktree': '현재 워크트리',
'settings.projects.actions.runIn.aria': '이 작업의 작업 디렉터리',
'settings.projects.actions.field.autoOpenUrl': 'URL 자동 열기',
'settings.projects.actions.field.autoOpenUrlForAria': '{title}의 URL 자동 열기',
'settings.projects.actions.field.autoOpenUrlDescription': '명령 출력에서 감지한 URL 또는 아래의 사용자 정의 URL을 엽니다',
@@ -1368,6 +1368,11 @@ export const settingsDict = {
'settings.projects.actions.field.autoOpenUrlForAria': 'Automatycznie otwieraj URL dla {title}',
'settings.projects.actions.field.command': 'Polecenie',
'settings.projects.actions.field.commandPlaceholder': 'np. bun run lint',
'settings.projects.actions.runIn.label': 'Uruchom w',
'settings.projects.actions.runIn.info': 'Wybierz, gdzie uruchamiać tę akcję po uruchomieniu z połączonego worktree.',
'settings.projects.actions.runIn.project': 'Nadrzędny checkout',
'settings.projects.actions.runIn.worktree': 'Bieżący worktree',
'settings.projects.actions.runIn.aria': 'Katalog roboczy tej akcji',
'settings.projects.actions.field.desktopSshForward': 'Przekierowanie SSH pulpitu',
'settings.projects.actions.field.iconAria': 'Ikona {icon}',
'settings.projects.actions.field.overrideUrlPlaceholder': 'Nadpisz URL (opcjonalnie)',
@@ -438,6 +438,11 @@ export const settingsDict = {
"settings.projects.actions.field.actionNamePlaceholder": "Nome da ação",
"settings.projects.actions.field.command": "Comando",
"settings.projects.actions.field.commandPlaceholder": "ex.: bun run lint",
"settings.projects.actions.runIn.label": "Executar em",
"settings.projects.actions.runIn.info": "Escolha onde esta ação é executada quando iniciada a partir de um worktree vinculado.",
"settings.projects.actions.runIn.project": "Checkout pai",
"settings.projects.actions.runIn.worktree": "Worktree atual",
"settings.projects.actions.runIn.aria": "Diretório de trabalho desta ação",
"settings.projects.actions.field.autoOpenUrl": "Abrir URL automaticamente",
"settings.projects.actions.field.autoOpenUrlForAria": "Abrir URL automaticamente para {title}",
"settings.projects.actions.field.autoOpenUrlDescription": "Abrir URL da saída ou a URL personalizada abaixo",
@@ -466,6 +466,11 @@ export const settingsDict = {
'settings.projects.actions.field.actionNamePlaceholder': 'Eylem adı',
'settings.projects.actions.field.command': 'Komut',
'settings.projects.actions.field.commandPlaceholder': 'örn. bun run lint',
'settings.projects.actions.runIn.label': 'Çalıştırma konumu',
'settings.projects.actions.runIn.info': 'Bağlı bir worktree\'den başlatıldığında bu eylemin nerede çalışacağını seçin.',
'settings.projects.actions.runIn.project': 'Üst checkout',
'settings.projects.actions.runIn.worktree': 'Geçerli worktree',
'settings.projects.actions.runIn.aria': 'Bu eylemin çalışma dizini',
'settings.projects.actions.field.autoOpenUrl': 'URL\'yi otomatik aç',
'settings.projects.actions.field.autoOpenUrlForAria': '{title} için URL\'yi otomatik aç',
'settings.projects.actions.field.autoOpenUrlDescription': 'Çıktıdaki URL\'yi veya aşağıdaki özel URL\'yi aç',
@@ -438,6 +438,11 @@ export const settingsDict = {
"settings.projects.actions.field.actionNamePlaceholder": "Назва дії",
"settings.projects.actions.field.command": "Команда",
"settings.projects.actions.field.commandPlaceholder": "напр. bun run lint",
"settings.projects.actions.runIn.label": "Запускати в",
"settings.projects.actions.runIn.info": "Виберіть, де запускати цю дію, коли її запущено з пов'язаного worktree.",
"settings.projects.actions.runIn.project": "Батьківський checkout",
"settings.projects.actions.runIn.worktree": "Поточний worktree",
"settings.projects.actions.runIn.aria": "Робоча тека для цієї дії",
"settings.projects.actions.field.autoOpenUrl": "Автоматичне відкриття URL",
"settings.projects.actions.field.autoOpenUrlForAria": "Автоматичне відкриття URL для {title}",
"settings.projects.actions.field.autoOpenUrlDescription": "Відкрити URL із виведення або власний URL нижче",
@@ -438,6 +438,11 @@ export const settingsDict = {
'settings.projects.actions.field.actionNamePlaceholder': '操作名称',
'settings.projects.actions.field.command': '命令',
'settings.projects.actions.field.commandPlaceholder': '例如 bun run lint',
'settings.projects.actions.runIn.label': '运行位置',
'settings.projects.actions.runIn.info': '选择从关联 worktree 启动时此操作的运行位置。',
'settings.projects.actions.runIn.project': '父检出目录',
'settings.projects.actions.runIn.worktree': '当前 worktree',
'settings.projects.actions.runIn.aria': '此操作的工作目录',
'settings.projects.actions.field.autoOpenUrl': '自动打开 URL',
'settings.projects.actions.field.autoOpenUrlForAria': '为 {title} 自动打开 URL',
'settings.projects.actions.field.autoOpenUrlDescription': '从输出中打开 URL,或使用下面的自定义 URL',
@@ -435,6 +435,11 @@ export const settingsDict = {
'settings.projects.actions.field.actionNamePlaceholder': '操作名稱',
'settings.projects.actions.field.command': '命令',
'settings.projects.actions.field.commandPlaceholder': '例如 bun run lint',
'settings.projects.actions.runIn.label': '執行位置',
'settings.projects.actions.runIn.info': '選擇從關聯 worktree 啟動時此動作的執行位置。',
'settings.projects.actions.runIn.project': '父檢出目錄',
'settings.projects.actions.runIn.worktree': '目前 worktree',
'settings.projects.actions.runIn.aria': '此動作的工作目錄',
'settings.projects.actions.field.autoOpenUrl': '自動開啟 URL',
'settings.projects.actions.field.autoOpenUrlForAria': '為 {title} 自動開啟 URL',
'settings.projects.actions.field.autoOpenUrlDescription': '從輸出中開啟 URL,或使用下面的自訂 URL',
@@ -0,0 +1,150 @@
import { beforeEach, describe, expect, mock, test } from 'bun:test';
import { createProjectIdFromPath } from './projectId';
const homeDirectory = '/Users/test';
const project = { id: 'openchamber', path: '/workspace/openchamber' };
let files = new Map<string, string>();
mock.module('@/contexts/runtimeAPIRegistry', () => ({
getRegisteredRuntimeAPIs: mock(() => ({
files: {
createDirectory: mock(async () => ({ success: true })),
readFile: mock(async (path: string) => ({ content: files.get(path) ?? '' })),
writeFile: mock(async (path: string, content: string) => {
files.set(path, content);
return { success: true };
}),
delete: mock(async (path: string) => {
files.delete(path);
}),
},
})),
}));
mock.module('@/lib/desktop', () => ({
getDesktopHomeDirectory: mock(async () => homeDirectory),
isVSCodeRuntime: mock(() => false),
}));
mock.module('@/lib/runtime-fetch', () => ({
runtimeFetch: mock(async (url: string) => {
if (url.endsWith('/fs/home')) {
return new Response(JSON.stringify({ home: homeDirectory }), {
headers: { 'Content-Type': 'application/json' },
});
}
return new Response(JSON.stringify({ success: true }), {
headers: { 'Content-Type': 'application/json' },
});
}),
}));
const {
getProjectActionsState,
saveProjectActionsState,
} = await import('./openchamberConfig');
const getConfigPath = (projectPath: string): string => (
`${homeDirectory}/.config/openchamber/projects/${createProjectIdFromPath(projectPath)}.json`
);
describe('project actions config sanitization', () => {
beforeEach(() => {
files = new Map();
});
test('round-trips runIn parent through saved project actions state', async () => {
const saved = await saveProjectActionsState(project, {
actions: [{
id: 'action-1',
name: 'Run action',
command: 'pnpm dev',
runIn: 'parent',
}],
primaryActionId: 'action-1',
});
expect(saved).toBe(true);
const state = await getProjectActionsState(project);
expect(state).toEqual({
actions: [{
id: 'action-1',
name: 'Run action',
command: 'pnpm dev',
icon: null,
runIn: 'parent',
}],
primaryActionId: 'action-1',
});
});
test('keeps runIn omitted when saving project actions in the current worktree', async () => {
const saved = await saveProjectActionsState(project, {
actions: [{
id: 'action-1',
name: 'Run action',
command: 'pnpm dev',
}],
primaryActionId: 'action-1',
});
expect(saved).toBe(true);
const state = await getProjectActionsState(project);
expect(state).toEqual({
actions: [{
id: 'action-1',
name: 'Run action',
command: 'pnpm dev',
icon: null,
}],
primaryActionId: 'action-1',
});
});
test('normalizes runIn worktree to omission when loading project actions state', async () => {
files.set(getConfigPath(project.path), JSON.stringify({
projectPath: project.path,
projectActions: [
{ id: 'action-1', name: 'Run action', command: 'pnpm dev', runIn: 'worktree' },
],
projectActionsPrimaryId: 'action-1',
}));
const state = await getProjectActionsState(project);
expect(state).toEqual({
actions: [
{ id: 'action-1', name: 'Run action', command: 'pnpm dev', icon: null },
],
primaryActionId: 'action-1',
});
});
test('omits unsupported runIn values when loading project actions state', async () => {
files.set(getConfigPath(project.path), JSON.stringify({
projectPath: project.path,
projectActions: [
{ id: 'action-project', name: 'Project', command: 'pnpm dev', runIn: 'project' },
{ id: 'action-number', name: 'Number', command: 'pnpm test', runIn: 123 },
],
projectActionsPrimaryId: 'action-project',
}));
const state = await getProjectActionsState(project);
expect(state).toEqual({
actions: [
{ id: 'action-project', name: 'Project', command: 'pnpm dev', icon: null },
{ id: 'action-number', name: 'Number', command: 'pnpm test', icon: null },
],
primaryActionId: 'action-project',
});
});
});
+9 -2
View File
@@ -50,6 +50,7 @@ export interface OpenChamberProjectAction {
name: string;
command: string;
icon?: string | null;
runIn?: 'parent';
platforms?: OpenChamberProjectActionPlatform[];
autoOpenUrl?: boolean;
openUrl?: string;
@@ -280,6 +281,7 @@ const sanitizeProjectActions = (value: unknown): OpenChamberProjectAction[] => {
name?: unknown;
command?: unknown;
icon?: unknown;
runIn?: unknown;
platforms?: unknown;
autoOpenUrl?: unknown;
openUrl?: unknown;
@@ -296,6 +298,7 @@ const sanitizeProjectActions = (value: unknown): OpenChamberProjectAction[] => {
seenIds.add(id);
const iconRaw = typeof record.icon === 'string' ? record.icon.trim() : '';
const runIn = record.runIn === 'parent' ? 'parent' : undefined;
const platforms = sanitizeProjectActionPlatforms(record.platforms);
const autoOpenUrl = record.autoOpenUrl === true;
const openUrlRaw = typeof record.openUrl === 'string' ? record.openUrl.trim() : '';
@@ -308,7 +311,7 @@ const sanitizeProjectActions = (value: unknown): OpenChamberProjectAction[] => {
OPENCHAMBER_PROJECT_ACTION_DESKTOP_FORWARD_MAX_LENGTH
);
sanitized.push({
const sanitizedAction: OpenChamberProjectAction = {
id,
name,
command,
@@ -317,7 +320,11 @@ const sanitizeProjectActions = (value: unknown): OpenChamberProjectAction[] => {
...(openUrl ? { openUrl } : {}),
...(desktopOpenSshForward ? { desktopOpenSshForward } : {}),
...(platforms.length > 0 ? { platforms } : {}),
});
};
if (runIn) {
sanitizedAction.runIn = runIn;
}
sanitized.push(sanitizedAction);
}
return sanitized;
+259 -12
View File
@@ -1,7 +1,12 @@
import { describe, expect, test } from 'bun:test';
import type { TerminalAPI, TerminalHandlers } from './api/types';
import { waitForTerminalExit } from './projectActionTerminal';
import { detectDevServerCommand } from './detectDevServer';
import {
createProjectActionTerminalSession,
normalizeProjectActionCommand,
reconcileTerminalSessionAuthority,
stopProjectActionTerminalSession,
waitForTerminalExit,
} from './projectActionTerminal';
const fakeTerminal = () => {
let handlers: TerminalHandlers | null = null;
@@ -15,16 +20,6 @@ const fakeTerminal = () => {
};
describe('project action terminal lifecycle', () => {
test('preserves a configured dev action preview URL', async () => {
const detected = await detectDevServerCommand('/repo', [{
id: 'dev',
name: 'Dev server',
command: 'bun run dev',
openUrl: 'http://localhost:4321',
}], null);
expect(detected?.previewUrlHint).toBe('http://localhost:4321');
});
test('resolves on live exit and closes its temporary subscription', async () => {
const fake = fakeTerminal();
const result = waitForTerminalExit(fake.terminal, 'term-1', 100);
@@ -45,4 +40,256 @@ describe('project action terminal lifecycle', () => {
expect(await waitForTerminalExit(fake.terminal, 'term-1', 5)).toBe(false);
expect(fake.isClosed()).toBe(true);
});
test('normalizes a project action command before create', () => {
expect(normalizeProjectActionCommand(' printf "hi"\r\nexit\u0007 ')).toBe('printf "hi"\nexit');
});
test('closes the previous session before creating a command-mode run', 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' } };
},
connect: () => ({ close: () => {} }),
sendInput: async () => {},
resize: async () => {},
close: async (sessionId) => {
calls.push(`close:${sessionId}`);
},
};
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(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"}}',
]);
});
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' }),
connect: () => ({ close: () => {} }),
sendInput: async () => {},
resize: async () => {},
close: async (sessionId) => {
closed.push(sessionId);
},
};
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']);
});
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' } }),
connect: () => ({ close: () => {} }),
sendInput: async () => {},
resize: async () => {},
close: async (sessionId) => {
closed.push(sessionId);
},
};
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']);
});
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' }),
connect: () => ({ close: () => {} }),
sendInput: async () => {},
resize: async () => {},
close: async (sessionId) => {
closed.push(sessionId);
},
};
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('PROJECT_ACTION_PURPOSE_UNSUPPORTED');
expect(closed).toEqual(['tab-1']);
});
test('reuses one in-flight authority listing per directory', async () => {
let calls = 0;
let resolveSessions: ((value: Array<{ sessionId: string; cwd: string; status: 'running'; createdAt: number | null }>) => void) | undefined;
const capturedRevisions: number[] = [];
const terminal: TerminalAPI = {
listSessions: async () => {
calls += 1;
return await new Promise((resolve) => {
resolveSessions = resolve;
});
},
createSession: async () => ({ sessionId: 'ignored', cols: 80, rows: 24, status: 'running' }),
connect: () => ({ close: () => {} }),
sendInput: async () => {},
resize: async () => {},
close: async () => {},
};
const captureStartedActionMutationRevisions = () => {
const revision = capturedRevisions.length + 1;
capturedRevisions.push(revision);
return new Map([['/repo::build', revision]]);
};
const first = reconcileTerminalSessionAuthority(terminal, '/repo', {
captureStartedActionMutationRevisions,
});
const second = reconcileTerminalSessionAuthority(terminal, '/repo', {
captureStartedActionMutationRevisions,
});
expect(first).toBe(second);
const finishListing = resolveSessions;
if (!finishListing) {
throw new Error('list resolver was not captured');
}
finishListing([{ sessionId: 'srv-1', cwd: '/repo', status: 'running', createdAt: 1 }]);
expect(await first).toEqual({
sessions: [{ sessionId: 'srv-1', cwd: '/repo', status: 'running', createdAt: 1 }],
startedActionMutationRevisions: new Map([['/repo::build', 1]]),
});
expect(calls).toBe(1);
expect(capturedRevisions).toEqual([1]);
});
test('does not share an authority listing across runtime adapters', async () => {
const calls: string[] = [];
const createTerminal = (name: string): TerminalAPI => ({
listSessions: async () => {
calls.push(name);
return [];
},
createSession: async () => ({ sessionId: 'ignored', cols: 80, rows: 24, status: 'running' }),
connect: () => ({ close: () => {} }),
sendInput: async () => {},
resize: async () => {},
close: async () => {},
});
await Promise.all([
reconcileTerminalSessionAuthority(createTerminal('runtime-a'), '/repo'),
reconcileTerminalSessionAuthority(createTerminal('runtime-b'), '/repo'),
]);
expect(calls).toEqual(['runtime-a', 'runtime-b']);
});
test('stale stop completion does not interrupt or force-kill a newer execution and cleanup runs once', async () => {
const sent: string[] = [];
const forceKillCalls: string[] = [];
const subscriptions: Array<{ handlers: TerminalHandlers; closed: number }> = [];
let current = true;
const terminal: TerminalAPI = {
createSession: async () => ({ sessionId: 'unused', cols: 80, rows: 24, status: 'running' }),
connect: (_id, handlers) => {
const record = { handlers, closed: 0 };
subscriptions.push(record);
return { close: () => { record.closed += 1; } };
},
sendInput: async (sessionId, input) => {
sent.push(`${sessionId}:${input}`);
current = false;
},
resize: async () => {},
close: async () => {},
forceKill: async ({ sessionId }) => {
forceKillCalls.push(sessionId ?? '');
},
};
let stopping = 0;
let restored = 0;
let cleared = 0;
let finalized = 0;
await stopProjectActionTerminalSession({
terminal,
sessionId: 'srv-1',
isExecutionStillCurrent: () => current,
markStopping: () => { stopping += 1; },
restoreRunning: () => { restored += 1; },
clearSession: () => { cleared += 1; },
finalizeExit: () => { finalized += 1; },
timeoutMs: 1,
});
expect(sent).toEqual(['srv-1:\x03']);
expect(forceKillCalls).toEqual([]);
expect(stopping).toBe(1);
expect(restored).toBe(0);
expect(cleared).toBe(0);
expect(finalized).toBe(0);
expect(subscriptions).toHaveLength(1);
expect(subscriptions[0]?.closed).toBe(1);
});
test('stop failure returns the action to a retryable running state', async () => {
const terminal: TerminalAPI = {
createSession: async () => ({ sessionId: 'unused', cols: 80, rows: 24, status: 'running' }),
connect: (_id, handlers) => ({ close: () => { handlers.onError?.(new Error('ignored'), false); } }),
sendInput: async () => {},
resize: async () => {},
close: async () => { throw new Error('close failed'); },
};
let restored = 0;
let finalized = 0;
await stopProjectActionTerminalSession({
terminal,
sessionId: 'srv-1',
isExecutionStillCurrent: () => true,
markStopping: () => undefined,
restoreRunning: () => { restored += 1; },
clearSession: () => undefined,
finalizeExit: () => { finalized += 1; },
timeoutMs: 1,
});
expect(restored).toBe(1);
expect(finalized).toBe(0);
});
});
+231 -1
View File
@@ -1,4 +1,129 @@
import type { TerminalAPI } from './api/types';
import type { CreateTerminalOptions, TerminalAPI, TerminalServerSession, TerminalSession, TerminalSessionPurpose } from './api/types';
type TerminalActionMutationRevisions = ReadonlyMap<string, number>;
const normalizeDirectory = (dir: string): string => {
let normalized = dir.trim();
while (normalized.length > 1 && normalized.endsWith('/')) {
normalized = normalized.slice(0, -1);
}
return normalized;
};
type ProjectActionTerminalCreateOptions = Omit<Extract<CreateTerminalOptions, { mode: 'command' }>, 'mode' | 'command'>;
type CreateProjectActionTerminalSessionOptions = {
terminal: TerminalAPI;
previousSessionId: string | null;
createOptions: ProjectActionTerminalCreateOptions;
command: string;
isRunStillExpected: () => boolean;
purpose: Extract<TerminalSessionPurpose, { type: 'project-action' }>;
};
type StopProjectActionTerminalSessionOptions = {
terminal: TerminalAPI;
sessionId: string;
isExecutionStillCurrent: () => boolean;
markStopping: () => void;
restoreRunning: () => void;
clearSession: () => void;
finalizeExit: () => void;
timeoutMs?: number;
};
const COMMAND_MODE_UNSUPPORTED_ERROR = 'COMMAND_MODE_UNSUPPORTED';
const PROJECT_ACTION_RUN_CANCELLED_ERROR = 'PROJECT_ACTION_RUN_CANCELLED';
const PROJECT_ACTION_PURPOSE_UNSUPPORTED_ERROR = 'PROJECT_ACTION_PURPOSE_UNSUPPORTED';
const createProjectActionTerminalError = (message: string): Error => new Error(message);
const closeTerminalSession = async (terminal: TerminalAPI, sessionId: string): Promise<void> => {
try {
await terminal.close(sessionId);
} catch {
// noop
}
};
const rejectCreatedSession = async (terminal: TerminalAPI, sessionId: string, errorMessage: string): Promise<never> => {
await closeTerminalSession(terminal, sessionId);
throw createProjectActionTerminalError(errorMessage);
};
export const normalizeProjectActionCommand = (command: string): string => {
const normalizedNewlines = command.trim().replace(/\r\n|\r/g, '\n');
let next = '';
for (let index = 0; index < normalizedNewlines.length; index += 1) {
const code = normalizedNewlines.charCodeAt(index);
const isControl = (code >= 0 && code <= 8)
|| code === 11
|| code === 12
|| (code >= 14 && code <= 31)
|| code === 127;
if (!isControl) {
next += normalizedNewlines[index];
}
}
return next;
};
const isCommandTerminalSession = (session: TerminalSession): boolean => session.mode === 'command';
const isProjectActionTerminalPurpose = (
purpose: TerminalSessionPurpose | undefined,
): purpose is Extract<TerminalSessionPurpose, { type: 'project-action' }> => purpose?.type === 'project-action';
const isMatchingProjectActionPurpose = (
purpose: TerminalSessionPurpose | undefined,
actionId: string,
): purpose is Extract<TerminalSessionPurpose, { type: 'project-action' }> => (
isProjectActionTerminalPurpose(purpose)
&& purpose.actionId === actionId
&& purpose.executionId.trim().length > 0
);
type ReconcileTerminalSessionAuthorityOptions = {
captureStartedActionMutationRevisions?: (directory: string) => TerminalActionMutationRevisions;
};
type ReconcileTerminalSessionAuthorityResult = {
sessions: TerminalServerSession[];
startedActionMutationRevisions: TerminalActionMutationRevisions;
};
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,
mode: 'command',
command: normalizeProjectActionCommand(command),
purpose,
});
if (!isCommandTerminalSession(created)) {
await rejectCreatedSession(terminal, created.sessionId, COMMAND_MODE_UNSUPPORTED_ERROR);
}
if (!isMatchingProjectActionPurpose(created.purpose, purpose.actionId)) {
await rejectCreatedSession(terminal, created.sessionId, PROJECT_ACTION_PURPOSE_UNSUPPORTED_ERROR);
}
if (!isRunStillExpected()) {
await rejectCreatedSession(terminal, created.sessionId, PROJECT_ACTION_RUN_CANCELLED_ERROR);
}
return created;
};
export const waitForTerminalExit = (
terminal: TerminalAPI,
@@ -24,3 +149,108 @@ export const waitForTerminalExit = (
if (settled) subscription.close();
else timeout = setTimeout(() => finish(false), timeoutMs);
});
export const stopProjectActionTerminalSession = async ({
terminal,
sessionId,
isExecutionStillCurrent,
markStopping,
restoreRunning,
clearSession,
finalizeExit,
timeoutMs = 1000,
}: StopProjectActionTerminalSessionOptions): Promise<void> => {
markStopping();
const exitPromise = waitForTerminalExit(terminal, sessionId, timeoutMs);
try {
if (isExecutionStillCurrent()) {
await terminal.sendInput(sessionId, '\x03');
}
} catch {
// noop
}
const exitObserved = await exitPromise;
if (!isExecutionStillCurrent()) {
return;
}
if (!exitObserved) {
let terminationFailed = false;
if (terminal.forceKill) {
try {
if (isExecutionStillCurrent()) {
await terminal.forceKill({ sessionId });
}
} catch {
terminationFailed = true;
}
} else {
try {
if (isExecutionStillCurrent()) {
await terminal.close(sessionId);
}
} catch {
terminationFailed = true;
}
}
if (!isExecutionStillCurrent()) {
return;
}
if (terminationFailed) {
restoreRunning();
return;
}
clearSession();
}
if (!isExecutionStillCurrent()) {
return;
}
finalizeExit();
};
const reconcileFlightsByTerminal = new WeakMap<
TerminalAPI,
Map<string, Promise<ReconcileTerminalSessionAuthorityResult | null>>
>();
export const reconcileTerminalSessionAuthority = (
terminal: TerminalAPI,
directory: string,
options: ReconcileTerminalSessionAuthorityOptions = {},
): Promise<ReconcileTerminalSessionAuthorityResult | null> => {
if (!terminal.listSessions) {
return Promise.resolve(null);
}
const normalizedDirectory = normalizeDirectory(directory);
let terminalFlights = reconcileFlightsByTerminal.get(terminal);
if (!terminalFlights) {
terminalFlights = new Map();
reconcileFlightsByTerminal.set(terminal, terminalFlights);
}
const existing = terminalFlights.get(normalizedDirectory);
if (existing) {
return existing;
}
const startedActionMutationRevisions = options.captureStartedActionMutationRevisions?.(normalizedDirectory)
?? new Map<string, number>();
const flight = terminal.listSessions(normalizedDirectory)
.then((sessions) => ({ sessions, startedActionMutationRevisions }))
.catch(() => null)
.finally(() => {
if (terminalFlights.get(normalizedDirectory) === flight) {
terminalFlights.delete(normalizedDirectory);
if (terminalFlights.size === 0) {
reconcileFlightsByTerminal.delete(terminal);
}
}
});
terminalFlights.set(normalizedDirectory, flight);
return flight;
};
@@ -18,4 +18,22 @@ describe('resolveProjectForSessionDirectory', () => {
expect(resolveProjectForSessionDirectory(projects, worktrees, '/workspace/openchamber-feature')).toEqual(projects[0]);
});
test('prefers registered worktree ownership over a containing project', () => {
const configuredProjects = [
{ id: 'home', path: '/Users/elfy', label: 'Home' },
{ id: 'infoscan', path: '/Users/elfy/GitRepos/infoscan', label: 'InfoScan' },
];
const worktreePath = '/Users/elfy/.local/share/opencode/worktree/refactor-self-hosted-runners';
const worktrees = new Map([
['/Users/elfy/GitRepos/infoscan', [{
path: worktreePath,
projectDirectory: '/Users/elfy/GitRepos/infoscan',
branch: 'refactor/self-hosted-runners',
label: 'refactor/self-hosted-runners',
}]],
]);
expect(resolveProjectForSessionDirectory(configuredProjects, worktrees, worktreePath)).toEqual(configuredProjects[1]);
});
});
+14 -6
View File
@@ -24,7 +24,7 @@ const resolveProjectFromWorktreeDirectory = (
projects: ProjectEntry[],
availableWorktreesByProject: Map<string, WorktreeMetadata[]>,
directory: string | null,
): ProjectEntry | null => {
): { project: ProjectEntry; matchedWorktreePathLength: number } | null => {
const nd = normalizeProjectPath(directory);
if (!nd) return null;
let matchedWorktree: WorktreeMetadata | null = null;
@@ -47,9 +47,9 @@ const resolveProjectFromWorktreeDirectory = (
.filter((v): v is string => Boolean(v));
for (const c of candidates) {
const exact = projects.find((p) => normalizeProjectPath(p.path) === c) ?? null;
if (exact) return exact;
if (exact) return { project: exact, matchedWorktreePathLength: bestLen };
const nested = resolveProjectForDirectory(projects, c);
if (nested) return nested;
if (nested) return { project: nested, matchedWorktreePathLength: bestLen };
}
return null;
};
@@ -58,6 +58,14 @@ export const resolveProjectForSessionDirectory = (
projects: ProjectEntry[],
availableWorktreesByProject: Map<string, WorktreeMetadata[]>,
directory: string | null,
): ProjectEntry | null =>
resolveProjectForDirectory(projects, directory) ??
resolveProjectFromWorktreeDirectory(projects, availableWorktreesByProject, directory);
): ProjectEntry | null => {
const directProject = resolveProjectForDirectory(projects, directory);
const worktreeResolution = resolveProjectFromWorktreeDirectory(projects, availableWorktreesByProject, directory);
if (!directProject) return worktreeResolution?.project ?? null;
if (!worktreeResolution) return directProject;
const directPathLength = normalizeProjectPath(directProject.path)?.length ?? 0;
return worktreeResolution.matchedWorktreePathLength > directPathLength
? worktreeResolution.project
: directProject;
};
+115 -26
View File
@@ -1,23 +1,53 @@
import { describe, expect, test } from 'bun:test';
import { describe, expect, mock, test } from 'bun:test';
import type { TerminalSessionPurpose, TerminalStreamEvent } from './api/types';
import type { RelayTunnelWebSocket } from './relay/tunnel-client';
import { TerminalTransport } from './terminalApi';
mock.module('./runtime-fetch', () => ({ runtimeFetch: async () => new Response(null, { status: 500 }) }));
mock.module('./runtime-url', () => ({ getRuntimeUrlResolver: () => ({ websocket: () => 'ws://example.test/terminal' }) }));
mock.module('./runtime-auth', () => ({
clearRuntimeUrlAuthToken: () => undefined,
refreshRuntimeUrlAuthToken: async () => undefined,
}));
mock.module('./relay/runtime-socket', () => ({ openRuntimeWebSocket: () => { throw new Error('not used in tests'); } }));
const { parseTerminalSession, parseTerminalSessionPurpose, TerminalTransport } = await import('./terminalApi');
const encoder = new TextEncoder();
const decoder = new TextDecoder();
const frame = (message: Record<string, unknown>): Uint8Array => {
type WireMessage = {
t: string;
s?: string;
q?: number;
v?: number;
d?: string;
r?: string;
history?: string;
status?: TerminalStreamEvent['status'];
exitCode?: number;
signal?: number | null;
code?: string;
message?: string;
fatal?: boolean;
mode?: 'interactive' | 'command';
purpose?: TerminalSessionPurpose | { type: 'project-action'; actionId: string };
};
const frame = (message: WireMessage): Uint8Array => {
const body = encoder.encode(JSON.stringify(message));
const result = new Uint8Array(body.length + 1);
result[0] = 1;
result.set(body, 1);
return result;
};
const parseFrame = (value: string | ArrayBuffer | ArrayBufferView): Record<string, unknown> => {
const bytes = typeof value === 'string'
? encoder.encode(value)
: value instanceof ArrayBuffer
? new Uint8Array(value)
: new Uint8Array(value.buffer, value.byteOffset, value.byteLength);
return JSON.parse(decoder.decode(bytes.subarray(1))) as Record<string, unknown>;
const parseFrame = (value: string | ArrayBuffer | ArrayBufferView): WireMessage => {
const bytes = value instanceof ArrayBuffer
? new Uint8Array(value)
: ArrayBuffer.isView(value)
? new Uint8Array(value.buffer, value.byteOffset, value.byteLength)
: encoder.encode(value);
const parsed = JSON.parse(decoder.decode(bytes.subarray(1)));
// SAFETY: test frames are encoded from `WireMessage`, so decoding that same frame preserves the wire shape here.
return parsed as WireMessage;
};
class FakeSocket implements RelayTunnelWebSocket {
@@ -27,12 +57,12 @@ class FakeSocket implements RelayTunnelWebSocket {
onmessage: RelayTunnelWebSocket['onmessage'] = null;
onerror: (() => void) | null = null;
onclose: RelayTunnelWebSocket['onclose'] = null;
sent: Record<string, unknown>[] = [];
sent: WireMessage[] = [];
open(): void { this.readyState = 1; this.onopen?.(); }
emit(message: Record<string, unknown>): void {
emit(message: WireMessage): void {
const bytes = frame(message);
this.onmessage?.({ data: bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer });
this.onmessage?.({ data: bytes.slice().buffer });
}
send(data: string | ArrayBuffer | ArrayBufferView): void { this.sent.push(parseFrame(data)); }
close(): void { this.readyState = 3; this.onclose?.({ code: 1000, reason: '' }); }
@@ -41,6 +71,38 @@ class FakeSocket implements RelayTunnelWebSocket {
const tick = () => new Promise((resolve) => setTimeout(resolve, 0));
describe('terminal transport', () => {
test('parses the terminal purpose union and rejects malformed payloads', () => {
expect(parseTerminalSessionPurpose({ type: 'terminal' })).toEqual({ type: 'terminal' });
expect(parseTerminalSessionPurpose({ type: 'project-action', actionId: 'build', executionId: 'exec-1' })).toEqual({
type: 'project-action',
actionId: 'build',
executionId: 'exec-1',
});
expect(parseTerminalSessionPurpose({ type: 'project-action', actionId: 'build' })).toBe(undefined);
expect(parseTerminalSession({
sessionId: 'term-1',
cols: 80,
rows: 24,
status: 'running',
mode: 'command',
purpose: { type: 'project-action', actionId: 'build', executionId: 'exec-1' },
})).toEqual({
sessionId: 'term-1',
cols: 80,
rows: 24,
status: 'running',
mode: 'command',
purpose: { type: 'project-action', actionId: 'build', executionId: 'exec-1' },
});
expect(parseTerminalSession({
sessionId: 'term-1',
cols: 80,
rows: 24,
status: 'running',
purpose: { type: 'project-action', actionId: 'build' },
})).toBeNull();
});
test('hydrates simultaneous subscribers and rejects duplicate sequences', async () => {
const socket = new FakeSocket();
const transport = new TerminalTransport({ refreshAuth: async () => '', openSocket: () => socket });
@@ -52,7 +114,7 @@ describe('terminal transport', () => {
expect(socket.sent.some((message) => message.t === 'attach' && message.s === 'term-1')).toBe(true);
expect(socket.sent.filter((message) => message.t === 'attach')).toHaveLength(1);
socket.emit({ t: 'snapshot', v: 3, s: 'term-1', q: 1, history: 'prompt', status: 'running' });
socket.emit({ t: 'snapshot', v: 3, s: 'term-1', q: 1, history: 'prompt', status: 'running', mode: 'command', purpose: { type: 'project-action', actionId: 'build', executionId: 'exec-1' } });
await tick();
const secondEvents: string[] = [];
transport.subscribe('term-1', { onEvent: (event) => secondEvents.push(`${event.type}:${event.data ?? ''}`) });
@@ -71,8 +133,8 @@ describe('terminal transport', () => {
});
test('recovers when opening the first websocket fails', async () => {
if (typeof document !== 'undefined') Object.defineProperty(document, 'visibilityState', { configurable: true, value: 'visible' });
if (typeof navigator !== 'undefined') Object.defineProperty(navigator, 'onLine', { configurable: true, value: true });
if (globalThis.document) Object.defineProperty(globalThis.document, 'visibilityState', { configurable: true, value: 'visible' });
if (globalThis.navigator) Object.defineProperty(globalThis.navigator, 'onLine', { configurable: true, value: true });
const socket = new FakeSocket();
let attempts = 0;
const events: string[] = [];
@@ -165,7 +227,8 @@ describe('terminal transport', () => {
const unsubscribeFirst = transport.subscribe('term-1', {
onEvent: (event) => {
if (event.type === 'reconnecting' && typeof event.attempt === 'number') firstEvents.push(event.attempt);
if (event.type !== 'reconnecting' || event.attempt == null) return;
firstEvents.push(event.attempt);
},
});
await tick();
@@ -175,7 +238,8 @@ describe('terminal transport', () => {
unsubscribeFirst();
const unsubscribeReplacement = transport.subscribe('term-2', {
onEvent: (event) => {
if (event.type === 'reconnecting' && typeof event.attempt === 'number') replacementEvents.push(event.attempt);
if (event.type !== 'reconnecting' || event.attempt == null) return;
replacementEvents.push(event.attempt);
},
});
await tick();
@@ -190,7 +254,7 @@ describe('terminal transport', () => {
const originalSetTimeout = globalThis.setTimeout;
const originalDocument = Object.getOwnPropertyDescriptor(globalThis, 'document');
const delays: number[] = [];
let transport: TerminalTransport | null = null;
let transport: InstanceType<typeof TerminalTransport> | null = null;
Object.defineProperty(globalThis, 'document', {
configurable: true,
@@ -200,11 +264,16 @@ describe('terminal transport', () => {
removeEventListener: () => {},
},
});
globalThis.setTimeout = ((handler: TimerHandler, timeout?: number, ...args: unknown[]) => {
delays.push(Number(timeout ?? 0));
if (timeout === 0) return originalSetTimeout(handler, 0, ...args);
return 0 as unknown as ReturnType<typeof setTimeout>;
}) as typeof setTimeout;
Object.defineProperty(globalThis, 'setTimeout', {
configurable: true,
value: (handler: TimerHandler, timeout?: number, ...args: unknown[]) => {
delays.push(Number(timeout ?? 0));
if (timeout === 0) return originalSetTimeout(handler, 0, ...args);
const handle = originalSetTimeout(() => {}, 0);
clearTimeout(handle);
return handle;
},
});
try {
transport = new TerminalTransport({
@@ -218,9 +287,9 @@ describe('terminal transport', () => {
expect(delays).toContain(60_000);
} finally {
transport?.dispose();
globalThis.setTimeout = originalSetTimeout;
Object.defineProperty(globalThis, 'setTimeout', { configurable: true, value: originalSetTimeout });
if (originalDocument) Object.defineProperty(globalThis, 'document', originalDocument);
else delete (globalThis as { document?: unknown }).document;
else Reflect.deleteProperty(globalThis, 'document');
}
});
@@ -283,6 +352,26 @@ describe('terminal transport', () => {
transport.dispose();
});
test('preserves valid snapshot purpose and safely drops malformed snapshot purpose', async () => {
const socket = new FakeSocket();
const transport = new TerminalTransport({ refreshAuth: async () => '', openSocket: () => socket });
const purposes: Array<string | null> = [];
transport.subscribe('term-1', {
onEvent: (event) => {
if (event.type !== 'snapshot') return;
purposes.push(event.purpose?.type === 'project-action' ? event.purpose.executionId : null);
},
});
await tick();
socket.open();
await tick();
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']);
transport.dispose();
});
test('reuses the open socket when switching between terminals', async () => {
const sockets: FakeSocket[] = [];
let authCalls = 0;
+103 -27
View File
@@ -1,17 +1,37 @@
import type { CreateTerminalOptions, TerminalError, TerminalHandlers, TerminalServerSession, TerminalSession, 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 type { 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 };
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 Subscriber = { handlers: TerminalHandlers; lastSequence: number };
type TerminalProjection = {
sequence: number;
history: string;
status: TerminalStreamEvent['status'];
mode?: TerminalSession['mode'];
purpose?: TerminalSessionPurpose;
exitCode?: number;
signal?: number | null;
runtime?: TerminalStreamEvent['runtime'];
@@ -30,6 +50,42 @@ const SOCKET_OPEN = 1;
const IDLE_SOCKET_GRACE_MS = 15_000;
const encoder = new TextEncoder();
const decoder = new TextDecoder();
const terminalModeSchema = z.enum(['interactive', 'command']);
const terminalStatusSchema = z.enum(['running', 'exited', 'error']);
const terminalRuntimeSchema = z.enum(['node', 'bun']);
type TerminalSessionPurposeInput =
| TerminalSessionPurpose
| { type: 'project-action'; actionId: string; executionId?: string }
| null
| undefined;
type TerminalSessionInput = {
sessionId?: string;
cols?: number;
rows?: number;
status?: 'running' | 'exited' | 'error';
mode?: 'interactive' | 'command';
purpose?: TerminalSessionPurposeInput;
} | null | undefined;
const terminalSessionPurposeSchema = z.discriminatedUnion('type', [
z.object({ type: z.literal('terminal') }),
z.object({ type: z.literal('project-action'), actionId: z.string(), executionId: z.string() }),
]);
const terminalSessionSchema = z.object({
sessionId: z.string(),
cols: z.number(),
rows: z.number(),
status: terminalStatusSchema,
mode: terminalModeSchema.optional(),
purpose: terminalSessionPurposeSchema.optional(),
});
const terminalServerSessionSchema = z.object({
sessionId: z.string(),
cwd: z.string(),
status: z.enum(['running', 'exited']),
createdAt: z.number().nullable().optional().transform((value) => value ?? null),
mode: terminalModeSchema.optional(),
purpose: terminalSessionPurposeSchema.optional(),
});
const encode = (message: Message): Uint8Array => {
const payload = encoder.encode(JSON.stringify(message));
@@ -63,6 +119,28 @@ const trimProjection = (value: string): string => {
return decoder.decode(bytes.subarray(start));
};
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;
};
export const parseTerminalSession = (value: TerminalSessionInput): TerminalSession | null => {
return terminalSessionSchema.safeParse(value).data ?? null;
};
type TerminalTransportDependencies = {
refreshAuth: () => Promise<unknown>;
openSocket: () => RelayTunnelWebSocket;
@@ -98,7 +176,7 @@ export class TerminalTransport {
const projection = this.projections.get(sessionId);
if (projection) {
subscriber.lastSequence = projection.sequence;
handlers.onEvent({ type: 'snapshot', sequence: projection.sequence, data: projection.history, status: projection.status, exitCode: projection.exitCode, signal: projection.signal, runtime: projection.runtime, ptyBackend: projection.ptyBackend });
handlers.onEvent({ type: 'snapshot', sequence: projection.sequence, data: projection.history, status: projection.status, mode: projection.mode, purpose: projection.purpose, exitCode: projection.exitCode, signal: projection.signal, runtime: projection.runtime, ptyBackend: projection.ptyBackend });
}
const socketWasOpen = this.socket?.readyState === SOCKET_OPEN;
this.ensureConnected().then(() => {
@@ -267,18 +345,20 @@ export class TerminalTransport {
if (!subscribers) return;
if (message.t === 'snapshot') {
const projection: TerminalProjection = {
sequence: typeof message.q === 'number' ? message.q : 0,
history: typeof message.history === 'string' ? message.history : '',
status: message.status as TerminalStreamEvent['status'],
exitCode: typeof message.exitCode === 'number' ? message.exitCode : undefined,
signal: typeof message.signal === 'number' ? message.signal : null,
runtime: message.runtime as TerminalStreamEvent['runtime'],
ptyBackend: typeof message.ptyBackend === 'string' ? message.ptyBackend : undefined,
sequence: message.q ?? 0,
history: message.history ?? '',
status: parseTerminalStatus(message.status),
mode: parseTerminalMode(message.mode),
purpose: parseTerminalSessionPurpose(message.purpose),
exitCode: message.exitCode,
signal: message.signal ?? null,
runtime: parseTerminalRuntime(message.runtime),
ptyBackend: message.ptyBackend,
};
this.projections.set(message.s, projection);
for (const sub of subscribers) {
sub.lastSequence = projection.sequence;
sub.handlers.onEvent({ type: 'snapshot', sequence: projection.sequence, data: projection.history, status: projection.status, exitCode: projection.exitCode, signal: projection.signal, runtime: projection.runtime, ptyBackend: projection.ptyBackend });
sub.handlers.onEvent({ type: 'snapshot', sequence: projection.sequence, data: projection.history, status: projection.status, mode: projection.mode, purpose: projection.purpose, exitCode: projection.exitCode, signal: projection.signal, runtime: projection.runtime, ptyBackend: projection.ptyBackend });
}
return;
}
@@ -287,14 +367,14 @@ export class TerminalTransport {
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: typeof message.history === 'string' ? message.history : '', status: 'running', exitCode: undefined, 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 });
}
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: typeof message.history === 'string' ? message.history : '', status: 'running' });
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 });
}
}
@@ -354,27 +434,23 @@ let transport = new TerminalTransport();
export async function createTerminalSession(options: CreateTerminalOptions): Promise<TerminalSession> {
const response = await runtimeFetch('/api/terminal/create', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(options) });
if (!response.ok) throw await responseError(response, 'Failed to create terminal session');
return response.json() as Promise<TerminalSession>;
const payload: unknown = await response.json().catch(() => null);
const parsed = terminalSessionSchema.safeParse(payload).data;
if (!parsed) throw new Error('Failed to create terminal session');
return parsed;
}
export async function listTerminalSessions(cwd: string): Promise<TerminalServerSession[]> {
const response = await runtimeFetch(`/api/terminal/sessions?cwd=${encodeURIComponent(cwd)}`);
if (!response.ok) throw await responseError(response, 'Failed to list terminal sessions');
const payload: unknown = await response.json().catch(() => null);
const rawSessions = payload && typeof payload === 'object' && 'sessions' in payload ? payload.sessions : null;
const rawSessions = terminalSessionListSchema.safeParse(payload).data?.sessions;
if (!Array.isArray(rawSessions)) throw new Error('Failed to list terminal sessions');
const parsed: TerminalServerSession[] = [];
for (const entry of rawSessions as unknown[]) {
if (typeof entry !== 'object' || entry === null) continue;
// SAFETY: every field is verified below before the value is used.
const candidate = entry as Partial<Record<keyof TerminalServerSession, unknown>>;
if (typeof candidate.sessionId !== 'string' || typeof candidate.cwd !== 'string') continue;
if (candidate.status !== 'running' && candidate.status !== 'exited') continue;
parsed.push({
sessionId: candidate.sessionId,
cwd: candidate.cwd,
status: candidate.status,
createdAt: typeof candidate.createdAt === 'number' ? candidate.createdAt : null,
});
for (const entry of rawSessions) {
const session = terminalServerSessionSchema.safeParse(entry).data;
if (session) {
parsed.push(session);
}
}
return parsed;
}