fix(agent-browser): restore bounded presentation lifecycle
This commit is contained in:
@@ -106,6 +106,7 @@ interface BrowserRecord {
|
||||
title: string;
|
||||
visible: boolean;
|
||||
diagnosticsEnabled: boolean;
|
||||
diagnosticOwners: Set<string>;
|
||||
bounds: AgentBrowserBounds | null;
|
||||
error?: {
|
||||
code: AgentBrowserErrorCode;
|
||||
@@ -148,6 +149,7 @@ export interface AgentBrowserOpenInput {
|
||||
bounds?: AgentBrowserBounds;
|
||||
visible?: boolean;
|
||||
injectProjectData?: boolean;
|
||||
diagnosticsOwner?: string;
|
||||
}
|
||||
|
||||
export interface AgentBrowserPresentInput {
|
||||
@@ -156,6 +158,12 @@ export interface AgentBrowserPresentInput {
|
||||
bounds?: AgentBrowserBounds;
|
||||
}
|
||||
|
||||
export interface AgentBrowserWaitForPresentationInput {
|
||||
projectPath: string;
|
||||
generation: number;
|
||||
timeoutMs?: number;
|
||||
}
|
||||
|
||||
export interface AgentBrowserNavigateInput {
|
||||
projectPath: string;
|
||||
action: 'url' | 'back' | 'forward' | 'reload';
|
||||
@@ -213,6 +221,7 @@ export class AgentBrowserModule {
|
||||
private readonly payloadStore: AgentBrowserPayloadStore;
|
||||
private readonly cdpGuard: AgentBrowserCdpGuard;
|
||||
private readonly eventWaiters = new Set<() => void>();
|
||||
private readonly presentationWaiters = new Set<() => void>();
|
||||
private readonly commandCancellers = new Set<(fault: AgentBrowserFault) => void>();
|
||||
private readonly lifecycleListeners = new Set<AgentBrowserLifecycleListener>();
|
||||
private record: BrowserRecord | null = null;
|
||||
@@ -341,16 +350,32 @@ export class AgentBrowserModule {
|
||||
}
|
||||
if (this.record) {
|
||||
const record = this.record;
|
||||
if (bounds) {
|
||||
this.applyPresentation(record, input.visible ?? true, bounds);
|
||||
const diagnosticOwner = input.diagnosticsOwner?.trim();
|
||||
const addedDiagnosticOwner = Boolean(
|
||||
diagnosticOwner && !record.diagnosticOwners.has(diagnosticOwner),
|
||||
);
|
||||
try {
|
||||
if (diagnosticOwner) {
|
||||
await this.updateDiagnosticOwner(record, diagnosticOwner, true);
|
||||
}
|
||||
if (bounds) {
|
||||
this.applyPresentation(record, input.visible ?? true, bounds);
|
||||
}
|
||||
if (record.url !== targetUrl || record.error) {
|
||||
await this.navigateTo(record, targetUrl);
|
||||
}
|
||||
return this.snapshot(record);
|
||||
} catch (error) {
|
||||
if (addedDiagnosticOwner && diagnosticOwner) {
|
||||
await this.updateDiagnosticOwner(record, diagnosticOwner, false).catch(() => undefined);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
if (record.url !== targetUrl || record.error) {
|
||||
await this.navigateTo(record, targetUrl);
|
||||
}
|
||||
return this.snapshot(record);
|
||||
}
|
||||
|
||||
const view = this.adapter.createView(agentBrowserPartition(projectPath));
|
||||
const diagnosticOwners = new Set<string>();
|
||||
if (input.diagnosticsOwner?.trim()) diagnosticOwners.add(input.diagnosticsOwner.trim());
|
||||
const record: BrowserRecord = {
|
||||
browserId: randomUUID(),
|
||||
projectId: input.projectId,
|
||||
@@ -362,7 +387,8 @@ export class AgentBrowserModule {
|
||||
url: targetUrl,
|
||||
title: '',
|
||||
visible: Boolean(bounds) && (input.visible ?? true),
|
||||
diagnosticsEnabled: false,
|
||||
diagnosticsEnabled: diagnosticOwners.size > 0,
|
||||
diagnosticOwners,
|
||||
bounds,
|
||||
eventBuffer: new AgentBrowserEventBuffer(),
|
||||
childSessions: new Set(),
|
||||
@@ -439,14 +465,73 @@ export class AgentBrowserModule {
|
||||
}
|
||||
const bounds = input.bounds ? normalizeBounds(input.bounds) : record.bounds;
|
||||
this.applyPresentation(record, input.visible, bounds);
|
||||
this.notifyPresentationWaiters();
|
||||
return this.snapshot(record);
|
||||
});
|
||||
}
|
||||
|
||||
setDiagnostics(input: { projectPath: string; enabled: boolean }): Promise<AgentBrowserSnapshot> {
|
||||
waitForPresentation(
|
||||
input: AgentBrowserWaitForPresentationInput,
|
||||
): Promise<AgentBrowserSnapshot> {
|
||||
this.assertAvailable();
|
||||
const timeoutMs = normalizeIntegerRange(
|
||||
input.timeoutMs,
|
||||
MAX_WAIT_MS,
|
||||
1,
|
||||
MAX_CDP_TIMEOUT_MS,
|
||||
'timeoutMs',
|
||||
);
|
||||
const immediate = this.presentationSnapshot(input.projectPath, input.generation);
|
||||
if (immediate) return Promise.resolve(immediate);
|
||||
|
||||
return new Promise<AgentBrowserSnapshot>((resolvePromise, rejectPromise) => {
|
||||
let settled = false;
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
const cleanup = () => {
|
||||
this.presentationWaiters.delete(check);
|
||||
if (timer) clearTimeout(timer);
|
||||
};
|
||||
const resolve = (snapshot: AgentBrowserSnapshot) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
cleanup();
|
||||
resolvePromise(snapshot);
|
||||
};
|
||||
const reject = (error: unknown) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
cleanup();
|
||||
rejectPromise(error);
|
||||
};
|
||||
const check = () => {
|
||||
try {
|
||||
const snapshot = this.presentationSnapshot(input.projectPath, input.generation);
|
||||
if (snapshot) resolve(snapshot);
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
};
|
||||
this.presentationWaiters.add(check);
|
||||
timer = setTimeout(() => {
|
||||
reject(new AgentBrowserFault(
|
||||
'VIEWPORT_NOT_READY',
|
||||
'开发浏览器显示区域没有及时准备好。',
|
||||
true,
|
||||
input.generation,
|
||||
));
|
||||
}, timeoutMs);
|
||||
check();
|
||||
});
|
||||
}
|
||||
|
||||
setDiagnostics(input: {
|
||||
projectPath: string;
|
||||
enabled: boolean;
|
||||
owner?: string;
|
||||
}): Promise<AgentBrowserSnapshot> {
|
||||
return this.serialize(async () => {
|
||||
const record = this.requireRecord(input.projectPath);
|
||||
await this.configureDiagnostics(record, input.enabled);
|
||||
await this.updateDiagnosticOwner(record, input.owner ?? 'renderer', input.enabled);
|
||||
return this.snapshot(record);
|
||||
});
|
||||
}
|
||||
@@ -766,6 +851,7 @@ export class AgentBrowserModule {
|
||||
generation: record.generation,
|
||||
url: record.url,
|
||||
});
|
||||
this.notifyPresentationWaiters();
|
||||
}
|
||||
record.state = 'attaching';
|
||||
record.error = undefined;
|
||||
@@ -1023,6 +1109,7 @@ export class AgentBrowserModule {
|
||||
url: record.url,
|
||||
});
|
||||
this.notifyEventWaiters();
|
||||
this.notifyPresentationWaiters();
|
||||
};
|
||||
this.addDebuggerListener(record, 'message', onDebuggerMessage);
|
||||
this.addDebuggerListener(record, 'detach', onDebuggerDetach);
|
||||
@@ -1088,6 +1175,7 @@ export class AgentBrowserModule {
|
||||
code: 'DEVTOOLS_CONFLICT',
|
||||
message: '原生 DevTools 已打开,智能体调试暂时暂停。',
|
||||
};
|
||||
this.notifyPresentationWaiters();
|
||||
});
|
||||
this.addWebContentsListener(record, 'devtools-closed', () => {
|
||||
if (record !== this.record || record.state === 'closing' || record.state === 'crashed') {
|
||||
@@ -1112,6 +1200,7 @@ export class AgentBrowserModule {
|
||||
record.generation,
|
||||
);
|
||||
record.error = { code: fault.code, message: fault.message };
|
||||
this.notifyPresentationWaiters();
|
||||
}
|
||||
}).catch(() => undefined);
|
||||
});
|
||||
@@ -1134,6 +1223,7 @@ export class AgentBrowserModule {
|
||||
record.childSessions.clear();
|
||||
record.ioHandles.clear();
|
||||
this.notifyEventWaiters();
|
||||
this.notifyPresentationWaiters();
|
||||
});
|
||||
this.addWebContentsListener(record, 'destroyed', () => {
|
||||
if (record !== this.record || record.state === 'closing') return;
|
||||
@@ -1152,9 +1242,29 @@ export class AgentBrowserModule {
|
||||
});
|
||||
record.eventBuffer.markGap('view-recreated');
|
||||
this.notifyEventWaiters();
|
||||
this.notifyPresentationWaiters();
|
||||
});
|
||||
}
|
||||
|
||||
private async updateDiagnosticOwner(
|
||||
record: BrowserRecord,
|
||||
ownerValue: string,
|
||||
enabled: boolean,
|
||||
): Promise<void> {
|
||||
const owner = ownerValue.trim() || 'renderer';
|
||||
const hadOwner = record.diagnosticOwners.has(owner);
|
||||
if (hadOwner === enabled) return;
|
||||
if (enabled) record.diagnosticOwners.add(owner);
|
||||
else record.diagnosticOwners.delete(owner);
|
||||
try {
|
||||
await this.configureDiagnostics(record, record.diagnosticOwners.size > 0);
|
||||
} catch (error) {
|
||||
if (hadOwner) record.diagnosticOwners.add(owner);
|
||||
else record.diagnosticOwners.delete(owner);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private async configureDiagnostics(record: BrowserRecord, enabled: boolean): Promise<void> {
|
||||
if (record !== this.record || record.view.webContents.isDestroyed()) {
|
||||
throw new AgentBrowserFault(
|
||||
@@ -1520,6 +1630,56 @@ export class AgentBrowserModule {
|
||||
return record;
|
||||
}
|
||||
|
||||
private presentationSnapshot(
|
||||
projectPath: string,
|
||||
generation: number,
|
||||
): AgentBrowserSnapshot | null {
|
||||
const record = this.requireRecord(projectPath);
|
||||
if (record.generation !== generation) {
|
||||
throw new AgentBrowserFault(
|
||||
'CLOSED',
|
||||
'开发浏览器已切换到新的页面实例。',
|
||||
true,
|
||||
generation,
|
||||
);
|
||||
}
|
||||
if (record.state === 'crashed') {
|
||||
throw new AgentBrowserFault(
|
||||
'RENDERER_CRASHED',
|
||||
'开发浏览器页面进程已退出。',
|
||||
true,
|
||||
generation,
|
||||
);
|
||||
}
|
||||
if (record.state === 'suspended_devtools') {
|
||||
throw new AgentBrowserFault(
|
||||
'DEVTOOLS_CONFLICT',
|
||||
'请先关闭当前页面的原生 DevTools。',
|
||||
true,
|
||||
generation,
|
||||
);
|
||||
}
|
||||
if (record.state === 'detached_fault') {
|
||||
throw new AgentBrowserFault(
|
||||
'DEBUGGER_BUSY',
|
||||
'开发浏览器调试器尚未就绪。',
|
||||
true,
|
||||
generation,
|
||||
);
|
||||
}
|
||||
if (record.state === 'closing') {
|
||||
throw new AgentBrowserFault(
|
||||
'CLOSED',
|
||||
'开发浏览器正在关闭。',
|
||||
true,
|
||||
generation,
|
||||
);
|
||||
}
|
||||
return record.state === 'attached' && record.visible && record.bounds
|
||||
? this.snapshot(record)
|
||||
: null;
|
||||
}
|
||||
|
||||
private assertProject(record: BrowserRecord, projectPath: string): void {
|
||||
if (!samePath(record.projectPath, normalizeRequiredPath(projectPath))) {
|
||||
throw new AgentBrowserFault(
|
||||
@@ -1541,6 +1701,7 @@ export class AgentBrowserModule {
|
||||
const record = expected ?? this.record;
|
||||
if (!record) {
|
||||
this.notifyEventWaiters();
|
||||
this.notifyPresentationWaiters();
|
||||
return;
|
||||
}
|
||||
if (expected && this.record !== expected) return;
|
||||
@@ -1555,6 +1716,7 @@ export class AgentBrowserModule {
|
||||
});
|
||||
this.removeListeners(record);
|
||||
this.record = null;
|
||||
this.notifyPresentationWaiters();
|
||||
const interrupted = new AgentBrowserFault(
|
||||
'CLOSED',
|
||||
'开发浏览器已关闭。',
|
||||
@@ -1580,6 +1742,7 @@ export class AgentBrowserModule {
|
||||
this.adapter.destroy(record.view);
|
||||
record.childSessions.clear();
|
||||
record.ioHandles.clear();
|
||||
record.diagnosticOwners.clear();
|
||||
record.eventBuffer.clear();
|
||||
this.payloadStore.clear();
|
||||
this.notifyEventWaiters();
|
||||
@@ -1852,6 +2015,10 @@ export class AgentBrowserModule {
|
||||
for (const wake of waiters) wake();
|
||||
}
|
||||
|
||||
private notifyPresentationWaiters(): void {
|
||||
for (const wake of [...this.presentationWaiters]) wake();
|
||||
}
|
||||
|
||||
private notifyLifecycle(event: AgentBrowserLifecycleEvent): void {
|
||||
for (const listener of this.lifecycleListeners) {
|
||||
try {
|
||||
|
||||
Reference in New Issue
Block a user