feat(coding): add preview data runtime session

This commit is contained in:
2026-08-26 21:22:38 +08:00
parent bec67082b3
commit e842dd42eb
11 changed files with 1404 additions and 3 deletions

View File

@@ -159,8 +159,21 @@ export interface AgentBrowserReadPayloadInput {
export interface AgentBrowserModuleOptions {
payloadStore?: AgentBrowserPayloadStore;
cdpGuard?: AgentBrowserCdpGuard;
onLifecycle?(event: AgentBrowserLifecycleEvent): void;
}
export type AgentBrowserLifecycleEvent = Readonly<{
type: 'closed' | 'detached' | 'crashed' | 'cross-origin-navigation' | 'generation-replaced';
projectId: string;
projectPath: string;
generation: number;
url: string;
previousUrl?: string;
nextUrl?: string;
}>;
export type AgentBrowserLifecycleListener = (event: AgentBrowserLifecycleEvent) => void;
export function agentBrowserPartition(projectPath: string): string {
const normalized = normalizePath(projectPath);
const key = createHash('sha256').update(normalized).digest('hex').slice(0, 32);
@@ -172,6 +185,7 @@ export class AgentBrowserModule {
private readonly cdpGuard: AgentBrowserCdpGuard;
private readonly eventWaiters = new Set<() => void>();
private readonly commandCancellers = new Set<(fault: AgentBrowserFault) => void>();
private readonly lifecycleListeners = new Set<AgentBrowserLifecycleListener>();
private record: BrowserRecord | null = null;
private commandTail: Promise<void> = Promise.resolve();
private lifecycleBarrier: Promise<void> = Promise.resolve();
@@ -185,6 +199,15 @@ export class AgentBrowserModule {
) {
this.payloadStore = options.payloadStore ?? new AgentBrowserPayloadStore();
this.cdpGuard = options.cdpGuard ?? new AgentBrowserCdpGuard();
if (options.onLifecycle) this.lifecycleListeners.add(options.onLifecycle);
}
subscribeLifecycle(listener: AgentBrowserLifecycleListener): () => void {
if (this.disposed) return () => undefined;
this.lifecycleListeners.add(listener);
return () => {
this.lifecycleListeners.delete(listener);
};
}
async preflightCurrentProject(projectPath: string): Promise<{ ok: true }> {
@@ -671,6 +694,13 @@ export class AgentBrowserModule {
record.generation = ++this.generation;
record.childSessions.clear();
record.ioHandles.clear();
this.notifyLifecycle({
type: 'generation-replaced',
projectId: record.projectId,
projectPath: record.projectPath,
generation: record.generation,
url: record.url,
});
}
record.state = 'attaching';
record.error = undefined;
@@ -728,17 +758,52 @@ export class AgentBrowserModule {
? '原生 DevTools 正在使用当前页面调试器。'
: `开发浏览器调试器已断开:${String(reasonValue ?? 'unknown')}`,
};
this.notifyLifecycle({
type: 'detached',
projectId: record.projectId,
projectPath: record.projectPath,
generation: record.generation,
url: record.url,
});
this.notifyEventWaiters();
};
this.addDebuggerListener(record, 'message', onDebuggerMessage);
this.addDebuggerListener(record, 'detach', onDebuggerDetach);
this.addWebContentsListener(record, 'did-navigate', (_event, urlValue) => {
if (typeof urlValue === 'string') record.url = urlValue;
const previousUrl = record.url;
if (typeof urlValue === 'string') {
record.url = urlValue;
if (crossOriginNavigation(previousUrl, urlValue)) {
this.notifyLifecycle({
type: 'cross-origin-navigation',
projectId: record.projectId,
projectPath: record.projectPath,
generation: record.generation,
url: urlValue,
previousUrl,
nextUrl: urlValue,
});
}
}
this.refreshMetadata(record);
});
this.addWebContentsListener(record, 'did-navigate-in-page', (_event, urlValue) => {
if (typeof urlValue === 'string') record.url = urlValue;
this.addWebContentsListener(record, 'did-navigate-in-page', (_event, urlValue, isMainFrameValue) => {
const previousUrl = record.url;
if (typeof urlValue === 'string' && isMainFrameValue !== false) {
record.url = urlValue;
if (crossOriginNavigation(previousUrl, urlValue)) {
this.notifyLifecycle({
type: 'cross-origin-navigation',
projectId: record.projectId,
projectPath: record.projectPath,
generation: record.generation,
url: urlValue,
previousUrl,
nextUrl: urlValue,
});
}
}
this.refreshMetadata(record);
});
this.addWebContentsListener(record, 'page-title-updated', (_event, titleValue) => {
@@ -785,6 +850,13 @@ export class AgentBrowserModule {
code: 'RENDERER_CRASHED',
message: '开发浏览器页面进程已退出。',
};
this.notifyLifecycle({
type: 'crashed',
projectId: record.projectId,
projectPath: record.projectPath,
generation: record.generation,
url: record.url,
});
record.eventBuffer.markGap('view-recreated');
record.childSessions.clear();
record.ioHandles.clear();
@@ -797,6 +869,13 @@ export class AgentBrowserModule {
code: 'TARGET_GONE',
message: '开发浏览器页面已关闭。',
};
this.notifyLifecycle({
type: 'crashed',
projectId: record.projectId,
projectPath: record.projectPath,
generation: record.generation,
url: record.url,
});
record.eventBuffer.markGap('view-recreated');
this.notifyEventWaiters();
});
@@ -1137,6 +1216,13 @@ export class AgentBrowserModule {
}
if (expected && this.record !== expected) return;
record.state = 'closing';
this.notifyLifecycle({
type: 'closed',
projectId: record.projectId,
projectPath: record.projectPath,
generation: record.generation,
url: record.url,
});
this.removeListeners(record);
this.record = null;
const interrupted = new AgentBrowserFault(
@@ -1435,6 +1521,16 @@ export class AgentBrowserModule {
this.eventWaiters.clear();
for (const wake of waiters) wake();
}
private notifyLifecycle(event: AgentBrowserLifecycleEvent): void {
for (const listener of this.lifecycleListeners) {
try {
listener(event);
} catch {
// Lifecycle observers must not interrupt browser teardown or navigation.
}
}
}
}
function normalizeRequiredPath(projectPath: string): string {
@@ -1470,6 +1566,15 @@ function normalizeUrl(value: string): string {
return url.toString();
}
function crossOriginNavigation(previousUrl: string, nextUrl: string): boolean {
if (previousUrl === 'about:blank' || nextUrl === 'about:blank') return false;
try {
return new URL(previousUrl).origin !== new URL(nextUrl).origin;
} catch {
return false;
}
}
async function beforePublishPreflightDeadline<T>(
operation: Promise<T>,
deadline: number,