Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -31,6 +32,91 @@ type StreamArtifact = (
writeChunk: (chunk: Uint8Array) => Promise<void>,
) => Promise<number>;

// 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<string, Handler>();
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<typeof rename>) => {
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<typeof open>) => {
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<void>) {
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<string, unknown> = {}): Record<string, unknown> {
return {
id: "artifact-1",
Expand Down
35 changes: 30 additions & 5 deletions apps/desktop/src/main/runtime-host-artifacts-ipc-main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" };
}
},
);
Expand Down Expand Up @@ -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(
Expand All @@ -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";
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
8 changes: 4 additions & 4 deletions apps/desktop/src/renderer/locales/artifact-copy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down Expand Up @@ -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: {
Expand Down Expand Up @@ -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: {
Expand Down Expand Up @@ -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: {
Expand Down
11 changes: 10 additions & 1 deletion packages/core/src/artifacts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down