Advertisement

← vibesdk

worker/agents/think/space-workspace-ops.ts

5.9 KB Download file
import type { FileInfo } from '@cloudflare/shell';
import type {
	DeleteOperations,
	EditOperations,
	FindOperations,
	GrepOperations,
	ListOperations,
	ReadOperations,
	WriteOperations,
} from '@cloudflare/think/tools/workspace';

export interface SpaceWorkspaceStub extends DurableObjectStub {
	readFile(path: string): Promise<string>;
	readFileBytes(path: string): Promise<Uint8Array | null>;
	writeFile(path: string, content: string): Promise<unknown>;
	mkdir(path: string, opts?: { recursive?: boolean }): Promise<void>;
	rm(path: string, opts?: { recursive?: boolean; force?: boolean }): Promise<void>;
	readDir(dir: string, opts?: { limit?: number; offset?: number }): Promise<FileInfo[]>;
	glob(pattern: string): Promise<string[]>;
	stat(path: string): Promise<FileInfo | null>;
	// Git + deploy RPC methods (used by the deploy tool and behavior).
	gitCommit(
		message: string,
		author?: { name: string; email: string },
	): Promise<{ sha: string; message: string }>;
	gitStatus(): Promise<unknown>;
	deploy(branch: string): Promise<unknown>;
	rollbackToCommit(branch: string, commitHash: string): Promise<unknown>;
}

export type SpaceWorkspaceOps = ReadOperations &
	WriteOperations &
	EditOperations &
	ListOperations &
	FindOperations &
	DeleteOperations &
	GrepOperations;

function isFileInfo(value: FileInfo | null): value is FileInfo {
	return value !== null;
}

/**
 * Detect a transient Durable Object restart. "Durable Object reset because its
 * code was updated" is thrown on stub calls that are in flight when the worker
 * version changes (platform deploy, or a local dev rebuild). The error is
 * per-call: a freshly resolved stub routes to the new instance.
 */
export function isDurableObjectResetError(error: unknown): boolean {
	const message = error instanceof Error ? error.message : String(error);
	return /reset because its code was updated|Durable Object .*(reset|restarted)/i.test(message);
}

/**
 * Run a SpaceDO RPC, retrying ONCE with a freshly resolved stub when the call
 * failed because the DO was reset. All other errors propagate unchanged.
 */
export async function withDurableObjectResetRetry<S, T>(
	getStub: () => S,
	call: (stub: S) => Promise<T>,
): Promise<T> {
	try {
		return await call(getStub());
	} catch (error) {
		if (!isDurableObjectResetError(error)) throw error;
		return await call(getStub());
	}
}

// ── TEMP DIAGNOSTIC (write-escape-diag) ──────────────────────────────────────
// The deploy build has been failing on generated files that contain literal
// backslash-escaped quotes/backticks (e.g. `\" + \"`), which is a double-escape
// signature. This detector flags when such sequences arrive at writeFile so we
// can tell whether the corruption is present in the content the tool receives
// (i.e. upstream of the SpaceDO write) or introduced later. Remove once the
// source is confirmed.
const ESCAPE_DIAG_RE = /\\["'`]/;
function logSuspiciousEscaping(path: string, content: string): void {
	if (!ESCAPE_DIAG_RE.test(content)) return;
	const m = ESCAPE_DIAG_RE.exec(content);
	const idx = m ? m.index : 0;
	const snippet = content.slice(Math.max(0, idx - 40), idx + 40);
	// Count occurrences to gauge whether it's pervasive (double-escape) vs a
	// single legitimately-escaped char inside a normal string.
	const count = (content.match(/\\["'`]/g) ?? []).length;
	console.warn(
		`[write-escape-diag] path="${path}" len=${content.length} escapedQuoteCount=${count} firstAt=${idx}\n` +
			`  contextJSON=${JSON.stringify(snippet)}`,
	);
}
// ─────────────────────────────────────────────────────────────────────────────

function compareByPath(a: FileInfo, b: FileInfo): number {
	return a.path.localeCompare(b.path);
}

export function createSpaceWorkspaceOps(getStub: () => SpaceWorkspaceStub): SpaceWorkspaceOps {
	const ops: SpaceWorkspaceOps = {
		async readFile(path: string): Promise<string | null> {
			try {
				return await withDurableObjectResetRetry(getStub, (stub) => stub.readFile(path));
			} catch {
				return null;
			}
		},

		async readFileBytes(path: string): Promise<Uint8Array | null> {
			try {
				return await withDurableObjectResetRetry(getStub, (stub) =>
					stub.readFileBytes(path),
				);
			} catch {
				return null;
			}
		},

		async stat(path: string): Promise<FileInfo | null> {
			try {
				return await withDurableObjectResetRetry(getStub, (stub) => stub.stat(path));
			} catch {
				return null;
			}
		},

		async writeFile(path: string, content: string): Promise<void> {
			// TEMP: flag double-escaped content arriving at the write boundary.
			logSuspiciousEscaping(path, content);
			await withDurableObjectResetRetry(getStub, (stub) => stub.writeFile(path, content));
		},

		async mkdir(path: string, opts?: { recursive?: boolean }): Promise<void> {
			await withDurableObjectResetRetry(getStub, (stub) => stub.mkdir(path, opts));
		},

		async readDir(
			dir: string,
			opts?: { limit?: number; offset?: number },
		): Promise<FileInfo[]> {
			try {
				return await withDurableObjectResetRetry(getStub, (stub) => stub.readDir(dir, opts));
			} catch {
				return [];
			}
		},

		async glob(pattern: string): Promise<FileInfo[]> {
			const paths = await withDurableObjectResetRetry(getStub, (stub) => stub.glob(pattern));
			const infos = await Promise.all(
				paths.map((path) =>
					withDurableObjectResetRetry(getStub, (stub) => stub.stat(path)).catch(() => null),
				),
			);
			return infos.filter(isFileInfo).sort(compareByPath);
		},

		async rm(
			path: string,
			opts?: { recursive?: boolean; force?: boolean },
		): Promise<void> {
			await withDurableObjectResetRetry(getStub, (stub) => stub.rm(path, opts));
		},
	};

	return ops;
}