From 5eff12604c5221c4c4e506e32dc8ff0d1a24b249 Mon Sep 17 00:00:00 2001 From: liugddx Date: Sun, 6 Sep 2026 12:25:05 +0800 Subject: [PATCH 1/2] fix: rebase artifact export fix after storybook merge Generated-by: OpenAI Codex --- .../runtime-host-artifacts-ipc-main.test.ts | 89 ++++++++++++++++++- .../main/runtime-host-artifacts-ipc-main.ts | 35 ++++++-- .../workbar/tools/artifacts/artifact-pane.tsx | 75 ++++++++++------ .../src/renderer/locales/artifact-copy.ts | 32 ++++--- packages/core/src/artifacts.ts | 67 ++++++++++---- 5 files changed, 235 insertions(+), 63 deletions(-) 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..002351d9ed 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,92 @@ 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, + sendToRenderer() {}, + 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..4f8bb22a68 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 @@ -60,11 +60,12 @@ import { Copy, Trash2, } from '@maka/ui/icons'; -import { canUserDeleteArtifact, type ArtifactDescriptor, type ArtifactKind } from '@maka/core/artifacts'; +import type { ArtifactDescriptor, ArtifactKind } from '@maka/core/artifacts'; import type { UiLocale } from '@maka/core/ui-locale'; import { formatRelativeTimestamp } from '@maka/core/relative-time'; import { generalizedErrorMessageForLocale, redactSecrets } from '@maka/core/redaction'; import { + Badge, Banner, Button, MoreMenu, @@ -83,7 +84,6 @@ import { useWorkbarServices } from '../../services-context.js'; export function ArtifactPane(props: { sessionId: string; - refreshEnabled: boolean; onCountChange?: (count: number) => void; onDismiss?: () => void; }) { @@ -133,7 +133,7 @@ export function ArtifactPane(props: { setSelectedId(null); }, [sessionId]); - const refresh = useCallback(async (showFailureToast = true) => { + const refresh = useCallback(async () => { const requestSeq = ++artifactListRequestSeqRef.current; if (!sessionId) { recordsSessionIdRef.current = undefined; @@ -143,7 +143,9 @@ export function ArtifactPane(props: { return; } try { - const next = await artifacts.list(sessionId); + const next = await artifacts.list(sessionId, { + includeDeleted: true, + }); if (artifactPaneMountedRef.current && requestSeq === artifactListRequestSeqRef.current) { recordsSessionIdRef.current = sessionId; setRecordsSessionId(sessionId); @@ -158,7 +160,7 @@ export function ArtifactPane(props: { recordsSessionIdRef.current = undefined; setRecordsSessionId(undefined); setRecords([]); - } else if (showFailureToast) { + } else { toast.error(copy.pane.refreshFailed, message, undefined, { sessionId }); } } @@ -166,22 +168,22 @@ export function ArtifactPane(props: { }, [artifacts, copy, locale, sessionId, toast]); useEffect(() => { - if (!props.refreshEnabled) return; - let stopped = false; - let timer: ReturnType | undefined; - // Writeback can commit after the terminal Session event. Read the existing - // catalog while the workbar is visible, including its background file tab's count. - const poll = async () => { - await refresh(false); - if (!stopped) timer = setTimeout(() => void poll(), 2_000); - }; - void poll(); + void refresh(); + if (!sessionId) return; + // Keep the list in sync without polling. The + // backend emits `{ reason: 'created' | 'deleted' | 'purged' }` on the + // `artifacts:changed` channel; we just re-list since the list is bounded + // (one session's worth) and the metadata is already in memory on main. + const unsubscribe = artifacts.subscribeChanges((event) => { + if (event.sessionId === sessionId) { + void refresh(); + } + }); return () => { - stopped = true; - clearTimeout(timer); artifactListRequestSeqRef.current += 1; + unsubscribe(); }; - }, [props.refreshEnabled, sessionId, refresh]); + }, [artifacts, sessionId, refresh]); const activeRecords = useMemo( () => (recordsSessionId === sessionId ? filterUserVisibleArtifacts(records) : []), @@ -192,6 +194,7 @@ export function ArtifactPane(props: { props.onCountChange?.(activeRecords.length); }, [activeRecords.length, props.onCountChange]); + // 已删除墓碑记录保持可选,用于展示明确失败态;只有选中 id 彻底消失时才回退到最新 live artifact。 useEffect(() => { if (activeRecords.length === 0) { if (selectedId !== null) setSelectedId(null); @@ -353,7 +356,6 @@ export function ArtifactPane(props: { async function deleteArtifact(artifactId: string) { const actionSessionId = sessionId; const record = activeRecords.find((entry) => entry.id === artifactId); - if (!record || !canUserDeleteArtifact(record)) return; const name = record?.name ?? copy.pane.fallbackName; const ok = await toast.confirm({ title: copy.pane.deleteTitle(name), @@ -492,6 +494,7 @@ export function ArtifactPane(props: { // ArrowUp/Down. tabIndex={-1} data-selected={record.id === selectedId ? 'true' : 'false'} + data-deleted={record.status === 'deleted' ? 'true' : 'false'} onClick={() => openPreview(record.id)} label={record.name} icon={( @@ -505,6 +508,9 @@ export function ArtifactPane(props: { {formatRelativeTimestamp(record.createdAt, Date.now(), locale)} + {record.status === 'deleted' && ( + + )} )} /> @@ -560,14 +566,22 @@ export function ArtifactPane(props: { onClick: () => void runArtifactAction(`${previewRecord.id}:copy`, () => copyText(previewRecord.id)), }] : []), - ...(canUserDeleteArtifact(previewRecord) ? [{ type: 'divider' as const }, { - label: copy.pane.delete, + { type: 'divider' as const }, + { + label: + previewRecord.source === 'deep_research' || + previewRecord.source === 'tool_result_archive' + ? copy.pane.deleteReadOnly + : copy.pane.delete, icon: