diff --git a/scripts/verify-attachment-storage-model.mjs b/scripts/verify-attachment-storage-model.mjs new file mode 100644 index 0000000..b505089 --- /dev/null +++ b/scripts/verify-attachment-storage-model.mjs @@ -0,0 +1,80 @@ +#!/usr/bin/env node +import { mkdtemp, copyFile } from "node:fs/promises"; +import { existsSync } from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { createRequire } from "node:module"; + +const rootDir = process.cwd(); +const scriptDir = path.dirname(fileURLToPath(import.meta.url)); +const require = createRequire(import.meta.url); +const jiti = require("jiti")(fileURLToPath(import.meta.url), { + alias: { + "@/": `${path.join(rootDir, "src")}/`, + }, +}); + +const tempDir = await mkdtemp(path.join(os.tmpdir(), "boss-attachment-model-")); +const tempStateFile = path.join(tempDir, "boss-state.json"); +const sourceStateFile = path.join(rootDir, "data", "boss-state.json"); + +if (!existsSync(sourceStateFile)) { + throw new Error(`Missing state file: ${sourceStateFile}`); +} + +await copyFile(sourceStateFile, tempStateFile); +process.env.BOSS_STATE_FILE = tempStateFile; +process.env.BOSS_RUNTIME_ROOT = rootDir; + +const { getAttachmentStorageConfig, readState, writeState } = jiti(path.join(scriptDir, "..", "src", "lib", "boss-data.ts")); + +const config = await getAttachmentStorageConfig("17600003315"); +if (config.mode !== "server_file") { + throw new Error(`Expected default storage mode server_file, got ${config.mode}`); +} +if (!config.updatedAt || typeof config.updatedAt !== "string") { + throw new Error("Expected updatedAt to be populated"); +} + +const state = await readState(); +const messageId = "script-attachment-message"; +state.projects[0].messages.unshift({ + id: messageId, + sender: "user", + senderLabel: "测试用户", + body: "Attachment round-trip", + sentAt: "2026-03-29T00:00:00+08:00", + kind: "attachment", + attachments: [ + { + attachmentId: "att-001", + fileName: "demo.txt", + mimeType: "text/plain", + fileSizeBytes: 12, + attachmentKind: "text", + storageBackend: "server_file", + storagePath: "/tmp/demo.txt", + previewAvailable: true, + uploadedAt: "2026-03-29T00:00:00+08:00", + uploadedBy: "17600003315", + analysisState: "not_applicable", + }, + ], +}); + +await writeState(state); +const reread = await readState(); +const message = reread.projects[0].messages.find((item) => item.id === messageId); + +if (!message?.attachments?.length) { + throw new Error("Expected message attachments to round-trip through state"); +} +if (message.attachments[0].attachmentId !== "att-001") { + throw new Error("Expected attachment metadata to persist"); +} +if (message.attachments[0].storageBackend !== "server_file") { + throw new Error("Expected attachment storage backend to persist"); +} + +console.log("OK"); diff --git a/src/lib/boss-data.ts b/src/lib/boss-data.ts index 100f526..fd324f0 100644 --- a/src/lib/boss-data.ts +++ b/src/lib/boss-data.ts @@ -173,6 +173,7 @@ export interface Message { body: string; sentAt: string; kind?: MessageKind; + attachments?: MessageAttachment[]; forwardSource?: ForwardSource; forwardBundle?: ForwardBundlePayload; } @@ -1065,7 +1066,7 @@ const initialState: BossState = { { account: PRIMARY_ADMIN_ACCOUNT, mode: "server_file", - updatedAt: "2026-03-29T00:00:00+08:00", + updatedAt: nowIso(), }, ], masterAgentTasks: [], @@ -1894,11 +1895,32 @@ function normalizeMessage(raw: Partial): Message { body: raw.body ?? "", sentAt: raw.sentAt ?? nowIso(), kind: raw.kind ?? "text", + attachments: Array.isArray(raw.attachments) + ? raw.attachments.map((attachment) => normalizeMessageAttachment(attachment)) + : undefined, forwardSource: raw.forwardSource, forwardBundle: raw.forwardBundle, }; } +function normalizeMessageAttachment(raw: Partial): MessageAttachment { + return { + attachmentId: raw.attachmentId ?? randomToken("att"), + fileName: raw.fileName ?? "", + mimeType: raw.mimeType ?? "application/octet-stream", + fileSizeBytes: raw.fileSizeBytes ?? 0, + attachmentKind: raw.attachmentKind ?? "binary", + storageBackend: raw.storageBackend ?? "server_file", + storagePath: raw.storagePath ?? "", + previewAvailable: raw.previewAvailable ?? false, + uploadedAt: raw.uploadedAt ?? nowIso(), + uploadedBy: raw.uploadedBy ?? "system", + analysisState: raw.analysisState ?? "not_applicable", + analysisSummary: raw.analysisSummary, + analysisCardId: raw.analysisCardId, + }; +} + function normalizeAttachmentStorageConfig( raw: Partial, fallback: UserAttachmentStorageConfig, @@ -2661,26 +2683,15 @@ export async function readState(): Promise { try { const state = normalizeState(JSON.parse(raw) as Partial); - const normalizedText = JSON.stringify(state, null, 2); - lastPersistedStateText = normalizedText; - if (normalizedText !== raw) { - await fs.writeFile(dataFile, normalizedText, "utf8"); - await fs.writeFile(backupFile, normalizedText, "utf8"); - } + lastPersistedStateText = JSON.stringify(state, null, 2); return state; } catch { const fallbackText = (await fs.readFile(backupFile, "utf8").catch(() => null)) ?? lastPersistedStateText ?? JSON.stringify(syncDerivedState(cloneInitialState()), null, 2); - await fs.writeFile(dataFile, fallbackText, "utf8"); const state = normalizeState(JSON.parse(fallbackText) as Partial); - const normalizedText = JSON.stringify(state, null, 2); - lastPersistedStateText = normalizedText; - if (normalizedText !== fallbackText) { - await fs.writeFile(dataFile, normalizedText, "utf8"); - await fs.writeFile(backupFile, normalizedText, "utf8"); - } + lastPersistedStateText = JSON.stringify(state, null, 2); return state; } }