Restore existing project works independently of model sessions
This commit is contained in:
1 parent
4eb3efa3d1
commit
0f26b6d072
19 files changed
+1977
-68
No files matched your search
@@ -5,6 +5,10 @@ import { OPEN_WORK_PROMPT, type WorkPreviewResult } from '../../shared/coding-wo
|
||||
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 = {
|
||||
@@ -16,6 +20,15 @@ type Attempt = {
|
||||
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<boolean> {
|
||||
try {
|
||||
@@ -31,12 +44,42 @@ export async function workPageResponds(address: string): Promise<boolean> {
|
||||
}
|
||||
}
|
||||
|
||||
/** Identity for pages whose launch method is unknown; only bounded local HTML. */
|
||||
export async function workPageSignature(address: string): Promise<string | null> {
|
||||
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<string, Attempt>();
|
||||
private readonly flights = new Map<string, Promise<WorkPreviewResult>>();
|
||||
private readonly flightHelp = new Map<string, boolean>();
|
||||
// Native views are disposable during background sleep; the running project
|
||||
// server and its verified address have a separate lifetime.
|
||||
private readonly pages = new Map<string, { path: string; url: string }>();
|
||||
private readonly pageRevisions = new Map<string, number>();
|
||||
private readonly rememberedWrites = new Map<string, Promise<void>>();
|
||||
private readonly loaded = new Map<string, WorkEntry | null>();
|
||||
|
||||
constructor(private readonly deps: {
|
||||
browser: Pick<AgentBrowserService, 'getSnapshot' | 'open'>;
|
||||
@@ -45,55 +88,106 @@ export class CodingWorkPreviewService {
|
||||
ensureActive(project: Project): Promise<void>;
|
||||
responds?(url: string): Promise<boolean>;
|
||||
inspectWork?(projectPath: string): Promise<WorkPresence>;
|
||||
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<string | null>;
|
||||
}) {}
|
||||
|
||||
remember(project: Project, snapshot: AgentBrowserSnapshot): void {
|
||||
remember(project: Project, snapshot: AgentBrowserSnapshot, launch?: WorkLaunchSpec): Promise<void> {
|
||||
if (snapshot.projectId !== project.id || !snapshot.url || !snapshot.browserId
|
||||
|| snapshot.state !== 'attached' || snapshot.error) return;
|
||||
|| 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): Promise<WorkPreviewResult> {
|
||||
async ensure(project: Project, requestId: string, conversationId?: string, allowAssistant = false): Promise<WorkPreviewResult> {
|
||||
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) return existing;
|
||||
const flight = this.check(project, requestId, conversationId);
|
||||
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); }
|
||||
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): Promise<WorkPreviewResult> {
|
||||
private async check(project: Project, requestId: string, conversationId?: string, allowAssistant = false): Promise<WorkPreviewResult> {
|
||||
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 : undefined;
|
||||
if (address && await responds(address)) {
|
||||
: 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);
|
||||
this.remember(project, ready);
|
||||
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);
|
||||
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);
|
||||
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) };
|
||||
}
|
||||
@@ -120,13 +214,94 @@ export class CodingWorkPreviewService {
|
||||
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);
|
||||
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);
|
||||
@@ -134,7 +309,7 @@ export class CodingWorkPreviewService {
|
||||
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);
|
||||
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.
|
||||
|
||||
Reference in new issue
Block a user