diff --git a/apps/desktop/src/main/__tests__/runtime-host-artifacts-ipc-main.test.ts b/apps/desktop/src/main/__tests__/runtime-host-artifacts-ipc-main.test.ts index 283d33f342..436f3aa40b 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-artifacts-ipc-main.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-artifacts-ipc-main.test.ts @@ -18,7 +18,8 @@ */ import assert from "node:assert/strict"; -import { mkdtemp, readFile, rm } from "node:fs/promises"; +import fs, { mkdir, mkdtemp, readFile, readdir, rm, writeFile } from "node:fs/promises"; +import { syncBuiltinESMExports } from "node:module"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { test } from "node:test"; @@ -31,6 +32,91 @@ type StreamArtifact = ( writeChunk: (chunk: Uint8Array) => Promise, ) => Promise; +// Exercise the public Save As result and destination bytes together. Faults +// use real temporary files; only the failing filesystem operation is mocked. +for (const [fault, reason] of [ + ["none", null], + ["stream", "source_failed"], + ["total", "size_mismatch"], + ["length", "size_mismatch"], + ["rename", "replace_failed"], + ["directory", "replace_failed"], + ["write", "target_write_failed"], + ["sync", "target_write_failed"], + ["close", "target_write_failed"], +] as const) { + test(`Save As preserves the destination and reports ${fault} correctly`, async (t) => { + const root = await mkdtemp(join(tmpdir(), "maka-save-artifact-")); + const target = join(root, "report.txt"); + const originalPath = fault === "directory" ? join(target, "original.txt") : target; + const content = Buffer.from("NEW CONTENT\n中文内容测试:你好,世界。\n"); + const handlers = new Map(); + const injectedError = () => Object.assign(new Error("Injected destination failure"), { code: "EIO" }); + let injectedCalls = 0; + try { + if (fault === "directory") await mkdir(target); + await writeFile(originalPath, "ORIGINAL"); + if (fault === "rename") { + const rename = fs.rename; + t.mock.method(fs, "rename", async (...args: Parameters) => { + if (args[1] === target) { + injectedCalls += 1; + throw injectedError(); + } + return rename(...args); + }); + } + if (fault === "write" || fault === "sync" || fault === "close") { + const open = fs.open; + t.mock.method(fs, "open", async (...args: Parameters) => { + const handle = await open(...args); + t.mock.method(handle, fault, async () => { + injectedCalls += 1; + throw injectedError(); + }, { times: 1 }); + return handle; + }); + } + syncBuiltinESMExports(); + registerRuntimeHostArtifactsIpc({ + ipcMain: { handle: (channel, handler) => handlers.set(channel, handler as Handler) }, + client: { + hostEpoch: "host-1", + async getArtifact() { + return previewArtifact({ name: "report.txt", kind: "file", mimeType: "text/plain", sizeBytes: content.length }); + }, + async streamArtifact(_sessionId: string, _artifactId: string, writeChunk: (chunk: Uint8Array) => Promise) { + await writeChunk(content.subarray(0, 4)); + if (fault === "stream") throw new Error("Interrupted source stream"); + if (fault !== "length") await writeChunk(content.subarray(4)); + return content.length + (fault === "total" ? 1 : 0); + }, + } as never, + mainWindowController: { + showSaveDialog: async () => ({ canceled: false, filePath: target }), + } as never, + showItemInFolder() {}, + }); + const save = handlers.get("app:saveArtifactAs"); + assert.ok(save); + const result = await save({}, "session-1", "artifact-1"); + if (fault === "directory") { + assert.deepEqual(await readdir(target), ["original.txt"]); + assert.equal(await readFile(originalPath, "utf8"), "ORIGINAL"); + } else { + assert.equal(await readFile(originalPath, "utf8"), reason ? "ORIGINAL" : content.toString()); + } + assert.deepEqual(result, reason ? { ok: false, reason } : { ok: true, saved: "report.txt" }); + assert.deepEqual(await readdir(root), ["report.txt"], "no staging or backup remains"); + if (["rename", "write", "sync", "close"].includes(fault)) assert.equal(injectedCalls, 1); + } finally { + t.mock.restoreAll(); + syncBuiltinESMExports(); + await rm(root, { recursive: true, force: true }); + } + }); +} + function previewArtifact(overrides: Record = {}): Record { return { id: "artifact-1", diff --git a/apps/desktop/src/main/runtime-host-artifacts-ipc-main.ts b/apps/desktop/src/main/runtime-host-artifacts-ipc-main.ts index f5c3b7200b..32c22cb991 100644 --- a/apps/desktop/src/main/runtime-host-artifacts-ipc-main.ts +++ b/apps/desktop/src/main/runtime-host-artifacts-ipc-main.ts @@ -126,8 +126,9 @@ export function registerRuntimeHostArtifactsIpc( artifact.sizeBytes, ); return { ok: true, saved: artifact.name }; - } catch { - return { ok: false, reason: "write_failed" }; + } catch (error) { + if (error instanceof ArtifactMaterializationError) return { ok: false, reason: error.reason }; + return { ok: false, reason: "target_write_failed" }; } }, ); @@ -196,11 +197,14 @@ async function materializeArtifact( ); const handle = await open(stagingPath, "wx"); let offset = 0; + let writingStaging = false; + let streamCompleted = false; try { const totalBytes = await client.streamArtifact( sessionId, artifactId, async (chunk) => { + writingStaging = true; let written = 0; while (written < chunk.byteLength) { const result = await handle.write( @@ -213,18 +217,39 @@ async function materializeArtifact( written += result.bytesWritten; } offset += written; + writingStaging = false; }, ); + streamCompleted = true; if (totalBytes !== expectedBytes || offset !== expectedBytes) { - throw new Error("Artifact size changed during export"); + throw new ArtifactMaterializationError("size_mismatch"); } await handle.sync(); await handle.close(); - await rm(targetPath, { force: true }); - await rename(stagingPath, targetPath); + try { + await rename(stagingPath, targetPath); + } catch (error) { + throw new ArtifactMaterializationError("replace_failed", error); + } } catch (error) { await handle.close().catch(() => undefined); await rm(stagingPath, { force: true }).catch(() => undefined); + if (!(error instanceof ArtifactMaterializationError)) { + throw new ArtifactMaterializationError( + writingStaging || streamCompleted ? "target_write_failed" : "source_failed", + error, + ); + } throw error; } } + +class ArtifactMaterializationError extends Error { + constructor( + readonly reason: "source_failed" | "size_mismatch" | "target_write_failed" | "replace_failed", + cause?: unknown, + ) { + super(`Artifact materialization failed: ${reason}`, { cause }); + this.name = "ArtifactMaterializationError"; + } +} diff --git a/apps/desktop/src/renderer/features/workbar/tools/artifacts/artifact-pane.tsx b/apps/desktop/src/renderer/features/workbar/tools/artifacts/artifact-pane.tsx index b7c27cb40c..135bc78349 100644 --- a/apps/desktop/src/renderer/features/workbar/tools/artifacts/artifact-pane.tsx +++ b/apps/desktop/src/renderer/features/workbar/tools/artifacts/artifact-pane.tsx @@ -606,7 +606,16 @@ function saveArtifactFailureCopy(reason: string, copy: ArtifactCopy): string { case 'not_allowed': return copy.pane.saveFailures.not_allowed; case 'write_failed': - return copy.pane.saveFailures.write_failed; + case 'target_write_failed': + return copy.pane.saveFailures.target_write_failed; + case 'deleted': + return copy.pane.saveFailures.deleted; + case 'source_failed': + return copy.pane.saveFailures.source_failed; + case 'size_mismatch': + return copy.pane.saveFailures.size_mismatch; + case 'replace_failed': + return copy.pane.saveFailures.replace_failed; default: return copy.pane.saveFailures.default; } diff --git a/apps/desktop/src/renderer/locales/artifact-copy.ts b/apps/desktop/src/renderer/locales/artifact-copy.ts index 7f83467fd7..7c63bb5575 100644 --- a/apps/desktop/src/renderer/locales/artifact-copy.ts +++ b/apps/desktop/src/renderer/locales/artifact-copy.ts @@ -50,7 +50,7 @@ export type ArtifactCopy = { openInFinder: string; saveAs: string; copy: string; - saveFailures: Record<'not_found' | 'not_allowed' | 'write_failed' | 'default', string>; + saveFailures: Record<'not_found' | 'not_allowed' | 'write_failed' | 'deleted' | 'source_failed' | 'size_mismatch' | 'target_write_failed' | 'replace_failed' | 'default', string>; actionFailed: string; }; preview: { @@ -99,7 +99,7 @@ const ARTIFACT_COPY = { previewNamed: (name) => `预览 ${name}`, empty: '暂无生成文件', emptyHint: '助手生成文件后会显示在这里。', back: '返回生成文件列表', moreActions: (name) => `${name} 的更多操作`, openInFinder: '在 Finder 中打开', saveAs: '另存为', copy: '复制', - saveFailures: { not_found: '生成文件不存在。', not_allowed: '生成文件路径检查未通过。', write_failed: '目标位置无法写入。', default: '无法保存生成文件。' }, + saveFailures: { not_found: '生成文件不存在。', not_allowed: '生成文件路径检查未通过。', write_failed: '目标位置无法写入。', deleted: '生成文件已删除,不能另存。', source_failed: '生成文件传输中断,请重试。', size_mismatch: '生成文件大小在传输过程中发生变化,请重试。', target_write_failed: '目标位置无法写入。', replace_failed: '替换目标文件失败,原文件已保留。', default: '无法保存生成文件。' }, actionFailed: '生成文件操作失败,请稍后重试。', }, preview: { @@ -134,7 +134,7 @@ const ARTIFACT_COPY = { previewNamed: (name) => `預覽 ${name}`, empty: '暫無生成檔案', emptyHint: '助手生成檔案後會顯示在這裡。', back: '返回生成檔案列表', moreActions: (name) => `${name} 的更多操作`, openInFinder: '在 Finder 中開啟', saveAs: '另存為', copy: '複製', - saveFailures: { not_found: '生成檔案不存在。', not_allowed: '生成檔案路徑檢查未透過。', write_failed: '目標位置無法寫入。', default: '無法儲存生成檔案。' }, + saveFailures: { not_found: '生成檔案不存在。', not_allowed: '生成檔案路徑檢查未透過。', write_failed: '目標位置無法寫入。', deleted: '生成檔案已刪除,不能另存。', source_failed: '生成檔案傳輸中斷,請重試。', size_mismatch: '生成檔案大小在傳輸過程中發生變化,請重試。', target_write_failed: '目標位置無法寫入。', replace_failed: '替換目標檔案失敗,原檔案已保留。', default: '無法儲存生成檔案。' }, actionFailed: '生成檔案操作失敗,請稍後重試。', }, preview: { @@ -169,7 +169,7 @@ const ARTIFACT_COPY = { previewNamed: (name) => `Preview ${name}`, empty: 'No generated files', emptyHint: 'Files generated by the assistant appear here.', back: 'Back to generated files', moreActions: (name) => `More actions for ${name}`, openInFinder: 'Show in Finder', saveAs: 'Save as', copy: 'Copy', - saveFailures: { not_found: 'The generated file does not exist.', not_allowed: 'The generated file failed the path safety check.', write_failed: 'The destination is not writable.', default: 'Could not save the generated file.' }, + saveFailures: { not_found: 'The generated file does not exist.', not_allowed: 'The generated file failed the path safety check.', write_failed: 'The destination is not writable.', deleted: 'Deleted generated files cannot be saved.', source_failed: 'The generated file transfer was interrupted. Try again.', size_mismatch: 'The generated file size changed during transfer. Try again.', target_write_failed: 'The destination is not writable.', replace_failed: 'Could not replace the destination. The original file was kept.', default: 'Could not save the generated file.' }, actionFailed: 'The generated file action failed. Try again later.', }, preview: { diff --git a/packages/core/src/artifacts.ts b/packages/core/src/artifacts.ts index 48c058ca76..43921173bc 100644 --- a/packages/core/src/artifacts.ts +++ b/packages/core/src/artifacts.ts @@ -193,7 +193,16 @@ export type ArtifactBinaryReadResult = | { ok: true; base64: string; mimeType: string } | { ok: false; reason: ArtifactBinaryReadFailureReason }; -export type ArtifactSaveFailureReason = 'canceled' | 'not_found' | 'not_allowed' | 'write_failed'; +export type ArtifactSaveFailureReason = + | 'canceled' + | 'not_found' + | 'not_allowed' + | 'write_failed' + | 'deleted' + | 'source_failed' + | 'size_mismatch' + | 'target_write_failed' + | 'replace_failed'; export type ArtifactSaveResult = | { ok: true; saved: string }