import type { AgentBrowserService } from '../api/context'; import type { CodingConversationService } from './conversation-service'; import type { CodingConversationRuntime } from './contracts'; import { OPEN_WORK_PROMPT, type WorkPreviewResult } from '../../shared/coding-work-preview'; import { codingRecovery } from '../../shared/coding-recovery'; import type { AgentBrowserSnapshot } from '../../shared/agent-browser'; import { inspectWorkPresence, type WorkPresence } from './work-presence'; import { loadWorkEntry, saveWorkEntry } from './work-entry-store'; import { discoverWorkLaunch, startWorkLaunch, stopWorkLaunch, workLaunchMatches } from './work-launcher'; import type { WorkEntry, WorkLaunchSpec } from './work-entry-contracts'; import { createHash } from 'node:crypto'; type Project = { id: string; path: string }; type Attempt = { requestId: string; conversationId?: string; submitted?: boolean; runId?: string; submittedAt?: number; result?: WorkPreviewResult; }; function sameWorkServer(left: string, right: string): boolean { try { const a = new URL(left); const b = new URL(right); const local = (url: URL) => ['localhost', '127.0.0.1', '[::1]'].includes(url.hostname); return local(a) && local(b) && a.protocol === b.protocol && a.port === b.port; } catch { return false; } } /** Only probe a known loopback work address; never scan ports or follow redirects. */ export async function workPageResponds(address: string): Promise { try { const url = new URL(address); if (!['http:', 'https:'].includes(url.protocol) || !['localhost', '127.0.0.1', '[::1]'].includes(url.hostname) || url.username || url.password) return false; const response = await fetch(url, { redirect: 'manual', signal: AbortSignal.timeout(2_000) }); await response.body?.cancel(); return response.ok; } catch { return false; } } /** Identity for pages whose launch method is unknown; only bounded local HTML. */ export async function workPageSignature(address: string): Promise { try { const url = new URL(address); if (!['http:', 'https:'].includes(url.protocol) || !['localhost', '127.0.0.1', '[::1]'].includes(url.hostname) || url.username || url.password) return null; const response = await fetch(url, { redirect: 'manual', signal: AbortSignal.timeout(2_000) }); if (!response.ok || !response.headers.get('content-type')?.includes('text/html') || !response.body) { await response.body?.cancel(); return null; } const reader = response.body.getReader(); const hash = createHash('sha256'); let bytes = 0; try { while (true) { const { done, value } = await reader.read(); if (done) return hash.digest('hex'); bytes += value.byteLength; if (bytes > 256 * 1024) return null; hash.update(value); } } finally { await reader.cancel().catch(() => undefined); } } catch { return null; } } export class CodingWorkPreviewService { private readonly attempts = new Map(); private readonly flights = new Map>(); private readonly flightHelp = new Map(); // Native views are disposable during background sleep; the running project // server and its verified address have a separate lifetime. private readonly pages = new Map(); private readonly pageRevisions = new Map(); private readonly rememberedWrites = new Map>(); private readonly loaded = new Map(); constructor(private readonly deps: { browser: Pick; conversations: Pick; runtime: Pick; ensureActive(project: Project): Promise; responds?(url: string): Promise; inspectWork?(projectPath: string): Promise; entries?: { load: typeof loadWorkEntry; save: typeof saveWorkEntry }; launcher?: { discover: typeof discoverWorkLaunch; start: typeof startWorkLaunch; matches?: typeof workLaunchMatches; stop?: typeof stopWorkLaunch }; storedSnapshot?: CodingConversationService['getStoredSnapshot']; signature?(url: string): Promise; }) {} remember(project: Project, snapshot: AgentBrowserSnapshot, launch?: WorkLaunchSpec): Promise { if (snapshot.projectId !== project.id || !snapshot.url || !snapshot.browserId || snapshot.state !== 'attached' || snapshot.error) return Promise.resolve(); this.pages.delete(project.id); if (this.pages.size >= 100) this.pages.delete(this.pages.keys().next().value!); this.pages.set(project.id, { path: project.path, url: snapshot.url }); this.pageRevisions.set(project.id, (this.pageRevisions.get(project.id) ?? 0) + 1); this.attempts.delete(project.id); // A successful manual/Agent open supersedes a slow older startup check. this.flights.delete(project.id); const previous = this.rememberedWrites.get(project.id); const write = (async () => { await previous?.catch(() => undefined); const recipe = launch ?? await (this.deps.launcher?.discover ?? discoverWorkLaunch)(project.path).catch(() => null); const documentHash = await (this.deps.signature ?? workPageSignature)(snapshot.url); const entry: WorkEntry = { version: 1, projectId: project.id, projectPath: project.path, url: snapshot.url, ...(documentHash ? { documentHash } : {}), ...(recipe ? { launch: recipe } : {}) }; this.loaded.set(project.id, entry); await (this.deps.entries?.save ?? saveWorkEntry)(project, entry); })(); this.rememberedWrites.set(project.id, write); // Manual opens remain usable if project metadata temporarily cannot be saved. void write.catch(() => undefined); return write; } async ensure(project: Project, requestId: string, conversationId?: string, allowAssistant = false): Promise { await this.deps.ensureActive(project); // Serialize browser checks as well as prompt submission for each project. const existing = this.flights.get(project.id); if (existing) { if (allowAssistant && !this.flightHelp.get(project.id)) { await existing; return this.ensure(project, requestId, conversationId, true); } return existing; } const flight = this.check(project, requestId, conversationId, allowAssistant).catch((error: unknown) => { const code = error && typeof error === 'object' && 'code' in error && typeof error.code === 'string' ? error.code : 'WORK_PREVIEW_START_FAILED'; if (['PROJECT_NOT_ACTIVE', 'PROJECT_MISMATCH'].includes(code)) throw error; const result = { status: 'failed' as const, errorCode: code, message: codingRecovery(code, true).message }; const attempt = this.attempts.get(project.id); if (attempt?.requestId === requestId) attempt.result = result; return result; }); this.flights.set(project.id, flight); this.flightHelp.set(project.id, allowAssistant); try { return await flight; } finally { if (this.flights.get(project.id) === flight) { this.flights.delete(project.id); this.flightHelp.delete(project.id); } } } private async check(project: Project, requestId: string, conversationId?: string, allowAssistant = false): Promise { const { browser, conversations } = this.deps; const pageRevision = this.pageRevisions.get(project.id) ?? 0; const superseded = () => (this.pageRevisions.get(project.id) ?? 0) !== pageRevision; await this.rememberedWrites.get(project.id)?.catch(() => undefined); if (!this.loaded.has(project.id) || this.loaded.get(project.id)?.projectPath !== project.path) { const loaded = await (this.deps.entries?.load ?? loadWorkEntry)(project); if (superseded()) return this.check(project, requestId, conversationId, allowAssistant); this.loaded.set(project.id, loaded); } const saved = this.loaded.get(project.id); const snapshot = await browser.getSnapshot(project.path); if (superseded()) return this.check(project, requestId, conversationId, allowAssistant); const responds = this.deps.responds ?? workPageResponds; const remembered = this.pages.get(project.id); const address = snapshot.projectId === project.id && snapshot.url ? snapshot.url : remembered?.path === project.path ? remembered.url : saved?.url; const matches = this.deps.launcher?.matches ?? workLaunchMatches; const addressMatches = async () => saved?.launch ? await matches(project, saved.launch, address!) : saved?.documentHash ? await (this.deps.signature ?? workPageSignature)(address!) === saved.documentHash : Boolean(remembered?.path === project.path || (snapshot.projectId === project.id && snapshot.url)); if (address && await responds(address) && await addressMatches()) { await this.deps.ensureActive(project); if (superseded()) return this.check(project, requestId, conversationId, allowAssistant); const ready = snapshot.projectId === project.id && snapshot.url === address && snapshot.browserId && snapshot.state === 'attached' && !snapshot.error ? snapshot : await browser.open({ projectId: project.id, projectPath: project.path, url: address, visible: false }); await this.deps.ensureActive(project); if (superseded()) return this.check(project, requestId, conversationId, allowAssistant); await this.remember(project, ready, saved?.launch).catch(() => undefined); return { status: 'ready', browser: ready }; } if (this.pages.has(project.id) && this.pages.get(project.id) !== remembered) return this.check(project, requestId, conversationId, allowAssistant); this.pages.delete(project.id); await this.deps.ensureActive(project); let attempt = this.attempts.get(project.id); if (attempt?.submitted && attempt.conversationId) { const current = await conversations.getSnapshot(attempt.conversationId); if (this.pages.has(project.id)) return this.check(project, requestId, conversationId, allowAssistant); if (!['idle', 'error'].includes(current.run.status)) { return { status: 'starting', conversation: await conversations.getConversation(attempt.conversationId) }; } if (attempt.runId && current.run.runId !== attempt.runId && Date.now() - attempt.submittedAt! < 15_000) { return { status: 'starting', conversation: await conversations.getConversation(attempt.conversationId) }; } // The accepted action settled without opening a reachable work page. const errorCode = current.run.error?.code; attempt.result ??= { status: 'failed', errorCode, message: codingRecovery(errorCode, true).message }; } if (attempt?.result && attempt.requestId === requestId) return attempt.result; if (!attempt || attempt.result || attempt.requestId !== requestId) { attempt = { requestId, conversationId }; // Keep only a bounded number of inactive project attempts. if (this.attempts.size >= 100) this.attempts.delete(this.attempts.keys().next().value!); this.attempts.set(project.id, attempt); } const list = await conversations.listConversations(project.id); const projectIds = new Set(list.map(({ id }) => id)); const busy = () => this.deps.runtime.getDiagnostics().workers.some((worker) => ( projectIds.has(worker.conversationId) && ['starting', 'queued', 'running'].includes(worker.stage) )); if (busy()) return { status: 'waiting' }; const presence = await (this.deps.inspectWork ?? inspectWorkPresence)(project.path); await this.deps.ensureActive(project); if (this.pages.has(project.id)) return this.check(project, requestId, conversationId, allowAssistant); if (busy()) return { status: 'waiting' }; if (presence === 'empty') { // No cached terminal attempt: the same tab/request can discover newly made work. this.attempts.delete(project.id); return { status: 'empty' }; } // Upgrade existing projects from successful browser tool receipts, never // assistant prose or an arbitrary link, and never start Pi to read them. let discoveryError: unknown; const launch = saved?.launch ?? await (this.deps.launcher?.discover ?? discoverWorkLaunch)(project.path).catch((error: unknown) => { discoveryError = error; return null; }); if (!saved && this.deps.storedSnapshot) { for (const item of list.slice(0, 20)) { const history = await this.deps.storedSnapshot(item.id).catch(() => null); await this.deps.ensureActive(project); if (!history || history.conversation.projectId !== project.id) continue; for (const node of [...history.nodes].reverse()) { if (node.kind !== 'tool' || node.status !== 'complete' || node.toolName !== 'agent_browser' || node.details?.schema !== 'agent-browser.v1' || node.details.action !== 'open') continue; const text = node.output.filter((block) => block.kind === 'text').map((block) => block.text).join(''); if (text.length > 16_384) continue; let known: AgentBrowserSnapshot; try { known = JSON.parse(text); } catch { continue; } if (!known || typeof known !== 'object') continue; if (known.projectId !== project.id || known.state !== 'attached' || !known.browserId || !known.url || !launch || !(await responds(known.url)) || !(await matches(project, launch, known.url))) continue; await this.deps.ensureActive(project); if (superseded()) return this.check(project, requestId, conversationId, allowAssistant); const restored = await browser.open({ projectId: project.id, projectPath: project.path, url: known.url, visible: false }); await this.deps.ensureActive(project); if (superseded()) return this.check(project, requestId, conversationId, allowAssistant); await this.remember(project, restored, launch ?? undefined).catch(() => undefined); return { status: 'ready', browser: restored }; } } } if (discoveryError && !allowAssistant) throw discoveryError; await this.deps.ensureActive(project); if (this.pages.has(project.id)) return this.check(project, requestId, conversationId, allowAssistant); if (busy()) return { status: 'waiting' }; if (launch) { try { const url = await (this.deps.launcher?.start ?? startWorkLaunch)(project, launch, address); const stopUnused = async () => { const newer = this.pages.get(project.id); if (!newer || !sameWorkServer(newer.url, url)) await (this.deps.launcher?.stop ?? stopWorkLaunch)(project, url); }; if (superseded()) { await stopUnused(); return this.check(project, requestId, conversationId, allowAssistant); } // Keep the verified server associated with its own project even if the // student switches projects while it starts. Only UI attachment is gated. const startedEntry: WorkEntry = { version: 1, projectId: project.id, projectPath: project.path, url, launch }; this.loaded.set(project.id, startedEntry); const previousWrite = this.rememberedWrites.get(project.id); const startedWrite = (async () => { await previousWrite?.catch(() => undefined); await (this.deps.entries?.save ?? saveWorkEntry)(project, startedEntry); })(); this.rememberedWrites.set(project.id, startedWrite); await startedWrite.catch(() => undefined); if (superseded()) { await stopUnused(); return this.check(project, requestId, conversationId, allowAssistant); } await this.deps.ensureActive(project); if (superseded()) { await stopUnused(); return this.check(project, requestId, conversationId, allowAssistant); } const opened = await browser.open({ projectId: project.id, projectPath: project.path, url, visible: false }); await this.deps.ensureActive(project); if (superseded()) return this.check(project, requestId, conversationId, allowAssistant); await this.remember(project, opened, launch).catch(() => undefined); return { status: 'ready', browser: opened }; } catch (error) { if (!allowAssistant || (error && typeof error === 'object' && 'code' in error && ['PROJECT_NOT_ACTIVE', 'PROJECT_MISMATCH'].includes(String(error.code)))) throw error; // The user chose assistance for a known launch failure. The assistant // may inspect it; merely reopening the tab never takes this path. } } if (!allowAssistant) return { status: 'failed', errorCode: 'WORK_PREVIEW_NEEDS_HELP', message: codingRecovery('WORK_PREVIEW_NEEDS_HELP', true).message }; let conversation = attempt.conversationId ? await conversations.getConversation(attempt.conversationId) : list.find((item) => !item.archivedAt); await this.deps.ensureActive(project); conversation ??= await conversations.createConversation({ projectId: project.id, title: '新对话' }); attempt.conversationId = conversation.id; const current = await conversations.getSnapshot(conversation.id); if (this.pages.has(project.id)) return this.check(project, requestId, conversationId, allowAssistant); if (!['idle', 'error'].includes(current.run.status)) return { status: 'waiting', conversation }; await this.deps.ensureActive(project); // Mark before dispatch: uncertain acceptance must never cause automatic resubmission. attempt.submitted = true; attempt.submittedAt = Date.now(); try { const acceptance = await conversations.acceptPrompt({ conversationId: conversation.id, clientRequestId: `work-preview-${attempt.requestId}`, mode: 'prompt', text: OPEN_WORK_PROMPT, }); attempt.runId = acceptance.runId; } catch (error) { const errorCode = error && typeof error === 'object' && 'code' in error && typeof error.code === 'string' ? error.code : 'CODING_REQUEST_UNCERTAIN'; attempt.result = { status: 'failed', conversation, errorCode, message: codingRecovery(errorCode, true).message }; return attempt.result; } return { status: 'starting', conversation }; } }