fix(terminal): reconcile project action executions across clients (#3362)
This commit is contained in:
committed by
GitHub
parent
4e0eed717d
commit
d37ce34a2e
@@ -45,12 +45,12 @@ describe('project action terminal lifecycle', () => {
|
||||
expect(normalizeProjectActionCommand(' printf "hi"\r\nexit\u0007 ')).toBe('printf "hi"\nexit');
|
||||
});
|
||||
|
||||
test('closes the previous session before creating a command-mode run', async () => {
|
||||
test('creates a command-mode run under its execution ID', async () => {
|
||||
const calls: string[] = [];
|
||||
const terminal: TerminalAPI = {
|
||||
createSession: async (options) => {
|
||||
calls.push(`create:${JSON.stringify(options)}`);
|
||||
return { sessionId: 'tab-1', cols: 80, rows: 24, status: 'running', mode: 'command', purpose: { type: 'project-action', actionId: 'build', executionId: 'exec-1' } };
|
||||
return { sessionId: 'exec-1', cols: 80, rows: 24, status: 'running', mode: 'command', purpose: { type: 'project-action', actionId: 'build', executionId: 'exec-1' } };
|
||||
},
|
||||
connect: () => ({ close: () => {} }),
|
||||
sendInput: async () => {},
|
||||
@@ -62,27 +62,24 @@ describe('project action terminal lifecycle', () => {
|
||||
|
||||
const created = await createProjectActionTerminalSession({
|
||||
terminal,
|
||||
previousSessionId: 'stale-session',
|
||||
createOptions: {
|
||||
cwd: '/repo',
|
||||
sessionId: 'tab-1',
|
||||
},
|
||||
command: 'echo hello',
|
||||
isRunStillExpected: () => true,
|
||||
purpose: { type: 'project-action', actionId: 'build', executionId: 'exec-1' },
|
||||
});
|
||||
|
||||
expect(created).toEqual({ sessionId: 'tab-1', cols: 80, rows: 24, status: 'running', mode: 'command', purpose: { type: 'project-action', actionId: 'build', executionId: 'exec-1' } });
|
||||
expect(created).toEqual({ sessionId: 'exec-1', cols: 80, rows: 24, status: 'running', mode: 'command', purpose: { type: 'project-action', actionId: 'build', executionId: 'exec-1' } });
|
||||
expect(calls).toEqual([
|
||||
'close:stale-session',
|
||||
'create:{"cwd":"/repo","sessionId":"tab-1","mode":"command","command":"echo hello","purpose":{"type":"project-action","actionId":"build","executionId":"exec-1"}}',
|
||||
'create:{"cwd":"/repo","sessionId":"exec-1","mode":"command","command":"echo hello","purpose":{"type":"project-action","actionId":"build","executionId":"exec-1"}}',
|
||||
]);
|
||||
});
|
||||
|
||||
test('rejects and closes a create response that does not echo command mode', async () => {
|
||||
const closed: string[] = [];
|
||||
const terminal: TerminalAPI = {
|
||||
createSession: async () => ({ sessionId: 'tab-1', cols: 80, rows: 24, status: 'running' }),
|
||||
createSession: async () => ({ sessionId: 'exec-1', cols: 80, rows: 24, status: 'running' }),
|
||||
connect: () => ({ close: () => {} }),
|
||||
sendInput: async () => {},
|
||||
resize: async () => {},
|
||||
@@ -93,22 +90,20 @@ describe('project action terminal lifecycle', () => {
|
||||
|
||||
await expect(createProjectActionTerminalSession({
|
||||
terminal,
|
||||
previousSessionId: null,
|
||||
createOptions: {
|
||||
cwd: '/repo',
|
||||
sessionId: 'tab-1',
|
||||
},
|
||||
command: 'echo hello',
|
||||
isRunStillExpected: () => true,
|
||||
purpose: { type: 'project-action', actionId: 'build', executionId: 'exec-1' },
|
||||
})).rejects.toThrow('COMMAND_MODE_UNSUPPORTED');
|
||||
expect(closed).toEqual(['tab-1']);
|
||||
expect(closed).toEqual(['exec-1']);
|
||||
});
|
||||
|
||||
test('closes a newly created command session when stop removes the run during create', async () => {
|
||||
const closed: string[] = [];
|
||||
const terminal: TerminalAPI = {
|
||||
createSession: async () => ({ sessionId: 'tab-1', cols: 80, rows: 24, status: 'running', mode: 'command', purpose: { type: 'project-action', actionId: 'build', executionId: 'exec-1' } }),
|
||||
createSession: async () => ({ sessionId: 'exec-1', cols: 80, rows: 24, status: 'running', mode: 'command', purpose: { type: 'project-action', actionId: 'build', executionId: 'exec-1' } }),
|
||||
connect: () => ({ close: () => {} }),
|
||||
sendInput: async () => {},
|
||||
resize: async () => {},
|
||||
@@ -119,22 +114,20 @@ describe('project action terminal lifecycle', () => {
|
||||
|
||||
await expect(createProjectActionTerminalSession({
|
||||
terminal,
|
||||
previousSessionId: null,
|
||||
createOptions: {
|
||||
cwd: '/repo',
|
||||
sessionId: 'tab-1',
|
||||
},
|
||||
command: 'echo hello',
|
||||
isRunStillExpected: () => false,
|
||||
purpose: { type: 'project-action', actionId: 'build', executionId: 'exec-1' },
|
||||
})).rejects.toThrow('PROJECT_ACTION_RUN_CANCELLED');
|
||||
expect(closed).toEqual(['tab-1']);
|
||||
expect(closed).toEqual(['exec-1']);
|
||||
});
|
||||
|
||||
test('rejects and closes a create response that does not echo project-action purpose', async () => {
|
||||
const closed: string[] = [];
|
||||
const terminal: TerminalAPI = {
|
||||
createSession: async () => ({ sessionId: 'tab-1', cols: 80, rows: 24, status: 'running', mode: 'command' }),
|
||||
createSession: async () => ({ sessionId: 'exec-1', cols: 80, rows: 24, status: 'running', mode: 'command' }),
|
||||
connect: () => ({ close: () => {} }),
|
||||
sendInput: async () => {},
|
||||
resize: async () => {},
|
||||
@@ -145,13 +138,12 @@ describe('project action terminal lifecycle', () => {
|
||||
|
||||
await expect(createProjectActionTerminalSession({
|
||||
terminal,
|
||||
previousSessionId: null,
|
||||
createOptions: { cwd: '/repo', sessionId: 'tab-1' },
|
||||
createOptions: { cwd: '/repo' },
|
||||
command: 'echo hello',
|
||||
isRunStillExpected: () => true,
|
||||
purpose: { type: 'project-action', actionId: 'build', executionId: 'exec-1' },
|
||||
})).rejects.toThrow('PROJECT_ACTION_PURPOSE_UNSUPPORTED');
|
||||
expect(closed).toEqual(['tab-1']);
|
||||
expect(closed).toEqual(['exec-1']);
|
||||
});
|
||||
|
||||
test('reuses one in-flight authority listing per directory', async () => {
|
||||
@@ -293,3 +285,19 @@ describe('project action terminal lifecycle', () => {
|
||||
expect(finalized).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
test('cancelling a local request does not close a run adopted from another client', async () => {
|
||||
const closed: string[] = [];
|
||||
const terminal: TerminalAPI = {
|
||||
createSession: async () => ({ sessionId: 'other-client', cols: 80, rows: 24, status: 'running', mode: 'command', purpose: { type: 'project-action', actionId: 'build', executionId: 'other-execution' } }),
|
||||
connect: () => ({ close() {} }),
|
||||
sendInput: async () => {}, resize: async () => {},
|
||||
close: async id => { closed.push(id); },
|
||||
};
|
||||
await expect(createProjectActionTerminalSession({
|
||||
terminal, createOptions: { cwd: '/repo' }, command: 'echo hello',
|
||||
purpose: { type: 'project-action', actionId: 'build', executionId: 'requested-execution' },
|
||||
isRunStillExpected: () => false,
|
||||
})).rejects.toThrow('PROJECT_ACTION_RUN_CANCELLED');
|
||||
expect(closed).toEqual([]);
|
||||
});
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { getRuntimeKey } from './runtime-switch';
|
||||
import type { CreateTerminalOptions, TerminalAPI, TerminalServerSession, TerminalSession, TerminalSessionPurpose } from './api/types';
|
||||
|
||||
type TerminalActionMutationRevisions = ReadonlyMap<string, number>;
|
||||
@@ -10,11 +11,10 @@ const normalizeDirectory = (dir: string): string => {
|
||||
return normalized;
|
||||
};
|
||||
|
||||
type ProjectActionTerminalCreateOptions = Omit<Extract<CreateTerminalOptions, { mode: 'command' }>, 'mode' | 'command'>;
|
||||
type ProjectActionTerminalCreateOptions = Omit<Extract<CreateTerminalOptions, { mode: 'command' }>, 'mode' | 'command' | 'sessionId'>;
|
||||
|
||||
type CreateProjectActionTerminalSessionOptions = {
|
||||
terminal: TerminalAPI;
|
||||
previousSessionId: string | null;
|
||||
createOptions: ProjectActionTerminalCreateOptions;
|
||||
command: string;
|
||||
isRunStillExpected: () => boolean;
|
||||
@@ -46,8 +46,9 @@ const closeTerminalSession = async (terminal: TerminalAPI, sessionId: string): P
|
||||
}
|
||||
};
|
||||
|
||||
const rejectCreatedSession = async (terminal: TerminalAPI, sessionId: string, errorMessage: string): Promise<never> => {
|
||||
await closeTerminalSession(terminal, sessionId);
|
||||
const rejectCreatedSession = async (terminal: TerminalAPI, session: TerminalSession, requestedExecutionId: string, errorMessage: string): Promise<never> => {
|
||||
// A deduplicated response belongs to the peer that created it.
|
||||
if (session.sessionId === requestedExecutionId) await closeTerminalSession(terminal, session.sessionId);
|
||||
throw createProjectActionTerminalError(errorMessage);
|
||||
};
|
||||
|
||||
@@ -93,33 +94,29 @@ type ReconcileTerminalSessionAuthorityResult = {
|
||||
|
||||
export const createProjectActionTerminalSession = async ({
|
||||
terminal,
|
||||
previousSessionId,
|
||||
createOptions,
|
||||
command,
|
||||
isRunStillExpected,
|
||||
purpose,
|
||||
}: CreateProjectActionTerminalSessionOptions): Promise<TerminalSession> => {
|
||||
if (previousSessionId) {
|
||||
await closeTerminalSession(terminal, previousSessionId);
|
||||
}
|
||||
|
||||
const created = await terminal.createSession({
|
||||
...createOptions,
|
||||
sessionId: purpose.executionId,
|
||||
mode: 'command',
|
||||
command: normalizeProjectActionCommand(command),
|
||||
purpose,
|
||||
});
|
||||
|
||||
if (!isCommandTerminalSession(created)) {
|
||||
await rejectCreatedSession(terminal, created.sessionId, COMMAND_MODE_UNSUPPORTED_ERROR);
|
||||
await rejectCreatedSession(terminal, created, purpose.executionId, COMMAND_MODE_UNSUPPORTED_ERROR);
|
||||
}
|
||||
|
||||
if (!isMatchingProjectActionPurpose(created.purpose, purpose.actionId)) {
|
||||
await rejectCreatedSession(terminal, created.sessionId, PROJECT_ACTION_PURPOSE_UNSUPPORTED_ERROR);
|
||||
await rejectCreatedSession(terminal, created, purpose.executionId, PROJECT_ACTION_PURPOSE_UNSUPPORTED_ERROR);
|
||||
}
|
||||
|
||||
if (!isRunStillExpected()) {
|
||||
await rejectCreatedSession(terminal, created.sessionId, PROJECT_ACTION_RUN_CANCELLED_ERROR);
|
||||
await rejectCreatedSession(terminal, created, purpose.executionId, PROJECT_ACTION_RUN_CANCELLED_ERROR);
|
||||
}
|
||||
|
||||
return created;
|
||||
@@ -228,12 +225,14 @@ export const reconcileTerminalSessionAuthority = (
|
||||
}
|
||||
|
||||
const normalizedDirectory = normalizeDirectory(directory);
|
||||
const runtimeKey = getRuntimeKey();
|
||||
const flightKey = `${runtimeKey}\u0000${normalizedDirectory}`;
|
||||
let terminalFlights = reconcileFlightsByTerminal.get(terminal);
|
||||
if (!terminalFlights) {
|
||||
terminalFlights = new Map();
|
||||
reconcileFlightsByTerminal.set(terminal, terminalFlights);
|
||||
}
|
||||
const existing = terminalFlights.get(normalizedDirectory);
|
||||
const existing = terminalFlights.get(flightKey);
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
@@ -241,16 +240,16 @@ export const reconcileTerminalSessionAuthority = (
|
||||
const startedActionMutationRevisions = options.captureStartedActionMutationRevisions?.(normalizedDirectory)
|
||||
?? new Map<string, number>();
|
||||
const flight = terminal.listSessions(normalizedDirectory)
|
||||
.then((sessions) => ({ sessions, startedActionMutationRevisions }))
|
||||
.then((sessions) => runtimeKey === getRuntimeKey() ? { sessions, startedActionMutationRevisions } : null)
|
||||
.catch(() => null)
|
||||
.finally(() => {
|
||||
if (terminalFlights.get(normalizedDirectory) === flight) {
|
||||
terminalFlights.delete(normalizedDirectory);
|
||||
if (terminalFlights.get(flightKey) === flight) {
|
||||
terminalFlights.delete(flightKey);
|
||||
if (terminalFlights.size === 0) {
|
||||
reconcileFlightsByTerminal.delete(terminal);
|
||||
}
|
||||
}
|
||||
});
|
||||
terminalFlights.set(normalizedDirectory, flight);
|
||||
terminalFlights.set(flightKey, flight);
|
||||
return flight;
|
||||
};
|
||||
|
||||
@@ -352,7 +352,7 @@ describe('terminal transport', () => {
|
||||
transport.dispose();
|
||||
});
|
||||
|
||||
test('preserves valid snapshot purpose and safely drops malformed snapshot purpose', async () => {
|
||||
test('preserves valid snapshot purpose and rejects a malformed restarted frame', async () => {
|
||||
const socket = new FakeSocket();
|
||||
const transport = new TerminalTransport({ refreshAuth: async () => '', openSocket: () => socket });
|
||||
const purposes: Array<string | null> = [];
|
||||
@@ -368,7 +368,7 @@ describe('terminal transport', () => {
|
||||
socket.emit({ t: 'snapshot', v: 3, s: 'term-1', q: 1, history: 'prompt', status: 'running', purpose: { type: 'project-action', actionId: 'build', executionId: 'exec-1' } });
|
||||
socket.emit({ t: 'restarted', v: 3, s: 'term-1', q: 2, history: 'prompt 2', purpose: { type: 'project-action', actionId: 'build' } });
|
||||
await tick();
|
||||
expect(purposes).toEqual(['exec-1', 'exec-1']);
|
||||
expect(purposes).toEqual(['exec-1']);
|
||||
transport.dispose();
|
||||
});
|
||||
|
||||
|
||||
@@ -1,30 +1,16 @@
|
||||
import type { CreateTerminalOptions, TerminalError, TerminalHandlers, TerminalServerSession, TerminalSession, TerminalSessionPurpose, TerminalShellOption, TerminalStreamEvent } from './api/types';
|
||||
import { openRuntimeWebSocket } from './relay/runtime-socket';
|
||||
import type { RelayTunnelWebSocket } from './relay/tunnel-client';
|
||||
import type { RelayTunnelSocketMessageEvent, RelayTunnelWebSocket } from './relay/tunnel-client';
|
||||
import { runtimeFetch } from './runtime-fetch';
|
||||
import { getRuntimeUrlResolver } from './runtime-url';
|
||||
import { clearRuntimeUrlAuthToken, refreshRuntimeUrlAuthToken } from './runtime-auth';
|
||||
import { isTerminalShell } from './terminalShell';
|
||||
import { z } from 'zod';
|
||||
|
||||
type Message = Record<string, unknown> & {
|
||||
t: string;
|
||||
s?: string;
|
||||
q?: number;
|
||||
d?: string;
|
||||
r?: string;
|
||||
history?: string;
|
||||
status?: TerminalStreamEvent['status'];
|
||||
exitCode?: number;
|
||||
signal?: number | null;
|
||||
runtime?: TerminalStreamEvent['runtime'];
|
||||
ptyBackend?: string;
|
||||
mode?: TerminalSession['mode'];
|
||||
purpose?: TerminalSessionPurposeInput;
|
||||
message?: string;
|
||||
code?: string;
|
||||
fatal?: boolean;
|
||||
};
|
||||
type ClientMessage =
|
||||
| { t: 'hello' | 'ping'; v: 3 }
|
||||
| { t: 'attach' | 'detach'; v: 3; s: string }
|
||||
| { t: 'write'; v: 3; s: string; d: string };
|
||||
type Subscriber = { handlers: TerminalHandlers; lastSequence: number };
|
||||
type TerminalProjection = {
|
||||
sequence: number;
|
||||
@@ -87,7 +73,27 @@ const terminalServerSessionSchema = z.object({
|
||||
purpose: terminalSessionPurposeSchema.optional(),
|
||||
});
|
||||
|
||||
const encode = (message: Message): Uint8Array => {
|
||||
const terminalMessageMetadata = {
|
||||
mode: terminalModeSchema.optional(),
|
||||
purpose: terminalSessionPurposeSchema.optional(),
|
||||
};
|
||||
const terminalMessageSchema = z.discriminatedUnion('t', [
|
||||
z.object({ t: z.literal('hello') }),
|
||||
z.object({ t: z.literal('pong') }),
|
||||
z.object({ t: z.literal('error'), s: z.string().optional(), message: z.string().optional(), code: z.string().optional(), fatal: z.boolean().optional() }),
|
||||
z.object({
|
||||
t: z.literal('snapshot'), s: z.string(), q: z.number().int().nonnegative().default(0),
|
||||
history: z.string().default(''), status: terminalStatusSchema,
|
||||
exitCode: z.number().nullish().transform(value => value ?? undefined), signal: z.number().nullable().optional(),
|
||||
runtime: terminalRuntimeSchema.optional(), ptyBackend: z.string().optional(), ...terminalMessageMetadata,
|
||||
}),
|
||||
z.object({ t: z.literal('output'), s: z.string(), q: z.number().int().nonnegative(), d: z.string(), r: z.string().optional() }),
|
||||
z.object({ t: z.literal('exit'), s: z.string(), q: z.number().int().nonnegative(), exitCode: z.number().nullish().transform(value => value ?? undefined), signal: z.number().nullable().optional() }),
|
||||
z.object({ t: z.literal('restarted'), s: z.string(), q: z.number().int().nonnegative(), history: z.string().default(''), ...terminalMessageMetadata }),
|
||||
]);
|
||||
type TerminalMessage = z.infer<typeof terminalMessageSchema>;
|
||||
|
||||
const encode = (message: ClientMessage): Uint8Array => {
|
||||
const payload = encoder.encode(JSON.stringify(message));
|
||||
const frame = new Uint8Array(payload.length + 1);
|
||||
frame[0] = TAG;
|
||||
@@ -95,15 +101,10 @@ const encode = (message: Message): Uint8Array => {
|
||||
return frame;
|
||||
};
|
||||
|
||||
const decode = async (data: unknown): Promise<Message | null> => {
|
||||
let bytes: Uint8Array;
|
||||
if (data instanceof ArrayBuffer) bytes = new Uint8Array(data);
|
||||
else if (data instanceof Uint8Array) bytes = data;
|
||||
else if (typeof Blob !== 'undefined' && data instanceof Blob) bytes = new Uint8Array(await data.arrayBuffer());
|
||||
else if (typeof data === 'string') bytes = encoder.encode(data);
|
||||
else return null;
|
||||
const decode = (data: RelayTunnelSocketMessageEvent['data']): TerminalMessage | null => {
|
||||
let bytes = data instanceof ArrayBuffer ? new Uint8Array(data) : encoder.encode(data);
|
||||
if (bytes[0] === TAG) bytes = bytes.subarray(1);
|
||||
try { return JSON.parse(decoder.decode(bytes)) as Message; } catch { return null; }
|
||||
try { return terminalMessageSchema.safeParse(JSON.parse(decoder.decode(bytes))).data ?? null; } catch { return null; }
|
||||
};
|
||||
|
||||
const responseError = async (response: Response, fallback: string): Promise<Error> => {
|
||||
@@ -121,18 +122,6 @@ const trimProjection = (value: string): string => {
|
||||
|
||||
const terminalSessionListSchema = z.object({ sessions: z.array(z.unknown()) });
|
||||
|
||||
const parseTerminalMode = (value: TerminalSession['mode'] | null | undefined): TerminalSession['mode'] | undefined => {
|
||||
return terminalModeSchema.safeParse(value).data;
|
||||
};
|
||||
|
||||
const parseTerminalStatus = (value: TerminalStreamEvent['status'] | null | undefined): TerminalStreamEvent['status'] => {
|
||||
return terminalStatusSchema.safeParse(value).data ?? 'running';
|
||||
};
|
||||
|
||||
const parseTerminalRuntime = (value: TerminalStreamEvent['runtime'] | null | undefined): TerminalStreamEvent['runtime'] | undefined => {
|
||||
return terminalRuntimeSchema.safeParse(value).data;
|
||||
};
|
||||
|
||||
export const parseTerminalSessionPurpose = (value: TerminalSessionPurposeInput): TerminalSessionPurpose | undefined => {
|
||||
return terminalSessionPurposeSchema.safeParse(value).data;
|
||||
};
|
||||
@@ -330,12 +319,12 @@ export class TerminalTransport {
|
||||
}
|
||||
}
|
||||
|
||||
private async handleMessage(raw: unknown): Promise<void> {
|
||||
const message = await decode(raw);
|
||||
private handleMessage(raw: RelayTunnelSocketMessageEvent['data']): void {
|
||||
const message = decode(raw);
|
||||
if (!message || message.t === 'hello' || message.t === 'pong') return;
|
||||
if (message.t === 'error') {
|
||||
const error = new Error(typeof message.message === 'string' ? message.message : 'Terminal error') as TerminalError;
|
||||
if (typeof message.code === 'string') error.code = message.code;
|
||||
const error: TerminalError = new Error(message.message ?? 'Terminal error');
|
||||
error.code = message.code;
|
||||
const targets = message.s ? [message.s] : [...this.subscribers.keys()];
|
||||
for (const id of targets) for (const sub of this.subscribers.get(id) ?? []) sub.handlers.onError?.(error, message.fatal === true);
|
||||
return;
|
||||
@@ -347,12 +336,12 @@ export class TerminalTransport {
|
||||
const projection: TerminalProjection = {
|
||||
sequence: message.q ?? 0,
|
||||
history: message.history ?? '',
|
||||
status: parseTerminalStatus(message.status),
|
||||
mode: parseTerminalMode(message.mode),
|
||||
purpose: parseTerminalSessionPurpose(message.purpose),
|
||||
status: message.status,
|
||||
mode: message.mode,
|
||||
purpose: message.purpose,
|
||||
exitCode: message.exitCode,
|
||||
signal: message.signal ?? null,
|
||||
runtime: parseTerminalRuntime(message.runtime),
|
||||
runtime: message.runtime,
|
||||
ptyBackend: message.ptyBackend,
|
||||
};
|
||||
this.projections.set(message.s, projection);
|
||||
@@ -362,23 +351,23 @@ export class TerminalTransport {
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (typeof message.q !== 'number') return;
|
||||
|
||||
const previous = this.projections.get(message.s);
|
||||
if (previous && message.q > previous.sequence) {
|
||||
if (message.t === 'output') this.projections.set(message.s, { ...previous, sequence: message.q, history: trimProjection(previous.history + (typeof message.r === 'string' ? message.r : (typeof message.d === 'string' ? message.d : ''))) });
|
||||
else if (message.t === 'exit') this.projections.set(message.s, { ...previous, sequence: message.q, status: 'exited', exitCode: typeof message.exitCode === 'number' ? message.exitCode : undefined, signal: typeof message.signal === 'number' ? message.signal : null });
|
||||
else if (message.t === 'restarted') this.projections.set(message.s, { ...previous, sequence: message.q, history: message.history ?? '', status: 'running', mode: parseTerminalMode(message.mode) ?? previous.mode, purpose: parseTerminalSessionPurpose(message.purpose) ?? previous.purpose, exitCode: undefined, signal: null });
|
||||
if (message.t === 'output') this.projections.set(message.s, { ...previous, sequence: message.q, history: trimProjection(previous.history + (message.r ?? message.d)) });
|
||||
else if (message.t === 'exit') this.projections.set(message.s, { ...previous, sequence: message.q, status: 'exited', exitCode: message.exitCode, signal: message.signal ?? null });
|
||||
else if (message.t === 'restarted') this.projections.set(message.s, { ...previous, sequence: message.q, history: message.history ?? '', status: 'running', mode: message.mode ?? previous.mode, purpose: message.purpose ?? previous.purpose, exitCode: undefined, signal: null });
|
||||
}
|
||||
for (const sub of subscribers) {
|
||||
if (message.q <= sub.lastSequence) continue;
|
||||
sub.lastSequence = message.q;
|
||||
if (message.t === 'output') sub.handlers.onEvent({ type: 'data', sequence: message.q, data: typeof message.d === 'string' ? message.d : '', replayData: typeof message.r === 'string' ? message.r : undefined });
|
||||
else if (message.t === 'exit') sub.handlers.onEvent({ type: 'exit', sequence: message.q, exitCode: typeof message.exitCode === 'number' ? message.exitCode : undefined, signal: typeof message.signal === 'number' ? message.signal : null });
|
||||
else if (message.t === 'restarted') sub.handlers.onEvent({ type: 'snapshot', sequence: message.q, data: message.history ?? '', status: 'running', mode: parseTerminalMode(message.mode) ?? previous?.mode, purpose: parseTerminalSessionPurpose(message.purpose) ?? previous?.purpose });
|
||||
if (message.t === 'output') sub.handlers.onEvent({ type: 'data', sequence: message.q, data: message.d, replayData: message.r });
|
||||
else if (message.t === 'exit') sub.handlers.onEvent({ type: 'exit', sequence: message.q, exitCode: message.exitCode, signal: message.signal ?? null });
|
||||
else if (message.t === 'restarted') sub.handlers.onEvent({ type: 'snapshot', sequence: message.q, data: message.history ?? '', status: 'running', mode: message.mode ?? previous?.mode, purpose: message.purpose ?? previous?.purpose });
|
||||
}
|
||||
}
|
||||
|
||||
private send(message: Message): boolean {
|
||||
private send(message: ClientMessage): boolean {
|
||||
if (!this.socket || this.socket.readyState !== SOCKET_OPEN) return false;
|
||||
try { this.socket.send(encode(message)); return true; } catch { return false; }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import { afterEach, beforeEach, expect, test } from 'bun:test';
|
||||
import { Window } from 'happy-dom';
|
||||
import type { TerminalAPI, TerminalServerSession } from './api/types';
|
||||
import { observeTerminalSessions } from './terminalSessionObserver';
|
||||
import { useTerminalStore } from '@/stores/useTerminalStore';
|
||||
|
||||
let browser: Window;
|
||||
const descriptors = new Map<string, PropertyDescriptor | undefined>();
|
||||
const cleanups: Array<() => void> = [];
|
||||
const tick = () => new Promise(resolve => setTimeout(resolve, 0));
|
||||
|
||||
beforeEach(() => {
|
||||
browser = new Window({ url: 'http://localhost' });
|
||||
for (const [key, value] of Object.entries({ window: browser, document: browser.document, navigator: browser.navigator })) {
|
||||
descriptors.set(key, Object.getOwnPropertyDescriptor(globalThis, key));
|
||||
Object.defineProperty(globalThis, key, { value, configurable: true });
|
||||
}
|
||||
useTerminalStore.getState().clearAll();
|
||||
});
|
||||
afterEach(async () => {
|
||||
for (const close of cleanups.splice(0)) close();
|
||||
await browser.happyDOM.close();
|
||||
for (const [key, descriptor] of descriptors) {
|
||||
if (descriptor) Object.defineProperty(globalThis, key, descriptor);
|
||||
else Reflect.deleteProperty(globalThis, key);
|
||||
}
|
||||
});
|
||||
const createTerminal = () => {
|
||||
let records: TerminalServerSession[] = [];
|
||||
let failed = false;
|
||||
const reads: string[] = [];
|
||||
const terminal: TerminalAPI = {
|
||||
listSessions: async directory => {
|
||||
reads.push(directory);
|
||||
if (failed) throw new Error('offline');
|
||||
return records;
|
||||
},
|
||||
createSession: async () => { throw new Error('unused'); },
|
||||
connect: () => ({ close() {} }), sendInput: async () => {}, resize: async () => {}, close: async () => {},
|
||||
};
|
||||
return { terminal, reads, setRecords: (next: TerminalServerSession[]) => { records = next; }, fail: (value: boolean) => { failed = value; } };
|
||||
};
|
||||
const running: TerminalServerSession = { sessionId: 'peer-run', cwd: '/repo', status: 'running', createdAt: 1, mode: 'command', purpose: { type: 'project-action', actionId: 'build', executionId: 'peer-run' } };
|
||||
|
||||
test('visible consumers share one loop and discover a later peer run without interaction', async () => {
|
||||
const source = createTerminal();
|
||||
const first: TerminalServerSession[][] = [];
|
||||
const second: TerminalServerSession[][] = [];
|
||||
cleanups.push(observeTerminalSessions(source.terminal, '/repo', () => new Map(), result => first.push(result.sessions)));
|
||||
cleanups.push(observeTerminalSessions(source.terminal, '/repo', () => new Map(), result => second.push(result.sessions)));
|
||||
await tick();
|
||||
expect(source.reads).toEqual(['/repo']);
|
||||
source.setRecords([running]);
|
||||
await new Promise(resolve => setTimeout(resolve, 5100));
|
||||
expect(source.reads).toEqual(['/repo', '/repo']);
|
||||
expect(first.at(-1)).toEqual([running]);
|
||||
expect(second.at(-1)).toEqual([running]);
|
||||
}, 10000);
|
||||
|
||||
test('hidden and offline scopes stop reads, wake on recovery, and preserve state on failure', async () => {
|
||||
const source = createTerminal();
|
||||
source.setRecords([running]);
|
||||
const store = useTerminalStore.getState();
|
||||
cleanups.push(observeTerminalSessions(source.terminal, '/repo', store.captureStartedActionMutationRevisions, result => {
|
||||
store.reconcileServerSessions('/repo', result.sessions, { startedActionMutationRevisions: result.startedActionMutationRevisions });
|
||||
}));
|
||||
await tick();
|
||||
Object.defineProperty(browser.document, 'visibilityState', { value: 'hidden', configurable: true });
|
||||
browser.document.dispatchEvent(new browser.Event('visibilitychange'));
|
||||
browser.dispatchEvent(new browser.Event('focus'));
|
||||
await tick();
|
||||
expect(source.reads).toHaveLength(1);
|
||||
Object.defineProperty(browser.document, 'visibilityState', { value: 'visible', configurable: true });
|
||||
Object.defineProperty(browser.navigator, 'onLine', { value: false, configurable: true });
|
||||
browser.document.dispatchEvent(new browser.Event('visibilitychange'));
|
||||
await tick();
|
||||
expect(source.reads).toHaveLength(1);
|
||||
source.fail(true);
|
||||
Object.defineProperty(browser.navigator, 'onLine', { value: true, configurable: true });
|
||||
browser.dispatchEvent(new browser.Event('online'));
|
||||
await tick();
|
||||
expect(source.reads).toHaveLength(2);
|
||||
expect(store.getActiveTab('/repo')?.lifecycle).toBe('running');
|
||||
source.fail(false);
|
||||
source.setRecords([]);
|
||||
browser.dispatchEvent(new browser.Event('focus'));
|
||||
await tick();
|
||||
expect(store.getActiveTab('/repo')?.lifecycle).toBe('exited');
|
||||
for (const close of cleanups.splice(0)) close();
|
||||
browser.dispatchEvent(new browser.Event('focus'));
|
||||
await tick();
|
||||
expect(source.reads).toHaveLength(3);
|
||||
});
|
||||
@@ -0,0 +1,80 @@
|
||||
import type { TerminalAPI } from './api/types';
|
||||
import { reconcileTerminalSessionAuthority } from './projectActionTerminal';
|
||||
import { getRuntimeKey, subscribeRuntimeEndpointChanged } from './runtime-switch';
|
||||
|
||||
const REFRESH_INTERVAL_MS = 5_000;
|
||||
type AuthorityResult = NonNullable<Awaited<ReturnType<typeof reconcileTerminalSessionAuthority>>>;
|
||||
type RevisionCapture = (directory: string) => ReadonlyMap<string, number>;
|
||||
type Listener = (result: AuthorityResult) => void;
|
||||
type Observation = { listeners: Set<Listener>; refresh: () => void; close: () => void };
|
||||
const observations = new WeakMap<TerminalAPI, Map<string, Observation>>();
|
||||
|
||||
/** One visible-demand loop per adapter/directory, shared by the header and panel. */
|
||||
export const observeTerminalSessions = (
|
||||
terminal: TerminalAPI,
|
||||
directory: string,
|
||||
captureStartedActionMutationRevisions: RevisionCapture,
|
||||
listener: Listener,
|
||||
): (() => void) => {
|
||||
if (!terminal.listSessions) return () => {};
|
||||
let directories = observations.get(terminal);
|
||||
if (!directories) {
|
||||
directories = new Map();
|
||||
observations.set(terminal, directories);
|
||||
}
|
||||
let observation = directories.get(directory);
|
||||
if (!observation) {
|
||||
const listeners = new Set<Listener>();
|
||||
let closed = false;
|
||||
let inFlight = false;
|
||||
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||
let generation = 0;
|
||||
const active = () => document.visibilityState !== 'hidden' && navigator.onLine !== false;
|
||||
const clearTimer = () => { if (timer !== null) clearTimeout(timer); timer = null; };
|
||||
const refresh = () => {
|
||||
clearTimer();
|
||||
if (closed || inFlight || !active()) return;
|
||||
inFlight = true;
|
||||
const startedGeneration = generation;
|
||||
const runtimeKey = getRuntimeKey();
|
||||
void reconcileTerminalSessionAuthority(terminal, directory, { captureStartedActionMutationRevisions })
|
||||
.then(result => {
|
||||
if (closed || generation !== startedGeneration || runtimeKey !== getRuntimeKey() || !result) return;
|
||||
for (const notify of listeners) notify(result);
|
||||
})
|
||||
.finally(() => {
|
||||
inFlight = false;
|
||||
if (!closed && active()) timer = setTimeout(refresh, REFRESH_INTERVAL_MS);
|
||||
});
|
||||
};
|
||||
const runtimeChanged = () => { generation += 1; refresh(); };
|
||||
window.addEventListener('focus', refresh);
|
||||
window.addEventListener('online', refresh);
|
||||
window.addEventListener('offline', clearTimer);
|
||||
document.addEventListener('visibilitychange', refresh);
|
||||
const stopRuntimeListener = subscribeRuntimeEndpointChanged(runtimeChanged);
|
||||
observation = {
|
||||
listeners,
|
||||
refresh,
|
||||
close: () => {
|
||||
closed = true;
|
||||
clearTimer();
|
||||
window.removeEventListener('focus', refresh);
|
||||
window.removeEventListener('online', refresh);
|
||||
window.removeEventListener('offline', clearTimer);
|
||||
document.removeEventListener('visibilitychange', refresh);
|
||||
stopRuntimeListener();
|
||||
},
|
||||
};
|
||||
directories.set(directory, observation);
|
||||
}
|
||||
observation.listeners.add(listener);
|
||||
observation.refresh();
|
||||
return () => {
|
||||
observation.listeners.delete(listener);
|
||||
if (observation.listeners.size > 0) return;
|
||||
observation.close();
|
||||
directories.delete(directory);
|
||||
if (directories.size === 0) observations.delete(terminal);
|
||||
};
|
||||
};
|
||||
Reference in New Issue
Block a user