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:
@@ -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 l’URL',
|
||||
'settings.projects.actions.field.autoOpenUrlForAria': 'Ouverture automatique de l’URL pour {title}',
|
||||
'settings.projects.actions.field.autoOpenUrlDescription': 'Ouvrir l’URL détectée dans la sortie, ou l’URL 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',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user