Retain active work entry restoration in sidebar preview
This commit is contained in:
1 parent
75067bf027
commit
ec07c67ef4
16 files changed
+1924
-63
No files matched your search
@@ -411,6 +411,13 @@ export class CodingConversationService {
|
||||
}
|
||||
}
|
||||
|
||||
/** Read existing facts for work restoration without opening a model session. */
|
||||
async getStoredSnapshot(conversationId: string): Promise<ConversationSnapshot | null> {
|
||||
const { project, conversation } = await this.projects.findActiveConversation(conversationId);
|
||||
if (!conversation.sessionKey || !this.options.readHistory) return null;
|
||||
return await this.options.readHistory(project.id, conversation);
|
||||
}
|
||||
|
||||
async acceptPrompt(input: {
|
||||
conversationId: string;
|
||||
clientRequestId: unknown;
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
/** Project-relative, app-owned launch recipes. Never persisted shell commands. */
|
||||
export type WorkLaunchSpec =
|
||||
| { kind: 'vite'; directory: string; args: string[] }
|
||||
| { kind: 'static'; directory: string; entry: string };
|
||||
|
||||
export type WorkEntryProject = { id: string; path: string };
|
||||
export type WorkEntry = {
|
||||
version: 1;
|
||||
projectId: string;
|
||||
projectPath: string;
|
||||
url: string;
|
||||
documentHash?: string;
|
||||
launch?: WorkLaunchSpec;
|
||||
};
|
||||
@@ -0,0 +1,233 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { constants, type Stats } from 'node:fs';
|
||||
import { lstat, mkdir, open, realpath, rename, rm } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import type { WorkEntry, WorkEntryProject, WorkLaunchSpec } from './work-entry-contracts';
|
||||
|
||||
const ENTRY_FILENAME = 'work-entry.json';
|
||||
const MAX_ENTRY_BYTES = 16 * 1024;
|
||||
const MAX_URL_LENGTH = 2_048;
|
||||
const MAX_PATH_LENGTH = 1_024;
|
||||
const NO_FOLLOW = constants.O_NOFOLLOW ?? 0;
|
||||
|
||||
type ProjectRoot = { root: string; rootStat: Stats };
|
||||
type Locations = ProjectRoot & { directory: string; file: string; directoryStat: Stats };
|
||||
|
||||
function invalid(): never {
|
||||
throw new Error('Invalid project work entry');
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return Boolean(value && typeof value === 'object' && !Array.isArray(value));
|
||||
}
|
||||
|
||||
function hasOnlyKeys(value: Record<string, unknown>, keys: string[]): boolean {
|
||||
return Object.keys(value).every((key) => keys.includes(key));
|
||||
}
|
||||
|
||||
function samePath(left: string, right: string): boolean {
|
||||
return process.platform === 'win32' ? left.toLowerCase() === right.toLowerCase() : left === right;
|
||||
}
|
||||
|
||||
function sameNode(left: Stats, right: Stats): boolean {
|
||||
return left.dev === right.dev && left.ino === right.ino;
|
||||
}
|
||||
|
||||
function containsControl(value: string, includeSpace = false): boolean {
|
||||
for (let index = 0; index < value.length; index += 1) {
|
||||
const code = value.charCodeAt(index);
|
||||
if (code < (includeSpace ? 33 : 32) || code === 127) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
async function projectRoot(project: WorkEntryProject): Promise<ProjectRoot> {
|
||||
if (typeof project.id !== 'string' || !/^[A-Za-z0-9_-]{1,128}$/u.test(project.id)
|
||||
|| typeof project.path !== 'string' || !path.isAbsolute(project.path)) invalid();
|
||||
const root = await realpath(project.path);
|
||||
const rootStat = await lstat(root);
|
||||
if (!rootStat.isDirectory() || rootStat.isSymbolicLink()) invalid();
|
||||
return { root, rootStat };
|
||||
}
|
||||
|
||||
async function assertRoot(target: ProjectRoot): Promise<void> {
|
||||
const stat = await lstat(target.root);
|
||||
if (!stat.isDirectory() || stat.isSymbolicLink() || !sameNode(stat, target.rootStat)
|
||||
|| !samePath(await realpath(target.root), target.root)) invalid();
|
||||
}
|
||||
|
||||
async function locations(project: WorkEntryProject, createMetadata = false, verifiedRoot?: ProjectRoot): Promise<Locations> {
|
||||
const { root, rootStat } = verifiedRoot ?? await projectRoot(project);
|
||||
const directory = path.join(root, '.makelore');
|
||||
const directoryStat = await lstat(directory).catch(async (error: NodeJS.ErrnoException) => {
|
||||
if (error.code !== 'ENOENT' || !createMetadata) throw error;
|
||||
await assertRoot({ root, rootStat });
|
||||
// Imported folders may not have metadata yet. Create only this direct child;
|
||||
// a concurrent creator must still pass the same directory/link checks below.
|
||||
await mkdir(directory, { mode: 0o700 }).catch((mkdirError: NodeJS.ErrnoException) => {
|
||||
if (mkdirError.code !== 'EEXIST') throw mkdirError;
|
||||
});
|
||||
return await lstat(directory);
|
||||
});
|
||||
if (!directoryStat.isDirectory() || directoryStat.isSymbolicLink()
|
||||
|| !samePath(await realpath(directory), directory)) invalid();
|
||||
await assertRoot({ root, rootStat });
|
||||
return { root, directory, file: path.join(directory, ENTRY_FILENAME), rootStat, directoryStat };
|
||||
}
|
||||
|
||||
async function assertLocations(target: Locations): Promise<void> {
|
||||
await assertRoot(target);
|
||||
const directoryStat = await lstat(target.directory);
|
||||
if (!directoryStat.isDirectory() || directoryStat.isSymbolicLink() || !sameNode(directoryStat, target.directoryStat)
|
||||
|| !samePath(await realpath(target.directory), target.directory)) invalid();
|
||||
}
|
||||
|
||||
function relativePath(value: unknown): string {
|
||||
if (typeof value !== 'string' || !value || value.length > MAX_PATH_LENGTH
|
||||
|| containsControl(value) || /[\\:]/u.test(value) || value.trim() !== value
|
||||
|| path.posix.isAbsolute(value) || path.win32.isAbsolute(value)) invalid();
|
||||
const pieces = value.split('/');
|
||||
if (pieces.length > 32 || pieces.includes('..')) invalid();
|
||||
return path.posix.normalize(value);
|
||||
}
|
||||
|
||||
async function relativeTarget(root: string, value: unknown, kind: 'directory' | 'file'): Promise<string> {
|
||||
const relative = relativePath(value);
|
||||
let current = root;
|
||||
for (const piece of relative.split('/').filter((piece) => piece !== '.')) {
|
||||
current = path.join(current, piece);
|
||||
if ((await lstat(current)).isSymbolicLink()) invalid();
|
||||
}
|
||||
const stat = await lstat(current);
|
||||
if (!(kind === 'directory' ? stat.isDirectory() : stat.isFile())
|
||||
|| !samePath(await realpath(current), current)) invalid();
|
||||
return relative;
|
||||
}
|
||||
|
||||
function localUrl(value: unknown): string {
|
||||
if (typeof value !== 'string' || value.length > MAX_URL_LENGTH
|
||||
|| containsControl(value, true)) invalid();
|
||||
const url = new URL(value);
|
||||
if (!['http:', 'https:'].includes(url.protocol)
|
||||
|| !['localhost', '127.0.0.1', '[::1]'].includes(url.hostname)
|
||||
|| url.username || url.password) invalid();
|
||||
return value;
|
||||
}
|
||||
|
||||
/** Only structured Vite options are persisted; the launcher supplies the loopback host. */
|
||||
function viteArgs(value: unknown): string[] {
|
||||
if (!Array.isArray(value) || value.length > 16) invalid();
|
||||
const result: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (let index = 0; index < value.length; index += 1) {
|
||||
const flag: unknown = value[index];
|
||||
if (typeof flag !== 'string' || seen.has(flag)) invalid();
|
||||
seen.add(flag);
|
||||
if (flag === '--strictPort' || flag === '--force') {
|
||||
result.push(flag);
|
||||
continue;
|
||||
}
|
||||
const parameter: unknown = value[++index];
|
||||
if (typeof parameter !== 'string') invalid();
|
||||
if (flag === '--port') {
|
||||
if (!/^[1-9][0-9]{0,4}$/u.test(parameter) || Number(parameter) > 65_535) invalid();
|
||||
} else if (flag === '--mode') {
|
||||
if (!/^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/u.test(parameter)) invalid();
|
||||
} else if (flag === '--base') {
|
||||
if (parameter.length > MAX_PATH_LENGTH || !/^(?:\/(?!\/)|\.\/)/u.test(parameter)
|
||||
|| containsControl(parameter, true) || /[\\?#:]/u.test(parameter)
|
||||
|| parameter.split('/').includes('..')) invalid();
|
||||
} else invalid();
|
||||
result.push(flag, parameter);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
async function launchSpec(root: string, value: unknown): Promise<WorkLaunchSpec> {
|
||||
if (!isRecord(value)) invalid();
|
||||
if (value.kind === 'vite' && hasOnlyKeys(value, ['kind', 'directory', 'args'])) {
|
||||
return { kind: 'vite', directory: await relativeTarget(root, value.directory, 'directory'), args: viteArgs(value.args) };
|
||||
}
|
||||
if (value.kind === 'static' && hasOnlyKeys(value, ['kind', 'directory', 'entry'])) {
|
||||
const directory = await relativeTarget(root, value.directory, 'directory');
|
||||
const entry = await relativeTarget(path.join(root, directory), value.entry, 'file');
|
||||
return { kind: 'static', directory, entry };
|
||||
}
|
||||
return invalid();
|
||||
}
|
||||
|
||||
async function validatedEntry(project: WorkEntryProject, root: string, value: unknown, saving = false): Promise<WorkEntry> {
|
||||
if (!isRecord(value) || !hasOnlyKeys(value, ['version', 'projectId', 'projectPath', 'url', 'launch', 'documentHash'])
|
||||
|| value.version !== 1 || value.projectId !== project.id || typeof value.projectPath !== 'string'
|
||||
|| !path.isAbsolute(value.projectPath)) invalid();
|
||||
const boundPath = saving ? await realpath(value.projectPath) : value.projectPath;
|
||||
if (!samePath(boundPath, root)) invalid();
|
||||
if (value.documentHash !== undefined
|
||||
&& (typeof value.documentHash !== 'string' || !/^[a-f0-9]{64}$/u.test(value.documentHash))) invalid();
|
||||
return {
|
||||
version: 1, projectId: project.id, projectPath: root, url: localUrl(value.url),
|
||||
...(value.launch === undefined ? {} : { launch: await launchSpec(root, value.launch) }),
|
||||
...(value.documentHash === undefined ? {} : { documentHash: value.documentHash }),
|
||||
};
|
||||
}
|
||||
|
||||
/** Reads bounded project-owned metadata without following links or starting any work. */
|
||||
export async function loadWorkEntry(project: WorkEntryProject): Promise<WorkEntry | null> {
|
||||
try {
|
||||
const target = await locations(project);
|
||||
const fileStat = await lstat(target.file);
|
||||
if (!fileStat.isFile() || fileStat.isSymbolicLink() || fileStat.size > MAX_ENTRY_BYTES) return null;
|
||||
const handle = await open(target.file, constants.O_RDONLY | NO_FOLLOW);
|
||||
try {
|
||||
const openedStat = await handle.stat();
|
||||
if (!openedStat.isFile() || openedStat.size > MAX_ENTRY_BYTES || !sameNode(fileStat, openedStat)) return null;
|
||||
const bytes = Buffer.alloc(MAX_ENTRY_BYTES + 1);
|
||||
let size = 0;
|
||||
while (size < bytes.length) {
|
||||
const chunk = await handle.read(bytes, size, bytes.length - size, size);
|
||||
if (!chunk.bytesRead) break;
|
||||
size += chunk.bytesRead;
|
||||
}
|
||||
if (size > MAX_ENTRY_BYTES) return null;
|
||||
await assertLocations(target);
|
||||
const currentStat = await lstat(target.file);
|
||||
if (currentStat.isSymbolicLink() || !sameNode(openedStat, currentStat)) return null;
|
||||
const source = new TextDecoder('utf-8', { fatal: true }).decode(bytes.subarray(0, size));
|
||||
return await validatedEntry(project, target.root, JSON.parse(source));
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
} catch {
|
||||
// Missing, copied, changed or corrupt metadata is never launch authority.
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Publishes one complete entry in project metadata, using an exclusive temp file. */
|
||||
export async function saveWorkEntry(project: WorkEntryProject, entry: WorkEntry): Promise<void> {
|
||||
const root = await projectRoot(project);
|
||||
const value = await validatedEntry(project, root.root, entry, true);
|
||||
const source = `${JSON.stringify(value, null, 2)}\n`;
|
||||
if (Buffer.byteLength(source, 'utf8') > MAX_ENTRY_BYTES) invalid();
|
||||
const target = await locations(project, true, root);
|
||||
await assertLocations(target);
|
||||
const temporary = path.join(target.directory, `.${ENTRY_FILENAME}.${randomUUID()}.tmp`);
|
||||
try {
|
||||
const handle = await open(temporary, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | NO_FOLLOW, 0o600);
|
||||
try {
|
||||
await handle.writeFile(source, 'utf8');
|
||||
await handle.sync();
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
await assertLocations(target);
|
||||
const existing = await lstat(target.file).catch((error: NodeJS.ErrnoException) => {
|
||||
if (error.code !== 'ENOENT') throw error;
|
||||
return null;
|
||||
});
|
||||
if (existing && (!existing.isFile() || existing.isSymbolicLink())) invalid();
|
||||
await rename(temporary, target.file);
|
||||
} finally {
|
||||
await assertLocations(target).then(() => rm(temporary, { force: true })).catch(() => undefined);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,551 @@
|
||||
import { spawn, type ChildProcess } from 'node:child_process';
|
||||
import { constants } from 'node:fs';
|
||||
import { lstat, mkdir, open, readFile, readdir, realpath } from 'node:fs/promises';
|
||||
import { get, type IncomingHttpHeaders } from 'node:http';
|
||||
import { createServer } from 'node:net';
|
||||
import path from 'node:path';
|
||||
import { createHash, randomUUID } from 'node:crypto';
|
||||
import type { WorkEntryProject, WorkLaunchSpec } from './work-entry-contracts';
|
||||
|
||||
export type WorkLaunchErrorCode =
|
||||
| 'WORK_PREVIEW_DEPENDENCIES_MISSING'
|
||||
| 'WORK_PREVIEW_START_FAILED'
|
||||
| 'WORK_PREVIEW_ENTRY_MISSING'
|
||||
| 'WORK_PREVIEW_NEEDS_HELP';
|
||||
|
||||
export class WorkLaunchError extends Error {
|
||||
constructor(readonly code: WorkLaunchErrorCode, message: string) {
|
||||
super(message);
|
||||
this.name = 'WorkLaunchError';
|
||||
}
|
||||
}
|
||||
|
||||
const MAX_DEPTH = 2;
|
||||
const MAX_DIRECTORIES = 160;
|
||||
const START_TIMEOUT_MS = 15_000;
|
||||
const ownedLaunches = new Map<string, ChildProcess>();
|
||||
const SKIP_DIRECTORIES = new Set(['node_modules', '.git', '.makelore', '.codex']);
|
||||
const LOCAL_BASE = /^(?:\/(?!\/)[A-Za-z0-9_./-]*|\.\/?[A-Za-z0-9_./-]*)$/;
|
||||
|
||||
/** Only a small Vite CLI vocabulary is replayable; package scripts never enter a shell. */
|
||||
function viteArgs(script: unknown): string[] | null {
|
||||
if (typeof script !== 'string' || script.length > 1_000) return null;
|
||||
const tokens = script.trim().split(/\s+/);
|
||||
if (tokens.shift() !== 'vite') return null;
|
||||
const args: string[] = [];
|
||||
for (let index = 0; index < tokens.length; index += 1) {
|
||||
const token = tokens[index];
|
||||
const equal = token.indexOf('=');
|
||||
const flag = equal < 0 ? token : token.slice(0, equal);
|
||||
const inline = equal < 0 ? undefined : token.slice(equal + 1);
|
||||
if (flag === '--strictPort' || flag === '--force') {
|
||||
if (inline !== undefined) return null;
|
||||
args.push(flag);
|
||||
continue;
|
||||
}
|
||||
if (flag === '--host') {
|
||||
const host = inline ?? (!tokens[index + 1]?.startsWith('--') ? tokens[++index] : undefined);
|
||||
if (host !== undefined && !['127.0.0.1', 'localhost', '0.0.0.0'].includes(host)) return null;
|
||||
continue; // The app supplies the loopback binding.
|
||||
}
|
||||
if (!['--port', '--base', '--mode'].includes(flag)) return null;
|
||||
const value = inline ?? tokens[++index];
|
||||
if (!value) return null;
|
||||
if (flag === '--port' && (!/^\d{1,5}$/.test(value) || Number(value) < 1 || Number(value) > 65_535)) return null;
|
||||
if (flag === '--mode' && !/^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/.test(value)) return null;
|
||||
if (flag === '--base' && (!LOCAL_BASE.test(value) || value.split('/').includes('..'))) return null;
|
||||
if (args.includes(flag)) return null;
|
||||
args.push(flag, flag === '--port' ? String(Number(value)) : value);
|
||||
}
|
||||
return args;
|
||||
}
|
||||
|
||||
function within(root: string, target: string): boolean {
|
||||
const relative = path.relative(root, target);
|
||||
return relative === '' || (!relative.startsWith(`..${path.sep}`) && relative !== '..' && !path.isAbsolute(relative));
|
||||
}
|
||||
|
||||
function relativeParts(value: string): string[] {
|
||||
if (typeof value !== 'string' || !value || value.includes('\\') || value.includes('\0') || path.isAbsolute(value)) {
|
||||
throw new WorkLaunchError('WORK_PREVIEW_ENTRY_MISSING', '作品的启动位置已改变,请重新检查作品。');
|
||||
}
|
||||
if (value === '.') return [];
|
||||
const parts = value.split('/');
|
||||
if (parts.some((part) => !part || part === '.' || part === '..' || part.startsWith('.'))) {
|
||||
throw new WorkLaunchError('WORK_PREVIEW_ENTRY_MISSING', '作品的启动位置不在项目中。');
|
||||
}
|
||||
return parts;
|
||||
}
|
||||
|
||||
async function safeExistingPath(root: string, relative: string, kind: 'directory' | 'file'): Promise<string> {
|
||||
const parts = relativeParts(relative);
|
||||
let current = root;
|
||||
try {
|
||||
for (const part of parts) {
|
||||
current = path.join(current, part);
|
||||
if ((await lstat(current)).isSymbolicLink()) throw new Error('symlink');
|
||||
}
|
||||
const stat = await lstat(current);
|
||||
const resolved = await realpath(current);
|
||||
if (!within(root, resolved) || (kind === 'file' ? !stat.isFile() : !stat.isDirectory())) throw new Error('outside project');
|
||||
return resolved;
|
||||
} catch {
|
||||
throw new WorkLaunchError('WORK_PREVIEW_ENTRY_MISSING', '作品的文件或启动位置找不到了。');
|
||||
}
|
||||
}
|
||||
|
||||
async function projectRoot(projectPath: string): Promise<string> {
|
||||
try {
|
||||
const root = await realpath(projectPath);
|
||||
if (!(await lstat(root)).isDirectory()) throw new Error('not a directory');
|
||||
return root;
|
||||
} catch {
|
||||
throw new WorkLaunchError('WORK_PREVIEW_ENTRY_MISSING', '项目文件夹找不到了。');
|
||||
}
|
||||
}
|
||||
|
||||
async function packageRecord(directory: string): Promise<Record<string, unknown> | null> {
|
||||
const file = path.join(directory, 'package.json');
|
||||
const stat = await lstat(file).catch(() => null);
|
||||
if (!stat) return null;
|
||||
if (!stat.isFile() || stat.isSymbolicLink() || stat.size > 1_000_000) {
|
||||
throw new WorkLaunchError('WORK_PREVIEW_NEEDS_HELP', '这个作品的启动方式需要帮忙检查。');
|
||||
}
|
||||
try {
|
||||
const value: unknown = JSON.parse(await readFile(file, 'utf8'));
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error('invalid package');
|
||||
return value as Record<string, unknown>;
|
||||
} catch {
|
||||
throw new WorkLaunchError('WORK_PREVIEW_NEEDS_HELP', '这个作品的启动方式需要帮忙检查。');
|
||||
}
|
||||
}
|
||||
|
||||
function devArgs(pkg: Record<string, unknown>): string[] | null {
|
||||
const scripts = pkg.scripts;
|
||||
return viteArgs(scripts && typeof scripts === 'object' && !Array.isArray(scripts)
|
||||
? (scripts as Record<string, unknown>).dev
|
||||
: undefined);
|
||||
}
|
||||
|
||||
export async function discoverWorkLaunch(projectPath: string): Promise<WorkLaunchSpec | null> {
|
||||
const root = await projectRoot(projectPath);
|
||||
const candidates: WorkLaunchSpec[] = [];
|
||||
let inspected = 0;
|
||||
let needsHelp = false;
|
||||
const visit = async (directory: string, depth: number): Promise<void> => {
|
||||
if (++inspected > MAX_DIRECTORIES) {
|
||||
needsHelp = true;
|
||||
return;
|
||||
}
|
||||
const relative = path.relative(root, directory).split(path.sep).join('/') || '.';
|
||||
let pkg: Record<string, unknown> | null;
|
||||
try { pkg = await packageRecord(directory); } catch { needsHelp = true; return; }
|
||||
if (pkg) {
|
||||
const args = devArgs(pkg);
|
||||
if (args) candidates.push({ kind: 'vite', directory: relative, args });
|
||||
else needsHelp = true;
|
||||
// Do not mistake a framework's public/dist/template files for a plain website.
|
||||
return;
|
||||
}
|
||||
const index = await lstat(path.join(directory, 'index.html')).catch(() => null);
|
||||
if (index?.isFile() && !index.isSymbolicLink()) {
|
||||
candidates.push({ kind: 'static', directory: relative, entry: 'index.html' });
|
||||
return;
|
||||
}
|
||||
if (depth >= MAX_DEPTH || candidates.length > 1) return;
|
||||
const entries = await readdir(directory, { withFileTypes: true }).catch(() => []);
|
||||
entries.sort((left, right) => left.name.localeCompare(right.name));
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory() || entry.isSymbolicLink() || entry.name.startsWith('.') || SKIP_DIRECTORIES.has(entry.name)) continue;
|
||||
await visit(path.join(directory, entry.name), depth + 1);
|
||||
if (candidates.length > 1 || inspected > MAX_DIRECTORIES) break;
|
||||
}
|
||||
};
|
||||
await visit(root, 0);
|
||||
if (candidates.length > 1 || needsHelp) {
|
||||
throw new WorkLaunchError('WORK_PREVIEW_NEEDS_HELP', '需要帮忙确认打开哪个作品,以及怎样启动它。');
|
||||
}
|
||||
return candidates[0] ?? null;
|
||||
}
|
||||
|
||||
async function viteCli(root: string, directory: string): Promise<string> {
|
||||
for (let current = directory; within(root, current); current = path.dirname(current)) {
|
||||
const packagePath = path.join(current, 'node_modules', 'vite', 'package.json');
|
||||
try {
|
||||
const pkg = JSON.parse(await readFile(packagePath, 'utf8')) as { name?: string };
|
||||
const cli = await realpath(path.join(current, 'node_modules', 'vite', 'bin', 'vite.js'));
|
||||
const api = path.resolve(path.dirname(cli), '../dist/node/index.js');
|
||||
if (pkg.name === 'vite' && (await lstat(cli)).isFile() && (await lstat(api)).isFile()) return cli;
|
||||
} catch { /* Try installed dependencies at a containing project directory. */ }
|
||||
if (current === root) break;
|
||||
}
|
||||
throw new WorkLaunchError('WORK_PREVIEW_DEPENDENCIES_MISSING', '作品的启动工具还没准备好,需要帮忙检查。');
|
||||
}
|
||||
|
||||
function argumentValue(args: string[], flag: string): string | undefined {
|
||||
const index = args.indexOf(flag);
|
||||
return index < 0 ? undefined : args[index + 1];
|
||||
}
|
||||
|
||||
function previousPort(previousUrl: string | undefined): number | undefined {
|
||||
try {
|
||||
const url = new URL(previousUrl ?? '');
|
||||
if (url.protocol !== 'http:' || !['127.0.0.1', 'localhost', '[::1]'].includes(url.hostname)
|
||||
|| url.username || url.password || !url.port) return undefined;
|
||||
return Number(url.port);
|
||||
} catch { return undefined; }
|
||||
}
|
||||
|
||||
async function availablePort(preferred?: number): Promise<number> {
|
||||
const reserve = (port: number) => new Promise<number>((resolve, reject) => {
|
||||
const server = createServer();
|
||||
server.once('error', reject);
|
||||
server.listen(port, '127.0.0.1', () => {
|
||||
const address = server.address();
|
||||
if (!address || typeof address === 'string') { server.close(); reject(new Error('port unavailable')); return; }
|
||||
server.close((error) => error ? reject(error) : resolve(address.port));
|
||||
});
|
||||
});
|
||||
if (preferred) {
|
||||
try { return await reserve(preferred); } catch { /* Never kill whoever already owns the old port. */ }
|
||||
}
|
||||
return await reserve(0);
|
||||
}
|
||||
|
||||
// App-owned code, passed as a Node argument. No project script or shell is evaluated.
|
||||
const STATIC_RUNNER = String.raw`
|
||||
const http = require('node:http');
|
||||
const fs = require('node:fs/promises');
|
||||
const path = require('node:path');
|
||||
const [root, entry, port, token, identity] = process.argv.slice(1);
|
||||
const mime = {'.html':'text/html; charset=utf-8','.js':'text/javascript; charset=utf-8','.mjs':'text/javascript; charset=utf-8','.css':'text/css; charset=utf-8','.json':'application/json','.svg':'image/svg+xml','.png':'image/png','.jpg':'image/jpeg','.jpeg':'image/jpeg','.webp':'image/webp','.gif':'image/gif','.ico':'image/x-icon','.mp3':'audio/mpeg','.wav':'audio/wav','.ogg':'audio/ogg','.mp4':'video/mp4','.woff':'font/woff','.woff2':'font/woff2','.ttf':'font/ttf','.wasm':'application/wasm','.txt':'text/plain; charset=utf-8'};
|
||||
async function safeFile(parts) {
|
||||
let file = root;
|
||||
for (const part of parts) {
|
||||
file = path.join(file, part);
|
||||
if ((await fs.lstat(file)).isSymbolicLink()) throw new Error('forbidden');
|
||||
}
|
||||
const resolved = await fs.realpath(file);
|
||||
const relative = path.relative(root, resolved);
|
||||
if (relative === '..' || relative.startsWith('..' + path.sep) || path.isAbsolute(relative)) throw new Error('forbidden');
|
||||
if (!(await fs.stat(resolved)).isFile()) throw new Error('missing');
|
||||
return resolved;
|
||||
}
|
||||
http.createServer(async (req, res) => {
|
||||
try {
|
||||
if (req.method !== 'GET' && req.method !== 'HEAD') { res.writeHead(405); res.end(); return; }
|
||||
const pathname = decodeURIComponent((req.url || '/').split('?')[0]);
|
||||
if (!pathname.startsWith('/') || pathname.includes('\\') || pathname.includes('\0')) throw new Error('forbidden');
|
||||
let parts = pathname.slice(1).split('/').filter(Boolean);
|
||||
if (parts.some(part => part === '..' || part.startsWith('.'))) throw new Error('forbidden');
|
||||
if (parts.length === 0) parts = entry.split('/');
|
||||
else if (pathname.endsWith('/')) parts.push('index.html');
|
||||
const file = await safeFile(parts);
|
||||
const bytes = await fs.readFile(file);
|
||||
res.writeHead(200, {'content-type':mime[path.extname(file).toLowerCase()] || 'application/octet-stream','content-length':bytes.length,'cache-control':'no-store','x-makelore-work':token,'x-makelore-work-project':identity,'x-content-type-options':'nosniff'});
|
||||
res.end(req.method === 'HEAD' ? undefined : bytes);
|
||||
} catch (error) {
|
||||
res.writeHead(error.message === 'forbidden' ? 403 : 404); res.end();
|
||||
}
|
||||
}).listen(Number(port), '127.0.0.1', () => console.log('Makelore preview pid=' + process.pid));
|
||||
`;
|
||||
|
||||
// Use the installed Vite runtime/config with app-owned options and report its resolved base.
|
||||
const VITE_RUNNER = String.raw`
|
||||
const http = require('node:http');
|
||||
const {pathToFileURL} = require('node:url');
|
||||
const path = require('node:path');
|
||||
const fs = require('node:fs/promises');
|
||||
const [token, identity, cli, projectRoot, ...args] = process.argv.slice(1);
|
||||
const emit = http.Server.prototype.emit;
|
||||
http.Server.prototype.emit = function(event, ...values) {
|
||||
if (event === 'request') {
|
||||
values[1].setHeader('x-makelore-work', token);
|
||||
values[1].setHeader('x-makelore-work-project', identity);
|
||||
}
|
||||
return emit.call(this, event, ...values);
|
||||
};
|
||||
const value = flag => args[args.indexOf(flag) + 1];
|
||||
(async () => {
|
||||
const vite = await import(pathToFileURL(path.resolve(path.dirname(cli), '../dist/node/index.js')).href);
|
||||
const options = {clearScreen:false,server:{host:'127.0.0.1',port:Number(value('--port')),strictPort:true,open:false},optimizeDeps:{force:args.includes('--force')}};
|
||||
if (args.includes('--base')) options.base = value('--base');
|
||||
if (args.includes('--mode')) options.mode = value('--mode');
|
||||
const server = await vite.createServer(options);
|
||||
const root = await fs.realpath(server.config.root);
|
||||
const relative = path.relative(projectRoot, root);
|
||||
if (relative === '..' || relative.startsWith('..' + path.sep) || path.isAbsolute(relative)) throw new Error('Vite root is outside this project');
|
||||
await server.listen();
|
||||
const origin = 'http://127.0.0.1:' + value('--port');
|
||||
const url = new URL(server.config.base || '/', origin + '/');
|
||||
if (url.origin !== origin || url.username || url.password) throw new Error('Vite base is outside this local server');
|
||||
console.log('Makelore ready pid=' + process.pid + ' url=' + url.href);
|
||||
if (!process.send) throw new Error('Missing launch readiness channel');
|
||||
process.send({token,url:url.href}, () => process.disconnect());
|
||||
})().catch(error => { console.error(error); process.exit(1); });
|
||||
`;
|
||||
|
||||
async function previewLog(root: string, kind: WorkLaunchSpec['kind']) {
|
||||
let directory = root;
|
||||
for (const part of ['.makelore', 'logs']) {
|
||||
directory = path.join(directory, part);
|
||||
await mkdir(directory).catch((error: NodeJS.ErrnoException) => { if (error.code !== 'EEXIST') throw error; });
|
||||
const stat = await lstat(directory);
|
||||
if (stat.isSymbolicLink() || !stat.isDirectory()) throw new Error('unsafe log directory');
|
||||
}
|
||||
const logPath = path.join(directory, `${kind}-preview.log`);
|
||||
const previous = await lstat(logPath).catch(() => null);
|
||||
if (previous && (previous.isSymbolicLink() || !previous.isFile())) throw new Error('unsafe log file');
|
||||
return await open(logPath,
|
||||
constants.O_CREAT | constants.O_APPEND | constants.O_WRONLY | (constants.O_NOFOLLOW ?? 0), 0o600);
|
||||
}
|
||||
|
||||
function childEnvironment(): NodeJS.ProcessEnv {
|
||||
const env: NodeJS.ProcessEnv = { ELECTRON_RUN_AS_NODE: '1', CI: '1', NO_COLOR: '1' };
|
||||
for (const key of ['PATH', 'HOME', 'USERPROFILE', 'SYSTEMROOT', 'COMSPEC', 'TEMP', 'TMP', 'APPDATA', 'LOCALAPPDATA']) {
|
||||
if (process.env[key] !== undefined) env[key] = process.env[key];
|
||||
}
|
||||
return env;
|
||||
}
|
||||
|
||||
type PreviewPage = { status?: number; headers: IncomingHttpHeaders; body: string };
|
||||
|
||||
function readPage(url: string, maxBytes = 256 * 1024): Promise<PreviewPage | null> {
|
||||
return new Promise((resolve) => {
|
||||
let settled = false;
|
||||
const finish = (page: PreviewPage | null) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
resolve(page);
|
||||
};
|
||||
const timer = setTimeout(() => { request.destroy(); finish(null); }, 800);
|
||||
const request = get(url, (response) => {
|
||||
let body = '';
|
||||
let receivedBytes = 0;
|
||||
response.setEncoding('utf8');
|
||||
response.on('data', (chunk: string) => {
|
||||
receivedBytes += Buffer.byteLength(chunk, 'utf8');
|
||||
if (receivedBytes > maxBytes) { request.destroy(); finish(null); return; }
|
||||
body += chunk;
|
||||
});
|
||||
response.once('error', () => finish(null));
|
||||
response.once('end', () => finish({ status: response.statusCode, headers: response.headers, body }));
|
||||
});
|
||||
request.once('error', () => finish(null));
|
||||
});
|
||||
}
|
||||
|
||||
function workIdentity(root: string, directory: string): string {
|
||||
return createHash('sha256').update(`${root}\0${directory}`).digest('hex');
|
||||
}
|
||||
|
||||
function hasExactModuleSource(body: string, source: string): boolean {
|
||||
const fullSource = source.trim();
|
||||
if (!fullSource) return false;
|
||||
if (body.includes(fullSource)) return true;
|
||||
const maps = [...body.matchAll(/\/\/[#@]\s*sourceMappingURL=data:application\/json(?:;charset=[\w-]+)?;base64,([A-Za-z0-9+/=]+)/g)];
|
||||
const lastMap = maps.at(-1)?.[1];
|
||||
if (!lastMap || lastMap.length > 2 * 1024 * 1024) return false;
|
||||
try {
|
||||
const map = JSON.parse(Buffer.from(lastMap, 'base64').toString('utf8')) as { sourcesContent?: unknown };
|
||||
return Array.isArray(map.sourcesContent) && map.sourcesContent.some((content) => content === source);
|
||||
} catch { return false; }
|
||||
}
|
||||
|
||||
function exactRawModule(body: string, source: string): boolean {
|
||||
const exported = body.match(/^\s*export\s+default\s+("(?:[^"\\]|\\.)*")\s*;?\s*(?:\/\/[#@]\s*sourceMappingURL=[^\r\n]*\s*)?$/);
|
||||
if (!exported) return false;
|
||||
try { return JSON.parse(exported[1]) === source; } catch { return false; }
|
||||
}
|
||||
|
||||
const TEXT_MODULE_EXTENSION = /\.(?:[cm]?js|jsx|[cm]?ts|tsx|css|json|svg|glsl|html?|txt)$/i;
|
||||
|
||||
async function matchesLocalModule(
|
||||
directory: string,
|
||||
relative: string,
|
||||
address: URL,
|
||||
visited: Set<string>,
|
||||
depth: number,
|
||||
): Promise<boolean> {
|
||||
if (visited.has(relative)) return true;
|
||||
if (depth > 4 || visited.size >= 20) return false;
|
||||
visited.add(relative);
|
||||
try {
|
||||
const file = await safeExistingPath(directory, relative, 'file');
|
||||
if ((await lstat(file)).size > 512 * 1024) return false;
|
||||
const source = await readFile(file, 'utf8');
|
||||
const rawAddress = new URL(address);
|
||||
rawAddress.search = '?raw';
|
||||
const raw = await readPage(rawAddress.href, 2 * 1024 * 1024);
|
||||
if (!raw || raw.status !== 200 || !exactRawModule(raw.body, source)) {
|
||||
const response = await readPage(address.href, 2 * 1024 * 1024);
|
||||
if (!response || response.status !== 200 || !hasExactModuleSource(response.body, source)) return false;
|
||||
}
|
||||
// Exact entry text alone is insufficient when another local module contains the game.
|
||||
const specifiers = [...source.matchAll(/(?:\bfrom\s*|\bimport\s*(?:\(\s*)?)["']([^"']+)["']/g)].map((match) => match[1]);
|
||||
if (specifiers.some((specifier) => /^(?:@\/|~\/|\/)/.test(specifier))
|
||||
|| /\bimport\s*\(\s*([^'"\s])/.test(source)) return false;
|
||||
const imports = specifiers.filter((specifier) => /^\.{1,2}\//.test(specifier));
|
||||
const matches = await Promise.all(imports.map(async (specifier) => {
|
||||
const local = decodeURIComponent(specifier.split(/[?#]/)[0]);
|
||||
let imported = path.posix.normalize(path.posix.join(path.posix.dirname(relative), local));
|
||||
const extension = path.posix.extname(imported);
|
||||
if (extension && !TEXT_MODULE_EXTENSION.test(imported)) return true;
|
||||
if (!extension) {
|
||||
const candidates = [imported, ...['.mjs', '.js', '.mts', '.ts', '.jsx', '.tsx', '.json'].flatMap((suffix) => [imported + suffix, `${imported}/index${suffix}`])];
|
||||
const present: string[] = [];
|
||||
for (const candidate of candidates) {
|
||||
try { await safeExistingPath(directory, candidate, 'file'); present.push(candidate); } catch { /* Unknown imports cannot establish identity. */ }
|
||||
}
|
||||
if (present.length !== 1) return false;
|
||||
imported = present[0];
|
||||
}
|
||||
const importedAddress = new URL(local, address);
|
||||
if (!extension) {
|
||||
const original = path.posix.normalize(path.posix.join(path.posix.dirname(relative), local));
|
||||
importedAddress.pathname += imported.slice(original.length);
|
||||
}
|
||||
return await matchesLocalModule(directory, imported, importedAddress, visited, depth + 1);
|
||||
}));
|
||||
return matches.every(Boolean);
|
||||
} catch { return false; }
|
||||
}
|
||||
|
||||
async function matchesEntryModule(directory: string, module: string, pageUrl: string, spec: WorkLaunchSpec, visited: Set<string>): Promise<boolean> {
|
||||
try {
|
||||
const address = new URL(module, pageUrl);
|
||||
if (address.origin !== new URL(pageUrl).origin || address.username || address.password) return false;
|
||||
let relative = decodeURIComponent(module.split(/[?#]/)[0]);
|
||||
if (relative.startsWith('/')) {
|
||||
const base = spec.kind === 'vite' ? argumentValue(spec.args, '--base') : undefined;
|
||||
if (base?.startsWith('/') && relative.startsWith(base)) relative = relative.slice(base.length);
|
||||
else relative = relative.slice(1);
|
||||
}
|
||||
if (relative.startsWith('./')) relative = relative.slice(2);
|
||||
if (!/\.(?:[cm]?js|jsx|ts|tsx)$/.test(relative)) return false;
|
||||
return await matchesLocalModule(directory, relative, address, visited, 0);
|
||||
} catch { return false; }
|
||||
}
|
||||
|
||||
/** Check a saved address against this project's files before reusing its local service. */
|
||||
export async function workLaunchMatches(project: WorkEntryProject, spec: WorkLaunchSpec, url: string): Promise<boolean> {
|
||||
if (previousPort(url) === undefined) return false;
|
||||
try {
|
||||
const root = await projectRoot(project.path);
|
||||
const directory = await safeExistingPath(root, spec.directory, 'directory');
|
||||
const entry = await safeExistingPath(directory, spec.kind === 'static' ? spec.entry : 'index.html', 'file');
|
||||
const page = await readPage(url);
|
||||
if (!page || page.status !== 200 || !String(page.headers['content-type']).includes('text/html')) return false;
|
||||
if (page.headers['x-makelore-work-project'] === workIdentity(root, directory)) return true;
|
||||
const source = await readFile(entry, 'utf8');
|
||||
if (spec.kind === 'static') return page.body === source;
|
||||
// Older, manually opened Vite services do not yet have the app's ownership header.
|
||||
const title = source.match(/<title\b[^>]*>([\s\S]*?)<\/title>/i)?.[1]?.trim();
|
||||
const actualTitle = page.body.match(/<title\b[^>]*>([\s\S]*?)<\/title>/i)?.[1]?.trim();
|
||||
const modules = [...source.matchAll(/<script\b([^>]*)>/gi)].flatMap((match) => {
|
||||
if (!/\btype\s*=\s*["']module["']/i.test(match[1])) return [];
|
||||
const module = match[1].match(/\bsrc\s*=\s*["']([^"']+)["']/i)?.[1];
|
||||
return module ? [module] : [];
|
||||
});
|
||||
if (!title || title !== actualTitle || modules.length === 0 || modules.length > 8 || !page.body.includes('/@vite/client')) return false;
|
||||
const visited = new Set<string>();
|
||||
const matches = await Promise.all(modules.map((module) => matchesEntryModule(directory, module, url, spec, visited)));
|
||||
return matches.every(Boolean);
|
||||
} catch { return false; }
|
||||
}
|
||||
|
||||
function terminateOwnedProcess(child: ChildProcess): void {
|
||||
if (!child.pid || child.exitCode !== null) return;
|
||||
if (process.platform !== 'win32') {
|
||||
try { process.kill(-child.pid, 'SIGTERM'); return; } catch { /* The child may already have exited. */ }
|
||||
}
|
||||
child.kill();
|
||||
}
|
||||
|
||||
/** Cancel only a launch owned by this Main process, never an arbitrary saved address. */
|
||||
export async function stopWorkLaunch(project: WorkEntryProject, url: string): Promise<void> {
|
||||
const root = await projectRoot(project.path).catch(() => null);
|
||||
if (!root) return;
|
||||
const key = `${root}\0${url}`;
|
||||
const child = ownedLaunches.get(key);
|
||||
if (!child) return;
|
||||
ownedLaunches.delete(key);
|
||||
terminateOwnedProcess(child);
|
||||
}
|
||||
|
||||
export async function startWorkLaunch(project: WorkEntryProject, spec: WorkLaunchSpec, previousUrl?: string): Promise<string> {
|
||||
const root = await projectRoot(project.path);
|
||||
const directory = await safeExistingPath(root, spec.directory, 'directory');
|
||||
let args: string[];
|
||||
let base = '/';
|
||||
const token = randomUUID();
|
||||
const identity = workIdentity(root, directory);
|
||||
if (spec.kind === 'vite') {
|
||||
const pkg = await packageRecord(directory);
|
||||
const recognized = pkg && devArgs(pkg);
|
||||
if (!recognized || JSON.stringify(recognized) !== JSON.stringify(spec.args)) {
|
||||
throw new WorkLaunchError('WORK_PREVIEW_NEEDS_HELP', '作品的启动方式变了,需要帮忙检查。');
|
||||
}
|
||||
await safeExistingPath(directory, 'index.html', 'file');
|
||||
const cli = await viteCli(root, directory);
|
||||
args = ['-e', VITE_RUNNER, token, identity, cli, root,
|
||||
...spec.args.filter((value, index) => value !== '--port' && spec.args[index - 1] !== '--port' && value !== '--strictPort')];
|
||||
const configuredBase = argumentValue(spec.args, '--base');
|
||||
if (configuredBase?.startsWith('/')) base = configuredBase.endsWith('/') ? configuredBase : `${configuredBase}/`;
|
||||
} else if (spec.kind === 'static') {
|
||||
if (await packageRecord(directory)) throw new WorkLaunchError('WORK_PREVIEW_NEEDS_HELP', '这个作品需要先确认启动方式。');
|
||||
await safeExistingPath(directory, spec.entry, 'file');
|
||||
if (!/\.html?$/i.test(spec.entry)) throw new WorkLaunchError('WORK_PREVIEW_ENTRY_MISSING', '作品的网页入口找不到了。');
|
||||
args = ['-e', STATIC_RUNNER, directory, spec.entry];
|
||||
} else {
|
||||
throw new WorkLaunchError('WORK_PREVIEW_NEEDS_HELP', '这个作品的启动方式需要帮忙检查。');
|
||||
}
|
||||
let child: ChildProcess | undefined;
|
||||
try {
|
||||
const port = await availablePort(previousPort(previousUrl) ?? (spec.kind === 'vite' ? Number(argumentValue(spec.args, '--port')) || undefined : undefined));
|
||||
let url = `http://127.0.0.1:${port}${base}`;
|
||||
let readyUrl = spec.kind === 'static' ? url : undefined;
|
||||
args.push(...(spec.kind === 'vite' ? ['--host', '127.0.0.1', '--port', String(port), '--strictPort'] : [String(port), token, identity]));
|
||||
const log = await previewLog(root, spec.kind);
|
||||
try {
|
||||
child = spawn(process.execPath, args, {
|
||||
cwd: directory, env: childEnvironment(), shell: false, detached: true,
|
||||
windowsHide: true, stdio: spec.kind === 'vite' ? ['ignore', log.fd, log.fd, 'ipc'] : ['ignore', log.fd, log.fd],
|
||||
});
|
||||
child.on('message', (message: unknown) => {
|
||||
if (!message || typeof message !== 'object') return;
|
||||
const value = message as { token?: unknown; url?: unknown };
|
||||
if (value.token !== token || typeof value.url !== 'string' || value.url.length > 4_096) return;
|
||||
try {
|
||||
const address = new URL(value.url);
|
||||
if (address.origin === `http://127.0.0.1:${port}` && !address.username && !address.password) readyUrl = address.href;
|
||||
} catch { /* Ignore untrusted or malformed child messages. */ }
|
||||
});
|
||||
await new Promise<void>((resolve, reject) => { child!.once('spawn', resolve); child!.once('error', reject); });
|
||||
await log.write(`\nMakelore launch pid=${child.pid} url=${url}\n`);
|
||||
} finally { await log.close(); }
|
||||
let failed = false;
|
||||
child.once('error', () => { failed = true; });
|
||||
const deadline = Date.now() + START_TIMEOUT_MS;
|
||||
while (Date.now() < deadline) {
|
||||
if (failed || child.exitCode !== null || child.signalCode !== null) break;
|
||||
if (readyUrl) url = readyUrl;
|
||||
const page = readyUrl ? await readPage(url) : null;
|
||||
if (page?.status === 200 && String(page.headers['content-type']).includes('text/html')
|
||||
&& page.headers['x-makelore-work'] === token) {
|
||||
const key = `${root}\0${url}`;
|
||||
const ownedChild = child;
|
||||
ownedLaunches.set(key, ownedChild);
|
||||
ownedChild.once('exit', () => { if (ownedLaunches.get(key) === ownedChild) ownedLaunches.delete(key); });
|
||||
child.unref();
|
||||
return url;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 80));
|
||||
}
|
||||
throw new Error('preview did not become ready');
|
||||
} catch {
|
||||
if (child) terminateOwnedProcess(child);
|
||||
throw new WorkLaunchError('WORK_PREVIEW_START_FAILED', '作品没有成功启动,可以帮忙检查启动记录。');
|
||||
}
|
||||
}
|
||||
@@ -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