diff --git a/packages/coding-agent/package.json b/packages/coding-agent/package.json index 0c19d3042..55c9b8a6a 100644 --- a/packages/coding-agent/package.json +++ b/packages/coding-agent/package.json @@ -31,7 +31,7 @@ "scripts": { "clean": "shx rm -rf dist", "build": "tsgo -p tsconfig.build.json && shx chmod +x dist/cli.js && npm run copy-assets", - "build:binary": "npm --prefix ../tui run build && npm --prefix ../ai run build && npm --prefix ../agent run build && npm run build && bun build --compile ./dist/bun/cli.js --outfile dist/pi && npm run copy-binary-assets", + "build:binary": "npm --prefix ../tui run build && npm --prefix ../ai run build && npm --prefix ../agent run build && npm run build && bun build --compile ./dist/bun/cli.js ./dist/utils/image-resize-worker.js --outfile dist/pi && npm run copy-binary-assets", "copy-assets": "shx mkdir -p dist/modes/interactive/theme && shx cp src/modes/interactive/theme/*.json dist/modes/interactive/theme/ && shx mkdir -p dist/modes/interactive/assets && shx cp src/modes/interactive/assets/*.png dist/modes/interactive/assets/ && shx mkdir -p dist/core/export-html/vendor && shx cp src/core/export-html/template.html src/core/export-html/template.css src/core/export-html/template.js dist/core/export-html/ && shx cp src/core/export-html/vendor/*.js dist/core/export-html/vendor/", "copy-binary-assets": "shx cp package.json dist/ && shx cp README.md dist/ && shx cp CHANGELOG.md dist/ && shx mkdir -p dist/theme && shx cp src/modes/interactive/theme/*.json dist/theme/ && shx mkdir -p dist/assets && shx cp src/modes/interactive/assets/*.png dist/assets/ && shx mkdir -p dist/export-html/vendor && shx cp src/core/export-html/template.html dist/export-html/ && shx cp src/core/export-html/vendor/*.js dist/export-html/vendor/ && shx cp -r docs dist/ && shx cp -r examples dist/ && shx cp ../../node_modules/@silvia-odwyer/photon-node/photon_rs_bg.wasm dist/", "test": "vitest --run", diff --git a/packages/coding-agent/src/cli/file-processor.ts b/packages/coding-agent/src/cli/file-processor.ts index fe0e32ebf..e1da052a8 100644 --- a/packages/coding-agent/src/cli/file-processor.ts +++ b/packages/coding-agent/src/cli/file-processor.ts @@ -50,13 +50,12 @@ export async function processFileArguments(fileArgs: string[], options?: Process if (mimeType) { // Handle image file const content = await readFile(absolutePath); - const base64Content = content.toString("base64"); let attachment: ImageContent; let dimensionNote: string | undefined; if (autoResizeImages) { - const resized = await resizeImage({ type: "image", data: base64Content, mimeType }); + const resized = await resizeImage(content, mimeType); if (!resized) { text += `[Image omitted: could not be resized below the inline image size limit.]\n`; continue; @@ -71,7 +70,7 @@ export async function processFileArguments(fileArgs: string[], options?: Process attachment = { type: "image", mimeType, - data: base64Content, + data: content.toString("base64"), }; } diff --git a/packages/coding-agent/src/core/tools/bash.ts b/packages/coding-agent/src/core/tools/bash.ts index b9e551420..bc1d3cc4f 100644 --- a/packages/coding-agent/src/core/tools/bash.ts +++ b/packages/coding-agent/src/core/tools/bash.ts @@ -1,4 +1,5 @@ -import { existsSync } from "node:fs"; +import { constants } from "node:fs"; +import { access as fsAccess } from "node:fs/promises"; import type { AgentTool } from "@earendil-works/pi-agent-core"; import { Container, Text, truncateToWidth } from "@earendil-works/pi-tui"; import { spawn } from "child_process"; @@ -66,62 +67,70 @@ export function createLocalBashOperations(options?: { shellPath?: string }): Bas return { exec: (command, cwd, { onData, signal, timeout, env }) => { return new Promise((resolve, reject) => { - const { shell, args } = getShellConfig(options?.shellPath); - if (!existsSync(cwd)) { - reject(new Error(`Working directory does not exist: ${cwd}\nCannot execute bash commands.`)); - return; - } - const child = spawn(shell, [...args, command], { - cwd, - detached: process.platform !== "win32", - env: env ?? getShellEnv(), - stdio: ["ignore", "pipe", "pipe"], - windowsHide: true, - }); - if (child.pid) trackDetachedChildPid(child.pid); - let timedOut = false; - let timeoutHandle: NodeJS.Timeout | undefined; - // Set timeout if provided. - if (timeout !== undefined && timeout > 0) { - timeoutHandle = setTimeout(() => { - timedOut = true; - if (child.pid) killProcessTree(child.pid); - }, timeout * 1000); - } - // Stream stdout and stderr. - child.stdout?.on("data", onData); - child.stderr?.on("data", onData); - // Handle abort signal by killing the entire process tree. - const onAbort = () => { - if (child.pid) killProcessTree(child.pid); - }; - if (signal) { - if (signal.aborted) onAbort(); - else signal.addEventListener("abort", onAbort, { once: true }); - } - // Handle shell spawn errors and wait for the process to terminate without hanging - // on inherited stdio handles held by detached descendants. - waitForChildProcess(child) - .then((code) => { - if (child.pid) untrackDetachedChildPid(child.pid); - if (timeoutHandle) clearTimeout(timeoutHandle); - if (signal) signal.removeEventListener("abort", onAbort); - if (signal?.aborted) { - reject(new Error("aborted")); - return; - } - if (timedOut) { - reject(new Error(`timeout:${timeout}`)); - return; - } - resolve({ exitCode: code }); - }) - .catch((err) => { - if (child.pid) untrackDetachedChildPid(child.pid); - if (timeoutHandle) clearTimeout(timeoutHandle); - if (signal) signal.removeEventListener("abort", onAbort); - reject(err); + void (async () => { + const { shell, args } = getShellConfig(options?.shellPath); + try { + await fsAccess(cwd, constants.F_OK); + } catch { + reject(new Error(`Working directory does not exist: ${cwd}\nCannot execute bash commands.`)); + return; + } + if (signal?.aborted) { + reject(new Error("aborted")); + return; + } + const child = spawn(shell, [...args, command], { + cwd, + detached: process.platform !== "win32", + env: env ?? getShellEnv(), + stdio: ["ignore", "pipe", "pipe"], + windowsHide: true, }); + if (child.pid) trackDetachedChildPid(child.pid); + let timedOut = false; + let timeoutHandle: NodeJS.Timeout | undefined; + // Set timeout if provided. + if (timeout !== undefined && timeout > 0) { + timeoutHandle = setTimeout(() => { + timedOut = true; + if (child.pid) killProcessTree(child.pid); + }, timeout * 1000); + } + // Stream stdout and stderr. + child.stdout?.on("data", onData); + child.stderr?.on("data", onData); + // Handle abort signal by killing the entire process tree. + const onAbort = () => { + if (child.pid) killProcessTree(child.pid); + }; + if (signal) { + if (signal.aborted) onAbort(); + else signal.addEventListener("abort", onAbort, { once: true }); + } + // Handle shell spawn errors and wait for the process to terminate without hanging + // on inherited stdio handles held by detached descendants. + waitForChildProcess(child) + .then((code) => { + if (child.pid) untrackDetachedChildPid(child.pid); + if (timeoutHandle) clearTimeout(timeoutHandle); + if (signal) signal.removeEventListener("abort", onAbort); + if (signal?.aborted) { + reject(new Error("aborted")); + return; + } + if (timedOut) { + reject(new Error(`timeout:${timeout}`)); + return; + } + resolve({ exitCode: code }); + }) + .catch((err) => { + if (child.pid) untrackDetachedChildPid(child.pid); + if (timeoutHandle) clearTimeout(timeoutHandle); + if (signal) signal.removeEventListener("abort", onAbort); + reject(err); + }); + })().catch((err: unknown) => reject(err instanceof Error ? err : new Error(String(err)))); }); }, }; diff --git a/packages/coding-agent/src/core/tools/edit.ts b/packages/coding-agent/src/core/tools/edit.ts index 5d915d45b..7c6367a6d 100644 --- a/packages/coding-agent/src/core/tools/edit.ts +++ b/packages/coding-agent/src/core/tools/edit.ts @@ -313,113 +313,63 @@ export function createEditToolDefinition( const { path, edits } = validateEditInput(input); const absolutePath = resolveToCwd(path, cwd); - return withFileMutationQueue( - absolutePath, - () => - new Promise<{ - content: Array<{ type: "text"; text: string }>; - details: EditToolDetails | undefined; - }>((resolve, reject) => { - // Check if already aborted. - if (signal?.aborted) { - reject(new Error("Operation aborted")); - return; - } + return withFileMutationQueue(absolutePath, async () => { + let aborted = signal?.aborted ?? false; + const onAbort = () => { + aborted = true; + }; + const throwIfAborted = (): void => { + if (aborted || signal?.aborted) { + throw new Error("Operation aborted"); + } + }; - let aborted = false; + signal?.addEventListener("abort", onAbort, { once: true }); + try { + throwIfAborted(); - // Set up abort handler. - const onAbort = () => { - aborted = true; - reject(new Error("Operation aborted")); - }; + // Check if file exists. + try { + await ops.access(absolutePath); + } catch (error: unknown) { + throwIfAborted(); + const errorMessage = + error instanceof Error && "code" in error ? `Error code: ${error.code}` : String(error); + throw new Error(`Could not edit file: ${path}. ${errorMessage}.`); + } + throwIfAborted(); - if (signal) { - signal.addEventListener("abort", onAbort, { once: true }); - } + // Read the file. + const buffer = await ops.readFile(absolutePath); + throwIfAborted(); - // Perform the edit operation. - void (async () => { - try { - // Check if file exists. - try { - await ops.access(absolutePath); - } catch (error: unknown) { - const errorMessage = - error instanceof Error && "code" in error ? `Error code: ${error.code}` : String(error); - if (signal) { - signal.removeEventListener("abort", onAbort); - } - reject(new Error(`Could not edit file: ${path}. ${errorMessage}.`)); - return; - } + // Strip BOM before matching. The model will not include an invisible BOM in oldText. + const rawContent = buffer.toString("utf-8"); + const { bom, text: content } = stripBom(rawContent); + const originalEnding = detectLineEnding(content); + const normalizedContent = normalizeToLF(content); + const { baseContent, newContent } = applyEditsToNormalizedContent(normalizedContent, edits, path); + throwIfAborted(); - // Check if aborted before reading. - if (aborted) { - return; - } + const finalContent = bom + restoreLineEndings(newContent, originalEnding); + await ops.writeFile(absolutePath, finalContent); + throwIfAborted(); - // Read the file. - const buffer = await ops.readFile(absolutePath); - const rawContent = buffer.toString("utf-8"); - - // Check if aborted after reading. - if (aborted) { - return; - } - - // Strip BOM before matching. The model will not include an invisible BOM in oldText. - const { bom, text: content } = stripBom(rawContent); - const originalEnding = detectLineEnding(content); - const normalizedContent = normalizeToLF(content); - const { baseContent, newContent } = applyEditsToNormalizedContent( - normalizedContent, - edits, - path, - ); - - // Check if aborted before writing. - if (aborted) { - return; - } - - const finalContent = bom + restoreLineEndings(newContent, originalEnding); - await ops.writeFile(absolutePath, finalContent); - - // Check if aborted after writing. - if (aborted) { - return; - } - - // Clean up abort handler. - if (signal) { - signal.removeEventListener("abort", onAbort); - } - - const diffResult = generateDiffString(baseContent, newContent); - const patch = generateUnifiedPatch(path, baseContent, newContent); - resolve({ - content: [ - { - type: "text", - text: `Successfully replaced ${edits.length} block(s) in ${path}.`, - }, - ], - details: { diff: diffResult.diff, patch, firstChangedLine: diffResult.firstChangedLine }, - }); - } catch (error: unknown) { - // Clean up abort handler. - if (signal) { - signal.removeEventListener("abort", onAbort); - } - - if (!aborted) { - reject(error instanceof Error ? error : new Error(String(error))); - } - } - })(); - }), - ); + const diffResult = generateDiffString(baseContent, newContent); + const patch = generateUnifiedPatch(path, baseContent, newContent); + return { + content: [ + { + type: "text", + text: `Successfully replaced ${edits.length} block(s) in ${path}.`, + }, + ], + details: { diff: diffResult.diff, patch, firstChangedLine: diffResult.firstChangedLine }, + }; + } finally { + signal?.removeEventListener("abort", onAbort); + } + }); }, renderCall(args, theme, context) { const component = getEditCallRenderComponent(context.state, context.lastComponent); diff --git a/packages/coding-agent/src/core/tools/file-mutation-queue.ts b/packages/coding-agent/src/core/tools/file-mutation-queue.ts index 220112559..5505a7a27 100644 --- a/packages/coding-agent/src/core/tools/file-mutation-queue.ts +++ b/packages/coding-agent/src/core/tools/file-mutation-queue.ts @@ -1,14 +1,27 @@ -import { realpathSync } from "node:fs"; +import { realpath } from "node:fs/promises"; import { resolve } from "node:path"; const fileMutationQueues = new Map>(); +let registrationQueue = Promise.resolve(); -function getMutationQueueKey(filePath: string): string { +function isMissingPathError(error: unknown): boolean { + return ( + typeof error === "object" && + error !== null && + "code" in error && + (error.code === "ENOENT" || error.code === "ENOTDIR") + ); +} + +async function getMutationQueueKey(filePath: string): Promise { const resolvedPath = resolve(filePath); try { - return realpathSync.native(resolvedPath); - } catch { - return resolvedPath; + return await realpath(resolvedPath); + } catch (error) { + if (isMissingPathError(error)) { + return resolvedPath; + } + throw error; } } @@ -17,16 +30,25 @@ function getMutationQueueKey(filePath: string): string { * Operations for different files still run in parallel. */ export async function withFileMutationQueue(filePath: string, fn: () => Promise): Promise { - const key = getMutationQueueKey(filePath); - const currentQueue = fileMutationQueues.get(key) ?? Promise.resolve(); + const registration = registrationQueue.then(async () => { + const key = await getMutationQueueKey(filePath); + const currentQueue = fileMutationQueues.get(key) ?? Promise.resolve(); - let releaseNext!: () => void; - const nextQueue = new Promise((resolveQueue) => { - releaseNext = resolveQueue; + let releaseNext!: () => void; + const nextQueue = new Promise((resolveQueue) => { + releaseNext = resolveQueue; + }); + const chainedQueue = currentQueue.then(() => nextQueue); + fileMutationQueues.set(key, chainedQueue); + + return { key, currentQueue, chainedQueue, releaseNext }; }); - const chainedQueue = currentQueue.then(() => nextQueue); - fileMutationQueues.set(key, chainedQueue); + registrationQueue = registration.then( + () => undefined, + () => undefined, + ); + const { key, currentQueue, chainedQueue, releaseNext } = await registration; await currentQueue; try { return await fn(); diff --git a/packages/coding-agent/src/core/tools/find.ts b/packages/coding-agent/src/core/tools/find.ts index 3fd2f6832..5749b8131 100644 --- a/packages/coding-agent/src/core/tools/find.ts +++ b/packages/coding-agent/src/core/tools/find.ts @@ -1,8 +1,9 @@ +import { constants } from "node:fs"; +import { access as fsAccess } from "node:fs/promises"; import { createInterface } from "node:readline"; import type { AgentTool } from "@earendil-works/pi-agent-core"; import { Text } from "@earendil-works/pi-tui"; import { spawn } from "child_process"; -import { existsSync } from "fs"; import path from "path"; import { type Static, Type } from "typebox"; import { keyHint } from "../../modes/interactive/components/keybinding-hints.ts"; @@ -46,7 +47,14 @@ export interface FindOperations { } const defaultFindOperations: FindOperations = { - exists: existsSync, + exists: async (absolutePath) => { + try { + await fsAccess(absolutePath, constants.F_OK); + return true; + } catch { + return false; + } + }, // This is a placeholder. Actual fd execution happens in execute() when no custom glob is provided. glob: () => [], }; diff --git a/packages/coding-agent/src/core/tools/grep.ts b/packages/coding-agent/src/core/tools/grep.ts index 041b31481..010500df5 100644 --- a/packages/coding-agent/src/core/tools/grep.ts +++ b/packages/coding-agent/src/core/tools/grep.ts @@ -1,8 +1,8 @@ +import { readFile as fsReadFile, stat as fsStat } from "node:fs/promises"; import { createInterface } from "node:readline"; import type { AgentTool } from "@earendil-works/pi-agent-core"; import { Text } from "@earendil-works/pi-tui"; import { spawn } from "child_process"; -import { readFileSync, statSync } from "fs"; import path from "path"; import { type Static, Type } from "typebox"; import { keyHint } from "../../modes/interactive/components/keybinding-hints.ts"; @@ -55,8 +55,8 @@ export interface GrepOperations { } const defaultGrepOperations: GrepOperations = { - isDirectory: (p) => statSync(p).isDirectory(), - readFile: (p) => readFileSync(p, "utf-8"), + isDirectory: async (p) => (await fsStat(p)).isDirectory(), + readFile: (p) => fsReadFile(p, "utf-8"), }; export interface GrepToolOptions { diff --git a/packages/coding-agent/src/core/tools/ls.ts b/packages/coding-agent/src/core/tools/ls.ts index f3d22c1d9..46c8cc800 100644 --- a/packages/coding-agent/src/core/tools/ls.ts +++ b/packages/coding-agent/src/core/tools/ls.ts @@ -1,6 +1,7 @@ +import { constants } from "node:fs"; +import { access as fsAccess, readdir as fsReaddir, stat as fsStat } from "node:fs/promises"; import type { AgentTool } from "@earendil-works/pi-agent-core"; import { Text } from "@earendil-works/pi-tui"; -import { existsSync, readdirSync, statSync } from "fs"; import nodePath from "path"; import { type Static, Type } from "typebox"; import { keyHint } from "../../modes/interactive/components/keybinding-hints.ts"; @@ -38,9 +39,16 @@ export interface LsOperations { } const defaultLsOperations: LsOperations = { - exists: existsSync, - stat: statSync, - readdir: readdirSync, + exists: async (absolutePath) => { + try { + await fsAccess(absolutePath, constants.F_OK); + return true; + } catch { + return false; + } + }, + stat: fsStat, + readdir: fsReaddir, }; export interface LsToolOptions { diff --git a/packages/coding-agent/src/core/tools/path-utils.ts b/packages/coding-agent/src/core/tools/path-utils.ts index 1ed1e2bc9..065b9eaaa 100644 --- a/packages/coding-agent/src/core/tools/path-utils.ts +++ b/packages/coding-agent/src/core/tools/path-utils.ts @@ -1,4 +1,5 @@ import { accessSync, constants } from "node:fs"; +import { access } from "node:fs/promises"; import { normalizePath, resolvePath } from "../../utils/paths.ts"; const NARROW_NO_BREAK_SPACE = "\u202F"; @@ -27,6 +28,15 @@ function fileExists(filePath: string): boolean { } } +async function fileExistsAsync(filePath: string): Promise { + try { + await access(filePath, constants.F_OK); + return true; + } catch { + return false; + } +} + export function expandPath(filePath: string): string { return normalizePath(filePath, { normalizeUnicodeSpaces: true, stripAtPrefix: true }); } @@ -72,3 +82,37 @@ export function resolveReadPath(filePath: string, cwd: string): string { return resolved; } + +export async function resolveReadPathAsync(filePath: string, cwd: string): Promise { + const resolved = resolveToCwd(filePath, cwd); + + if (await fileExistsAsync(resolved)) { + return resolved; + } + + // Try macOS AM/PM variant (narrow no-break space before AM/PM) + const amPmVariant = tryMacOSScreenshotPath(resolved); + if (amPmVariant !== resolved && (await fileExistsAsync(amPmVariant))) { + return amPmVariant; + } + + // Try NFD variant (macOS stores filenames in NFD form) + const nfdVariant = tryNFDVariant(resolved); + if (nfdVariant !== resolved && (await fileExistsAsync(nfdVariant))) { + return nfdVariant; + } + + // Try curly quote variant (macOS uses U+2019 in screenshot names) + const curlyVariant = tryCurlyQuoteVariant(resolved); + if (curlyVariant !== resolved && (await fileExistsAsync(curlyVariant))) { + return curlyVariant; + } + + // Try combined NFD + curly quote (for French macOS screenshots like "Capture d'écran") + const nfdCurlyVariant = tryCurlyQuoteVariant(nfdVariant); + if (nfdCurlyVariant !== resolved && (await fileExistsAsync(nfdCurlyVariant))) { + return nfdCurlyVariant; + } + + return resolved; +} diff --git a/packages/coding-agent/src/core/tools/read.ts b/packages/coding-agent/src/core/tools/read.ts index cd3b41edc..f8d12d11c 100644 --- a/packages/coding-agent/src/core/tools/read.ts +++ b/packages/coding-agent/src/core/tools/read.ts @@ -12,7 +12,7 @@ import { formatDimensionNote, resizeImage } from "../../utils/image-resize.ts"; import { detectSupportedImageMimeTypeFromFile } from "../../utils/mime.ts"; import { formatPathRelativeToCwdOrAbsolute } from "../../utils/paths.ts"; import type { ToolDefinition, ToolRenderResultOptions } from "../extensions/types.ts"; -import { resolveReadPath } from "./path-utils.ts"; +import { resolveReadPathAsync, resolveToCwd } from "./path-utils.ts"; import { getTextOutput, invalidArgText, replaceTabs, shortenPath, str } from "./render-utils.ts"; import { wrapToolDefinition } from "./tool-definition-wrapper.ts"; import { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, formatSize, type TruncationResult, truncateHead } from "./truncate.ts"; @@ -124,7 +124,7 @@ function getCompactReadClassification( const rawPath = str(args?.file_path ?? args?.path); if (!rawPath) return undefined; - const absolutePath = resolveReadPath(rawPath, cwd); + const absolutePath = resolveToCwd(rawPath, cwd); const fileName = basename(absolutePath); if (fileName === "SKILL.md") { return { kind: "skill", label: basename(dirname(absolutePath)) || fileName }; @@ -223,7 +223,6 @@ export function createReadToolDefinition( _onUpdate?, ctx?, ) { - const absolutePath = resolveReadPath(path, cwd); return new Promise<{ content: (TextContent | ImageContent)[]; details: ReadToolDetails | undefined }>( (resolve, reject) => { if (signal?.aborted) { @@ -239,6 +238,8 @@ export function createReadToolDefinition( (async () => { try { + const absolutePath = await resolveReadPathAsync(path, cwd); + if (aborted) return; // Check if file exists and is readable. await ops.access(absolutePath); if (aborted) return; @@ -249,10 +250,9 @@ export function createReadToolDefinition( if (mimeType) { // Read image as binary. const buffer = await ops.readFile(absolutePath); - const base64 = buffer.toString("base64"); if (autoResizeImages) { // Resize image if needed before sending it back to the model. - const resized = await resizeImage({ type: "image", data: base64, mimeType }); + const resized = await resizeImage(buffer, mimeType); if (!resized) { let textNote = `Read image file [${mimeType}]\n[Image omitted: could not be resized below the inline image size limit.]`; if (nonVisionImageNote) textNote += `\n${nonVisionImageNote}`; @@ -272,7 +272,7 @@ export function createReadToolDefinition( if (nonVisionImageNote) textNote += `\n${nonVisionImageNote}`; content = [ { type: "text", text: textNote }, - { type: "image", data: base64, mimeType }, + { type: "image", data: buffer.toString("base64"), mimeType }, ]; } } else { diff --git a/packages/coding-agent/src/core/tools/write.ts b/packages/coding-agent/src/core/tools/write.ts index 5c07848e7..ba78649b3 100644 --- a/packages/coding-agent/src/core/tools/write.ts +++ b/packages/coding-agent/src/core/tools/write.ts @@ -200,44 +200,36 @@ export function createWriteToolDefinition( ) { const absolutePath = resolveToCwd(path, cwd); const dir = dirname(absolutePath); - return withFileMutationQueue( - absolutePath, - () => - new Promise<{ content: Array<{ type: "text"; text: string }>; details: undefined }>( - (resolve, reject) => { - if (signal?.aborted) { - reject(new Error("Operation aborted")); - return; - } - let aborted = false; - const onAbort = () => { - aborted = true; - reject(new Error("Operation aborted")); - }; - signal?.addEventListener("abort", onAbort, { once: true }); - (async () => { - try { - // Create parent directories if needed. - await ops.mkdir(dir); - if (aborted) return; - // Write the file contents. - await ops.writeFile(absolutePath, content); - if (aborted) return; - signal?.removeEventListener("abort", onAbort); - resolve({ - content: [ - { type: "text", text: `Successfully wrote ${content.length} bytes to ${path}` }, - ], - details: undefined, - }); - } catch (error: any) { - signal?.removeEventListener("abort", onAbort); - if (!aborted) reject(error); - } - })(); - }, - ), - ); + return withFileMutationQueue(absolutePath, async () => { + let aborted = signal?.aborted ?? false; + const onAbort = () => { + aborted = true; + }; + const throwIfAborted = (): void => { + if (aborted || signal?.aborted) { + throw new Error("Operation aborted"); + } + }; + + signal?.addEventListener("abort", onAbort, { once: true }); + try { + throwIfAborted(); + // Create parent directories if needed. + await ops.mkdir(dir); + throwIfAborted(); + + // Write the file contents. + await ops.writeFile(absolutePath, content); + throwIfAborted(); + + return { + content: [{ type: "text", text: `Successfully wrote ${content.length} bytes to ${path}` }], + details: undefined, + }; + } finally { + signal?.removeEventListener("abort", onAbort); + } + }); }, renderCall(args, theme, context) { const renderArgs = args as { path?: string; file_path?: string; content?: string } | undefined; diff --git a/packages/coding-agent/src/utils/image-resize-core.ts b/packages/coding-agent/src/utils/image-resize-core.ts new file mode 100644 index 000000000..e60820c7b --- /dev/null +++ b/packages/coding-agent/src/utils/image-resize-core.ts @@ -0,0 +1,164 @@ +import { applyExifOrientation } from "./exif-orientation.ts"; +import { loadPhoton } from "./photon.ts"; + +export interface ImageResizeOptions { + maxWidth?: number; // Default: 2000 + maxHeight?: number; // Default: 2000 + maxBytes?: number; // Default: 4.5MB of base64 payload (below Anthropic's 5MB limit) + jpegQuality?: number; // Default: 80 +} + +export interface ResizedImage { + data: string; // base64 + mimeType: string; + originalWidth: number; + originalHeight: number; + width: number; + height: number; + wasResized: boolean; +} + +// 4.5MB of base64 payload. Provides headroom below Anthropic's 5MB limit. +const DEFAULT_MAX_BYTES = 4.5 * 1024 * 1024; + +const DEFAULT_OPTIONS: Required = { + maxWidth: 2000, + maxHeight: 2000, + maxBytes: DEFAULT_MAX_BYTES, + jpegQuality: 80, +}; + +interface EncodedCandidate { + data: string; + encodedSize: number; + mimeType: string; +} + +function encodeCandidate(buffer: Uint8Array, mimeType: string): EncodedCandidate { + const data = Buffer.from(buffer).toString("base64"); + return { + data, + encodedSize: Buffer.byteLength(data, "utf-8"), + mimeType, + }; +} + +/** + * Resize an image to fit within the specified max dimensions and encoded file size. + * Returns null if the image cannot be resized below maxBytes. + * + * Uses Photon (Rust/WASM) for image processing. If Photon is not available, + * returns null. + * + * Strategy for staying under maxBytes: + * 1. First resize to maxWidth/maxHeight + * 2. Try both PNG and JPEG formats, pick the smaller one + * 3. If still too large, try JPEG with decreasing quality + * 4. If still too large, progressively reduce dimensions until 1x1 + */ +export async function resizeImageInProcess( + inputBytes: Uint8Array, + mimeType: string, + options?: ImageResizeOptions, +): Promise { + const opts = { ...DEFAULT_OPTIONS, ...options }; + const inputBase64Size = Math.ceil(inputBytes.byteLength / 3) * 4; + + const photon = await loadPhoton(); + if (!photon) { + return null; + } + + let image: ReturnType | undefined; + try { + const rawImage = photon.PhotonImage.new_from_byteslice(inputBytes); + image = applyExifOrientation(photon, rawImage, inputBytes); + if (image !== rawImage) rawImage.free(); + + const originalWidth = image.get_width(); + const originalHeight = image.get_height(); + const format = mimeType.split("/")[1] ?? "png"; + + // Check if already within all limits (dimensions AND encoded size) + if (originalWidth <= opts.maxWidth && originalHeight <= opts.maxHeight && inputBase64Size < opts.maxBytes) { + return { + data: Buffer.from(inputBytes).toString("base64"), + mimeType: mimeType || `image/${format}`, + originalWidth, + originalHeight, + width: originalWidth, + height: originalHeight, + wasResized: false, + }; + } + + // Calculate initial dimensions respecting max limits + let targetWidth = originalWidth; + let targetHeight = originalHeight; + + if (targetWidth > opts.maxWidth) { + targetHeight = Math.round((targetHeight * opts.maxWidth) / targetWidth); + targetWidth = opts.maxWidth; + } + if (targetHeight > opts.maxHeight) { + targetWidth = Math.round((targetWidth * opts.maxHeight) / targetHeight); + targetHeight = opts.maxHeight; + } + + function tryEncodings(width: number, height: number, jpegQualities: number[]): EncodedCandidate[] { + const resized = photon!.resize(image!, width, height, photon!.SamplingFilter.Lanczos3); + + try { + const candidates: EncodedCandidate[] = [encodeCandidate(resized.get_bytes(), "image/png")]; + for (const quality of jpegQualities) { + candidates.push(encodeCandidate(resized.get_bytes_jpeg(quality), "image/jpeg")); + } + return candidates; + } finally { + resized.free(); + } + } + + const qualitySteps = Array.from(new Set([opts.jpegQuality, 85, 70, 55, 40])); + let currentWidth = targetWidth; + let currentHeight = targetHeight; + + while (true) { + const candidates = tryEncodings(currentWidth, currentHeight, qualitySteps); + for (const candidate of candidates) { + if (candidate.encodedSize < opts.maxBytes) { + return { + data: candidate.data, + mimeType: candidate.mimeType, + originalWidth, + originalHeight, + width: currentWidth, + height: currentHeight, + wasResized: true, + }; + } + } + + if (currentWidth === 1 && currentHeight === 1) { + break; + } + + const nextWidth = currentWidth === 1 ? 1 : Math.max(1, Math.floor(currentWidth * 0.75)); + const nextHeight = currentHeight === 1 ? 1 : Math.max(1, Math.floor(currentHeight * 0.75)); + if (nextWidth === currentWidth && nextHeight === currentHeight) { + break; + } + + currentWidth = nextWidth; + currentHeight = nextHeight; + } + + return null; + } catch { + return null; + } finally { + if (image) { + image.free(); + } + } +} diff --git a/packages/coding-agent/src/utils/image-resize-worker.ts b/packages/coding-agent/src/utils/image-resize-worker.ts new file mode 100644 index 000000000..ee881b3d9 --- /dev/null +++ b/packages/coding-agent/src/utils/image-resize-worker.ts @@ -0,0 +1,42 @@ +import { parentPort } from "node:worker_threads"; +import { type ImageResizeOptions, type ResizedImage, resizeImageInProcess } from "./image-resize-core.ts"; + +interface ResizeImageWorkerRequest { + inputBytes: Uint8Array; + mimeType: string; + options?: ImageResizeOptions; +} + +interface ResizeImageWorkerResponse { + result?: ResizedImage | null; + error?: string; +} + +function isResizeImageWorkerRequest(value: unknown): value is ResizeImageWorkerRequest { + if (!value || typeof value !== "object") return false; + const record = value as Record; + return record.inputBytes instanceof Uint8Array && typeof record.mimeType === "string"; +} + +const port = parentPort; +if (!port) { + throw new Error("image resize worker requires parentPort"); +} + +port.once("message", (message: unknown) => { + void (async () => { + try { + if (!isResizeImageWorkerRequest(message)) { + throw new Error("Invalid image resize worker request"); + } + const result = await resizeImageInProcess(message.inputBytes, message.mimeType, message.options); + const response: ResizeImageWorkerResponse = { result }; + port.postMessage(response); + } catch (error) { + const response: ResizeImageWorkerResponse = { + error: error instanceof Error ? error.message : String(error), + }; + port.postMessage(response); + } + })(); +}); diff --git a/packages/coding-agent/src/utils/image-resize.ts b/packages/coding-agent/src/utils/image-resize.ts index 3eb17dae4..24a753973 100644 --- a/packages/coding-agent/src/utils/image-resize.ts +++ b/packages/coding-agent/src/utils/image-resize.ts @@ -1,165 +1,96 @@ -import type { ImageContent } from "@earendil-works/pi-ai"; -import { applyExifOrientation } from "./exif-orientation.ts"; -import { loadPhoton } from "./photon.ts"; +import { Worker } from "node:worker_threads"; +import type { ImageResizeOptions, ResizedImage } from "./image-resize-core.ts"; -export interface ImageResizeOptions { - maxWidth?: number; // Default: 2000 - maxHeight?: number; // Default: 2000 - maxBytes?: number; // Default: 4.5MB of base64 payload (below Anthropic's 5MB limit) - jpegQuality?: number; // Default: 80 +export type { ImageResizeOptions, ResizedImage } from "./image-resize-core.ts"; + +interface ResizeImageWorkerResponse { + result?: ResizedImage | null; + error?: string; } -export interface ResizedImage { - data: string; // base64 - mimeType: string; - originalWidth: number; - originalHeight: number; - width: number; - height: number; - wasResized: boolean; +function toTransferableBytes(input: Uint8Array): Uint8Array { + // Transfer detaches the buffer, so transfer a worker-owned copy and leave the + // caller's bytes intact. + return new Uint8Array(input); } -// 4.5MB of base64 payload. Provides headroom below Anthropic's 5MB limit. -const DEFAULT_MAX_BYTES = 4.5 * 1024 * 1024; - -const DEFAULT_OPTIONS: Required = { - maxWidth: 2000, - maxHeight: 2000, - maxBytes: DEFAULT_MAX_BYTES, - jpegQuality: 80, -}; - -interface EncodedCandidate { - data: string; - encodedSize: number; - mimeType: string; +function isResizeImageWorkerResponse(value: unknown): value is ResizeImageWorkerResponse { + return value !== null && typeof value === "object"; } -function encodeCandidate(buffer: Uint8Array, mimeType: string): EncodedCandidate { - const data = Buffer.from(buffer).toString("base64"); - return { - data, - encodedSize: Buffer.byteLength(data, "utf-8"), - mimeType, - }; +function createResizeWorker(): Worker { + const isTypeScriptRuntime = import.meta.url.endsWith(".ts"); + const workerUrl = new URL( + isTypeScriptRuntime ? "./image-resize-worker.ts" : "./image-resize-worker.js", + import.meta.url, + ); + return new Worker(workerUrl); +} + +async function resizeImageInWorker( + inputBytes: Uint8Array, + mimeType: string, + options?: ImageResizeOptions, +): Promise { + const worker = createResizeWorker(); + try { + const inputBytesForWorker = toTransferableBytes(inputBytes); + return await new Promise((resolve, reject) => { + let settled = false; + const settle = (result: ResizedImage | null): void => { + if (settled) return; + settled = true; + resolve(result); + }; + const fail = (error: Error): void => { + if (settled) return; + settled = true; + reject(error); + }; + + worker.once("message", (message: unknown) => { + if (!isResizeImageWorkerResponse(message)) { + fail(new Error("Invalid image resize worker response")); + return; + } + if (message.error) { + fail(new Error(message.error)); + return; + } + settle(message.result ?? null); + }); + worker.once("error", fail); + worker.once("exit", (code) => { + if (!settled) { + fail(new Error(`Image resize worker exited with code ${code}`)); + } + }); + worker.postMessage( + { + inputBytes: inputBytesForWorker, + mimeType, + options, + }, + [inputBytesForWorker.buffer], + ); + }); + } finally { + void worker.terminate().catch(() => undefined); + } } /** * Resize an image to fit within the specified max dimensions and encoded file size. - * Returns null if the image cannot be resized below maxBytes. - * - * Uses Photon (Rust/WASM) for image processing. If Photon is not available, - * returns null. - * - * Strategy for staying under maxBytes: - * 1. First resize to maxWidth/maxHeight - * 2. Try both PNG and JPEG formats, pick the smaller one - * 3. If still too large, try JPEG with decreasing quality - * 4. If still too large, progressively reduce dimensions until 1x1 + * Runs Photon in a worker thread so WASM decoding, resizing, and encoding do not + * block the TUI event loop. Worker failures are propagated instead of retried on + * the main thread. */ -export async function resizeImage(img: ImageContent, options?: ImageResizeOptions): Promise { - const opts = { ...DEFAULT_OPTIONS, ...options }; - const inputBuffer = Buffer.from(img.data, "base64"); - const inputBase64Size = Buffer.byteLength(img.data, "utf-8"); - - const photon = await loadPhoton(); - if (!photon) { - return null; - } - - let image: ReturnType | undefined; - try { - const inputBytes = new Uint8Array(inputBuffer); - const rawImage = photon.PhotonImage.new_from_byteslice(inputBytes); - image = applyExifOrientation(photon, rawImage, inputBytes); - if (image !== rawImage) rawImage.free(); - - const originalWidth = image.get_width(); - const originalHeight = image.get_height(); - const format = img.mimeType?.split("/")[1] ?? "png"; - - // Check if already within all limits (dimensions AND encoded size) - if (originalWidth <= opts.maxWidth && originalHeight <= opts.maxHeight && inputBase64Size < opts.maxBytes) { - return { - data: img.data, - mimeType: img.mimeType ?? `image/${format}`, - originalWidth, - originalHeight, - width: originalWidth, - height: originalHeight, - wasResized: false, - }; - } - - // Calculate initial dimensions respecting max limits - let targetWidth = originalWidth; - let targetHeight = originalHeight; - - if (targetWidth > opts.maxWidth) { - targetHeight = Math.round((targetHeight * opts.maxWidth) / targetWidth); - targetWidth = opts.maxWidth; - } - if (targetHeight > opts.maxHeight) { - targetWidth = Math.round((targetWidth * opts.maxHeight) / targetHeight); - targetHeight = opts.maxHeight; - } - - function tryEncodings(width: number, height: number, jpegQualities: number[]): EncodedCandidate[] { - const resized = photon!.resize(image!, width, height, photon!.SamplingFilter.Lanczos3); - - try { - const candidates: EncodedCandidate[] = [encodeCandidate(resized.get_bytes(), "image/png")]; - for (const quality of jpegQualities) { - candidates.push(encodeCandidate(resized.get_bytes_jpeg(quality), "image/jpeg")); - } - return candidates; - } finally { - resized.free(); - } - } - - const qualitySteps = Array.from(new Set([opts.jpegQuality, 85, 70, 55, 40])); - let currentWidth = targetWidth; - let currentHeight = targetHeight; - - while (true) { - const candidates = tryEncodings(currentWidth, currentHeight, qualitySteps); - for (const candidate of candidates) { - if (candidate.encodedSize < opts.maxBytes) { - return { - data: candidate.data, - mimeType: candidate.mimeType, - originalWidth, - originalHeight, - width: currentWidth, - height: currentHeight, - wasResized: true, - }; - } - } - - if (currentWidth === 1 && currentHeight === 1) { - break; - } - - const nextWidth = currentWidth === 1 ? 1 : Math.max(1, Math.floor(currentWidth * 0.75)); - const nextHeight = currentHeight === 1 ? 1 : Math.max(1, Math.floor(currentHeight * 0.75)); - if (nextWidth === currentWidth && nextHeight === currentHeight) { - break; - } - - currentWidth = nextWidth; - currentHeight = nextHeight; - } - - return null; - } catch { - return null; - } finally { - if (image) { - image.free(); - } - } +export async function resizeImage( + inputBytes: Uint8Array, + mimeType: string, + options?: ImageResizeOptions, +): Promise { + return resizeImageInWorker(inputBytes, mimeType, options); } /** diff --git a/packages/coding-agent/test/file-mutation-queue.test.ts b/packages/coding-agent/test/file-mutation-queue.test.ts index 73d20c6d4..8e13a45e6 100644 --- a/packages/coding-agent/test/file-mutation-queue.test.ts +++ b/packages/coding-agent/test/file-mutation-queue.test.ts @@ -10,6 +10,18 @@ function delay(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } +function createDeferred(): { promise: Promise; resolve: () => void } { + let resolve!: () => void; + const promise = new Promise((promiseResolve) => { + resolve = promiseResolve; + }); + return { promise, resolve }; +} + +async function resolvesWithin(promise: Promise, ms: number): Promise { + return Promise.race([promise.then(() => true), delay(ms).then(() => false)]); +} + const tempDirs: string[] = []; async function createTempDir(): Promise { @@ -160,4 +172,103 @@ describe("built-in edit and write tools", () => { const content = await readFile(filePath, "utf8"); expect(content).toBe("replacement\n"); }); + + it("keeps write queue locked while an aborted write is still in flight", async () => { + const dir = await createTempDir(); + const filePath = join(dir, "abort-write.txt"); + const firstWriteStarted = createDeferred(); + const finishFirstWrite = createDeferred(); + const secondWriteStarted = createDeferred(); + let firstWriteSettled = false; + + const writeTool = createWriteTool(dir, { + operations: { + mkdir: async () => {}, + writeFile: async (path, content) => { + if (content === "first\n") { + firstWriteStarted.resolve(); + await finishFirstWrite.promise; + await writeFile(path, content, "utf8"); + firstWriteSettled = true; + return; + } + + if (content === "second\n") { + expect(firstWriteSettled).toBe(true); + secondWriteStarted.resolve(); + } + await writeFile(path, content, "utf8"); + }, + }, + }); + + const controller = new AbortController(); + const firstWrite = writeTool.execute("call-1", { path: filePath, content: "first\n" }, controller.signal); + await firstWriteStarted.promise; + controller.abort(); + + const secondWrite = writeTool.execute("call-2", { path: filePath, content: "second\n" }); + expect(await resolvesWithin(secondWriteStarted.promise, 20)).toBe(false); + + finishFirstWrite.resolve(); + await expect(firstWrite).rejects.toThrow("Operation aborted"); + await secondWrite; + + const content = await readFile(filePath, "utf8"); + expect(content).toBe("second\n"); + }); + + it("keeps edit queue locked while an aborted edit write is still in flight", async () => { + const dir = await createTempDir(); + const filePath = join(dir, "abort-edit.txt"); + await writeFile(filePath, "alpha\nbeta\n", "utf8"); + const firstWriteStarted = createDeferred(); + const finishFirstWrite = createDeferred(); + const secondWriteStarted = createDeferred(); + let firstWriteSettled = false; + + const editTool = createEditTool(dir, { + operations: { + access, + readFile, + writeFile: async (path, content) => { + if (content === "ALPHA\nbeta\n") { + firstWriteStarted.resolve(); + await finishFirstWrite.promise; + await writeFile(path, content, "utf8"); + firstWriteSettled = true; + return; + } + + if (content === "ALPHA\nBETA\n" || content === "alpha\nBETA\n") { + expect(firstWriteSettled).toBe(true); + secondWriteStarted.resolve(); + } + await writeFile(path, content, "utf8"); + }, + }, + }); + + const controller = new AbortController(); + const firstEdit = editTool.execute( + "call-1", + { path: filePath, edits: [{ oldText: "alpha", newText: "ALPHA" }] }, + controller.signal, + ); + await firstWriteStarted.promise; + controller.abort(); + + const secondEdit = editTool.execute("call-2", { + path: filePath, + edits: [{ oldText: "beta", newText: "BETA" }], + }); + expect(await resolvesWithin(secondWriteStarted.promise, 20)).toBe(false); + + finishFirstWrite.resolve(); + await expect(firstEdit).rejects.toThrow("Operation aborted"); + await secondEdit; + + const content = await readFile(filePath, "utf8"); + expect(content).toBe("ALPHA\nBETA\n"); + }); }); diff --git a/packages/coding-agent/test/image-processing.test.ts b/packages/coding-agent/test/image-processing.test.ts index 40f264a83..13d39dc6b 100644 --- a/packages/coding-agent/test/image-processing.test.ts +++ b/packages/coding-agent/test/image-processing.test.ts @@ -22,6 +22,10 @@ const MEDIUM_PNG_100x100 = const LARGE_PNG_200x200 = "iVBORw0KGgoAAAANSUhEUgAAAMgAAADIEAIAAAByquWKAAAAIGNIUk0AAHomAACAhAAA+gAAAIDoAAB1MAAA6mAAADqYAAAXcJy6UTwAAAAGYktHRP///////wlY99wAAAAHdElNRQfqAQ4AMzqU03BFAAAEEElEQVR42u3dvWqUURSF4XXMRAyIhSAWQvwnWtjaWusFx9bW1kKD/wELESxEiBjDjJdwysOC57mCXW32+zGcGbtdACpsdi+T8Xz1GABzm91xMl6sHgNgblzcT/Y+rh4DYG6cv0/2H64eA2BOEgI1fHQHaoyzbXIwVo8BMCcJgRqSEKgxft9Lrn5aPQbA3Ph1klw7Wj0GwJwkBGpYWECN8fMiub63egyAORcWUMPCAmqMH3eTG59XjwEwN76/S24+Wj0GwJwkBGpYWECN8e1fcmuzegyAORcWUMPCAmqM09vJ4dfVYwDMjS9vkzuPV48BMCcJgRqeSAZqjA/nyYP91WMAzElCoIYkBGqMk8Pk6HT1GABzm+3L5JIkBApIQqCGj+5AjfHmb/Lk8uoxAOYkIVBDEgI1LCygxmZ7nFyShEABFxZQw8ICaozXf5KnV1aPATDnwgJqWFhADQsLqOFnDUANFxZQw8ICaoxXZ8mzg9VjAMy5sIAaFhZQw3tYQA1/QgHUkIRADUkI1JCEQA1JCNSQhEANFxZQwzcsoIYkBGpIQqCGJARqSEKghiQEalhYQA1/QgHUcGEBNSwsoIYkBGq4sIAaFhZQw8ICaviGBdRwYQE1LCyghiQEariwgBoWFlDDe1hADS+OAjUkIVBDEgI1JCFQQxICNSQhUMOFBdTwDQuoIQmBGpIQqCEJgRqSEKghCYEaFhZQw4ujQA0XFlDDwgJqSEKghgsLqGFhATUsLKCGb1hADRcWUMPCAmpIQqCGCwuoYWEBNbyHBdTw4ihQQxICNSQhUEMSAjUkIVBDEgI1XFhADd+wgBqSEKghCYEakhCoIQmBGpIQqGFhATW8OArUcGEBNSwsoIYkBGq4sIAaFhZQw8ICaviGBdRwYQE1LCyghiQEariwgBoWFlDDe1hADS+OAjUkIVBDEgI1JCFQQxICNSQhUMOFBdTwDQuoIQmBGpIQqCEJgRqSEKghCYEa49lB8ups9RgAc14cBWpIQqCGhQXUkIRADRcWUMPCAmqMp1eS139WjwEw5xsWUEMSAjUsLKCGJARquLCAGhYWUGM8uZy8+bt6DIA5L44CNSQhUMMTyUCNcXSanByuHgNgThICNSQhUGM82E8+nK8eA2DOzxqAGpIQqDHuPE6+vF09BsDcOPyanN5ePQbAnCQEavgdFlBj3Nok3/6tHgNgzoujQA1JCNQYNx8l39+tHgNgbtz4nPy4u3oMgDlJCNSwsIAa4/pe8vNi9RgAc37WANSQhECNce0o+XWyegyAuXH1U/L73uoxAOYkIVDDwgJqjIORnG1XjwEw508ogBqSEKgx9h8m5+9XjwEwN/Y+Jhf3V48BMCcJgRpjPE+2x6vHAJgbSbLbrR4DYO4/GqiSgXN+ksgAAAAldEVYdGRhdGU6Y3JlYXRlADIwMjYtMDEtMTRUMDA6NTE6NTcrMDA6MDDpysx4AAAAJXRFWHRkYXRlOm1vZGlmeQAyMDI2LTAxLTE0VDAwOjUxOjU3KzAwOjAwmJd0xAAAACh0RVh0ZGF0ZTp0aW1lc3RhbXAAMjAyNi0wMS0xNFQwMDo1MTo1NyswMDowMM+CVRsAAAAASUVORK5CYII="; +function imageBytes(base64Data: string): Uint8Array { + return Buffer.from(base64Data, "base64"); +} + describe("convertToPng", () => { it("should return original data for PNG input", async () => { const result = await convertToPng(TINY_PNG, "image/png"); @@ -46,11 +50,28 @@ describe("convertToPng", () => { }); describe("resizeImage", () => { + it("should keep caller input bytes intact", async () => { + const input = new Uint8Array(imageBytes(TINY_PNG)); + const originalByteLength = input.byteLength; + const originalFirstByte = input[0]; + + const result = await resizeImage(input, "image/png", { + maxWidth: 100, + maxHeight: 100, + maxBytes: 1024 * 1024, + }); + + expect(result).not.toBeNull(); + expect(input.byteLength).toBe(originalByteLength); + expect(input[0]).toBe(originalFirstByte); + }); + it("should return original image if within limits", async () => { - const result = await resizeImage( - { type: "image", data: TINY_PNG, mimeType: "image/png" }, - { maxWidth: 100, maxHeight: 100, maxBytes: 1024 * 1024 }, - ); + const result = await resizeImage(imageBytes(TINY_PNG), "image/png", { + maxWidth: 100, + maxHeight: 100, + maxBytes: 1024 * 1024, + }); expect(result).not.toBeNull(); expect(result!.wasResized).toBe(false); @@ -62,10 +83,11 @@ describe("resizeImage", () => { }); it("should resize image exceeding dimension limits", async () => { - const result = await resizeImage( - { type: "image", data: MEDIUM_PNG_100x100, mimeType: "image/png" }, - { maxWidth: 50, maxHeight: 50, maxBytes: 1024 * 1024 }, - ); + const result = await resizeImage(imageBytes(MEDIUM_PNG_100x100), "image/png", { + maxWidth: 50, + maxHeight: 50, + maxBytes: 1024 * 1024, + }); expect(result).not.toBeNull(); expect(result!.wasResized).toBe(true); @@ -80,10 +102,11 @@ describe("resizeImage", () => { const originalSize = originalBuffer.length; // Set maxBytes to less than the original encoded image size - const result = await resizeImage( - { type: "image", data: LARGE_PNG_200x200, mimeType: "image/png" }, - { maxWidth: 2000, maxHeight: 2000, maxBytes: Math.floor(LARGE_PNG_200x200.length * 0.9) }, - ); + const result = await resizeImage(imageBytes(LARGE_PNG_200x200), "image/png", { + maxWidth: 2000, + maxHeight: 2000, + maxBytes: Math.floor(LARGE_PNG_200x200.length * 0.9), + }); // Should have tried to reduce size expect(result).not.toBeNull(); @@ -93,19 +116,21 @@ describe("resizeImage", () => { }); it("should return null when image cannot be resized below maxBytes", async () => { - const result = await resizeImage( - { type: "image", data: LARGE_PNG_200x200, mimeType: "image/png" }, - { maxWidth: 2000, maxHeight: 2000, maxBytes: 1 }, - ); + const result = await resizeImage(imageBytes(LARGE_PNG_200x200), "image/png", { + maxWidth: 2000, + maxHeight: 2000, + maxBytes: 1, + }); expect(result).toBeNull(); }); it("should handle JPEG input", async () => { - const result = await resizeImage( - { type: "image", data: TINY_JPEG, mimeType: "image/jpeg" }, - { maxWidth: 100, maxHeight: 100, maxBytes: 1024 * 1024 }, - ); + const result = await resizeImage(imageBytes(TINY_JPEG), "image/jpeg", { + maxWidth: 100, + maxHeight: 100, + maxBytes: 1024 * 1024, + }); expect(result).not.toBeNull(); expect(result!.wasResized).toBe(false); diff --git a/scripts/build-binaries.sh b/scripts/build-binaries.sh index c1b7b8687..dfc347933 100755 --- a/scripts/build-binaries.sh +++ b/scripts/build-binaries.sh @@ -105,10 +105,13 @@ fi for platform in "${PLATFORMS[@]}"; do echo "Building for $platform..." + # Bun compiled executables only embed worker scripts when they are passed as + # explicit build entrypoints. The runtime can still use new URL(...), but the + # worker must be present in the compiled executable. if [[ "$platform" == windows-* ]]; then - bun build --compile --target=bun-$platform ./dist/bun/cli.js --outfile binaries/$platform/pi.exe + bun build --compile --target=bun-$platform ./dist/bun/cli.js ./dist/utils/image-resize-worker.js --outfile binaries/$platform/pi.exe else - bun build --compile --target=bun-$platform ./dist/bun/cli.js --outfile binaries/$platform/pi + bun build --compile --target=bun-$platform ./dist/bun/cli.js ./dist/utils/image-resize-worker.js --outfile binaries/$platform/pi fi done