diff --git a/packages/desktop-electron/src/main/feedback.test.ts b/packages/desktop-electron/src/main/feedback.test.ts index 9284dd8e5..7c5794836 100644 --- a/packages/desktop-electron/src/main/feedback.test.ts +++ b/packages/desktop-electron/src/main/feedback.test.ts @@ -21,12 +21,19 @@ function setup(overrides: Partial[0]> = const calls = { copied: "", opened: "", + fallbackUrl: "", + shown: "", + openedPath: "", + savedMarkdown: "", errors: [] as unknown[], + handledErrors: [] as string[], } return { calls, handler: createFeedbackHandler({ feedbackUrl: "https://example.com/form", + reportRoot: "/tmp/pawwork/problem-reports", + context: () => "active", confirm: async () => true, copy: async (value) => { calls.copied = value @@ -34,9 +41,31 @@ function setup(overrides: Partial[0]> = openExternal: async (url) => { calls.opened = url }, + showFeedbackUrlFallback: async (url) => { + calls.fallbackUrl = url + }, + showItemInFolder: async (path) => { + calls.shown = path + }, + openPath: async (path) => { + calls.openedPath = path + }, + saveReport: async ({ markdown, reportId }) => { + calls.savedMarkdown = markdown + return { + path: `/tmp/pawwork/problem-reports/pawwork-problem-report-${reportId}.md`, + fileName: `pawwork-problem-report-${reportId}.md`, + locationHint: `PawWork app data/.../problem-reports/pawwork-problem-report-${reportId}.md`, + } + }, + cleanupReports: async () => undefined, + sessionExportTimeoutMs: 10, diagnostics: () => diagnostics, - logTail: () => "log tail", + logTail: () => "log tail\n[error] launch failed", sessionExport: async () => ({ status: "none" }), + onHandledError: (message) => { + calls.handledErrors.push(message) + }, onError: (error) => { calls.errors.push(error) }, @@ -47,19 +76,25 @@ function setup(overrides: Partial[0]> = describe("feedback handler", () => { test("has localized confirmation labels for Simplified Chinese", () => { - expect(feedbackDialogLabels("zh").title).toBe("复制问题报告?") - expect(feedbackDialogLabels("zh").confirm).toBe("复制报告并打开表单") + expect(feedbackDialogLabels("zh").title).toBe("准备问题报告?") + expect(feedbackDialogLabels("zh").confirm).toBe("复制摘要并打开表单") + expect(feedbackDialogLabels("zh").message).toContain("简短摘要") + expect(feedbackDialogLabels("zh").message).toContain("完整问题报告文件") + expect(feedbackDialogLabels("zh").message).toContain("提交后可以删除") }) test("has English confirmation labels", () => { - expect(feedbackDialogLabels("en").title).toBe("Copy problem report?") - expect(feedbackDialogLabels("en").confirm).toBe("Copy report and open form") + expect(feedbackDialogLabels("en").title).toBe("Prepare problem report?") + expect(feedbackDialogLabels("en").confirm).toBe("Copy summary and open form") + expect(feedbackDialogLabels("en").message).toContain("short summary") + expect(feedbackDialogLabels("en").message).toContain("full problem report file") + expect(feedbackDialogLabels("en").message).toContain("delete the local full report file after submission") expect(feedbackDialogLabels("en").failedTitle).toBe("Problem Report Failed") }) test("falls back to English confirmation labels", () => { - expect(feedbackDialogLabels("fr" as never).title).toBe("Copy problem report?") - expect(feedbackDialogLabels("fr" as never).confirm).toBe("Copy report and open form") + expect(feedbackDialogLabels("fr" as never).title).toBe("Prepare problem report?") + expect(feedbackDialogLabels("fr" as never).confirm).toBe("Copy summary and open form") }) test("cancel does not copy or open", async () => { @@ -69,14 +104,42 @@ describe("feedback handler", () => { expect(subject.calls.opened).toBe("") }) - test("confirm copies report and opens form", async () => { + test("confirm copies a short summary, saves the full report, reveals the file, and opens form", async () => { const subject = setup() await subject.handler() - expect(subject.calls.copied).toContain("PawWork Problem Report") + expect(subject.calls.copied).toContain("PawWork Problem Report Summary") + expect(subject.calls.copied).toContain("Full report: ready for manual upload") + expect(subject.calls.copied).not.toContain("```json") + expect(subject.calls.savedMarkdown).toContain("# PawWork Problem Report") + expect(subject.calls.shown).toContain("/tmp/pawwork/problem-reports/") expect(subject.calls.opened).toBe("https://example.com/form") }) - test("uses the context captured before confirmation", async () => { + test("busy guard starts before confirmation and releases on cancel", async () => { + let confirms = 0 + let resolveConfirm: (value: boolean) => void = () => undefined + const firstConfirm = new Promise((resolve) => { + resolveConfirm = resolve + }) + const subject = setup({ + confirm: async () => { + confirms += 1 + return firstConfirm + }, + }) + + const first = subject.handler() + const second = subject.handler() + resolveConfirm(false) + await Promise.all([first, second]) + + expect(confirms).toBe(1) + expect(subject.calls.copied).toBe("") + await subject.handler() + expect(confirms).toBe(2) + }) + + test("uses the context snapshotted before confirmation changes focus", async () => { let current = "active" let exportedContext: unknown let diagnosticsContext: unknown @@ -109,8 +172,126 @@ describe("feedback handler", () => { }, }) await subject.handler() - expect(subject.calls.copied).toContain('"status": "failed"') + expect(subject.calls.savedMarkdown).toContain('"status": "failed"') + expect(subject.calls.savedMarkdown).toContain("session unavailable") + expect(subject.calls.copied).toContain("PawWork Problem Report Summary") + expect(subject.calls.opened).toBe("https://example.com/form") + }) + + test("slow session export times out and still produces report artifacts", async () => { + let aborted = false + const subject = setup({ + sessionExportTimeoutMs: 1, + sessionExport: async (_context, signal) => + new Promise(() => { + signal?.addEventListener("abort", () => { + aborted = true + }) + }), + }) + + await subject.handler() + + expect(aborted).toBe(true) + expect(subject.calls.copied).toContain("PawWork Problem Report Summary") + expect(subject.calls.savedMarkdown).toContain('"status": "failed"') + expect(subject.calls.savedMarkdown).toContain("session export timed out") + expect(subject.calls.opened).toBe("https://example.com/form") + }) + + test("file write failure still copies summary and opens form without attachment instructions", async () => { + const subject = setup({ + saveReport: async () => { + throw new Error("EACCES: /Users/name/problem-reports") + }, + }) + + await subject.handler() + + expect(subject.calls.copied).toContain("Full report: not generated") + expect(subject.calls.copied).toContain("Submit this summary without an attachment if needed.") + expect(subject.calls.copied).not.toContain("/Users/name") + expect(subject.calls.shown).toBe("") + expect(subject.calls.opened).toBe("https://example.com/form") + }) + + test("full report construction failure still copies a minimum summary and opens form", async () => { + const subject = setup({ + diagnostics: () => { + throw new Error("diagnostics exploded") + }, + }) + + await subject.handler() + + expect(subject.calls.copied).toContain("PawWork Problem Report Summary") + expect(subject.calls.copied).toContain("Full report: not generated") + expect(subject.calls.opened).toBe("https://example.com/form") + }) + + test("form open failure is reported after summary and report file are available", async () => { + const subject = setup({ + openExternal: async () => { + throw new Error("browser unavailable") + }, + }) + + await subject.handler() + + expect(subject.calls.copied).toContain("PawWork Problem Report Summary") + expect(subject.calls.savedMarkdown).toContain("# PawWork Problem Report") + expect(subject.calls.fallbackUrl).toBe("https://example.com/form") + expect(subject.calls.handledErrors).toContain("feedback form open failed") + expect(subject.calls.errors).toHaveLength(0) + }) + + test("file reveal failure still opens the form and keeps summary recovery information", async () => { + const subject = setup({ + showItemInFolder: async () => { + throw new Error("reveal failed") + }, + }) + + await subject.handler() + + expect(subject.calls.copied).toContain("problem-reports") + expect(subject.calls.openedPath).toBe("/tmp/pawwork/problem-reports") + expect(subject.calls.opened).toBe("https://example.com/form") + expect(subject.calls.handledErrors).toContain("problem report reveal failed") + }) + + test("directory open fallback reports Electron openPath error strings", async () => { + const subject = setup({ + showItemInFolder: async () => { + throw new Error("reveal failed") + }, + openPath: async (path) => { + subject.calls.openedPath = path + return "No application is associated with the specified file" + }, + }) + + await subject.handler() + + expect(subject.calls.openedPath).toBe("/tmp/pawwork/problem-reports") + expect(subject.calls.opened).toBe("https://example.com/form") + expect(subject.calls.handledErrors).toContain("problem report directory open failed") + }) + + test("cleanup failure does not block opening the form", async () => { + const subject = setup({ + cleanupReports: async () => { + throw new Error("cleanup failed") + }, + }) + + await subject.handler() + + expect(subject.calls.copied).toContain("PawWork Problem Report Summary") + expect(subject.calls.shown).toContain("/tmp/pawwork/problem-reports/") expect(subject.calls.opened).toBe("https://example.com/form") + expect(subject.calls.errors).toHaveLength(0) + expect(subject.calls.handledErrors).toContain("problem report cleanup failed") }) test("missing feedback URL does not copy or open", async () => { diff --git a/packages/desktop-electron/src/main/feedback.ts b/packages/desktop-electron/src/main/feedback.ts index 244233300..827ab31d1 100644 --- a/packages/desktop-electron/src/main/feedback.ts +++ b/packages/desktop-electron/src/main/feedback.ts @@ -1,42 +1,84 @@ -import { buildProblemReport, type ProblemReportDiagnostics, type SessionExport } from "./problem-report" +import { dirname } from "node:path" +import { + buildProblemReport, + buildProblemReportSummary, + DEFAULT_PROBLEM_REPORT_MAX_BYTES, + defaultReportId, + type ProblemReportDiagnostics, + type SessionExport, +} from "./problem-report" import type { MenuLocale } from "./menu-labels" import { errorMessage } from "./error" +type SavedReport = { + path: string + fileName: string + locationHint: string +} + +type SaveReportInput = { + reportId: string + generatedAt: string + markdown: string +} + type FeedbackDeps = { feedbackUrl: string + reportRoot: string context?: () => unknown confirm: (context?: unknown) => Promise copy: (value: string) => Promise | void openExternal: (url: string) => Promise | void + showFeedbackUrlFallback: (url: string) => Promise | void + showItemInFolder: (path: string) => Promise | void + openPath: (path: string) => Promise | string | void + saveReport: (input: SaveReportInput) => Promise + cleanupReports: (currentPath: string) => Promise | void + sessionExportTimeoutMs: number diagnostics: (context?: unknown) => ProblemReportDiagnostics logTail: () => string - sessionExport: (context?: unknown) => Promise + sessionExport: (context?: unknown, signal?: AbortSignal) => Promise + onHandledError?: (message: string, error: unknown) => void onError?: (error: unknown) => Promise | void } export function feedbackDialogLabels(locale: MenuLocale) { const labels = { en: { - title: "Copy problem report?", + title: "Prepare problem report?", message: - "PawWork will copy a problem report to your clipboard and open the feedback form.\n\nThe report may include app diagnostics, recent app logs, current session messages, tool output, file names, paths that include your system username, and file snippets. Review it before submitting.", - confirm: "Copy report and open form", + "PawWork will copy a short summary to your clipboard, save a full problem report file locally, and open the feedback form.\n\nUpload the full problem report file if the form asks for details. You can delete the local full report file after submission.", + confirm: "Copy summary and open form", cancel: "Cancel", failedTitle: "Problem Report Failed", - failedMessage: "Could not copy the report or open the feedback form.", + failedMessage: "Could not prepare the problem report. You can try Report a Problem again.", + formOpenFailedTitle: "Feedback Form Did Not Open", + formOpenFailedMessage: + "PawWork prepared the problem report, but could not open the feedback form. Open this URL manually to finish submitting feedback.", }, zh: { - title: "复制问题报告?", + title: "准备问题报告?", message: - "PawWork 会复制一份问题报告到剪贴板,并打开反馈表单。\n\n报告可能包含应用诊断信息、最近应用日志、当前会话消息、工具输出、文件名、包含系统用户名的路径和文件片段。提交前请先检查。", - confirm: "复制报告并打开表单", + "PawWork 会复制一份简短摘要到剪贴板,保存完整问题报告文件到本地,并打开反馈表单。\n\n如果表单需要更多细节,可以上传完整问题报告文件。提交后可以删除本地完整报告文件。", + confirm: "复制摘要并打开表单", cancel: "取消", failedTitle: "问题报告失败", - failedMessage: "无法复制报告或打开反馈表单。", + failedMessage: "无法准备问题报告。你可以重新点击“报告问题”再试一次。", + formOpenFailedTitle: "反馈表单未打开", + formOpenFailedMessage: "PawWork 已准备好问题报告,但无法打开反馈表单。请手动打开这个链接继续提交反馈。", }, } satisfies Record< MenuLocale, - { title: string; message: string; confirm: string; cancel: string; failedTitle: string; failedMessage: string } + { + title: string + message: string + confirm: string + cancel: string + failedTitle: string + failedMessage: string + formOpenFailedTitle: string + formOpenFailedMessage: string + } > // Runtime fallback for unexpected locale values crossing process boundaries, @@ -44,31 +86,161 @@ export function feedbackDialogLabels(locale: MenuLocale) { return labels[locale] ?? labels.en } +function safeFailureReason(error: unknown) { + const message = errorMessage(error) + if (/timed out/i.test(message)) return "timeout" + if (/EACCES|EPERM/i.test(message)) return "permission_denied" + if (/ENOSPC/i.test(message)) return "disk_full" + if (/ENOENT/i.test(message)) return "path_unavailable" + return "report_failed" +} + +function fallbackDiagnostics(): ProblemReportDiagnostics { + return { + appVersion: "unknown", + channel: "unknown", + packaged: false, + updaterEnabled: false, + platform: process.platform, + osVersion: "unknown", + arch: process.arch, + electronVersion: process.versions.electron ?? "unknown", + locale: "en", + route: "/", + directory: null, + sessionID: null, + logPath: "", + } +} + +function recentKeyErrors(logTail: string) { + return logTail + .split(/\r?\n/) + .filter((line) => /\b(error|warn|warning|failed|exception)\b/i.test(line)) + .map((line) => line.replace(/\s+/g, " ").trim()) + .filter(Boolean) + .slice(-10) +} + +async function sessionExportWithTimeout(deps: FeedbackDeps, context: unknown) { + const controller = new AbortController() + let timeout: ReturnType | undefined + try { + return await Promise.race([ + deps.sessionExport(context, controller.signal), + new Promise((_, reject) => { + timeout = setTimeout(() => { + reject(new Error("session export timed out")) + controller.abort() + }, deps.sessionExportTimeoutMs) + }), + ]) + } finally { + if (timeout !== undefined) clearTimeout(timeout) + } +} + export function createFeedbackHandler(deps: FeedbackDeps) { - return async function reportProblem() { + let inFlight: Promise | undefined + + async function runReportProblem() { + if (!deps.feedbackUrl) return + const context = deps.context?.() + const confirmed = await deps.confirm(context) + if (!confirmed) return + + const id = defaultReportId() + const generatedAt = new Date().toISOString() + let diagnostics: ProblemReportDiagnostics + let logTail = "" + let sessionExport: SessionExport = { status: "none" } + let savedReport: SavedReport | undefined + let fullReportFailure: string | undefined + + try { + diagnostics = deps.diagnostics(context) + } catch (error) { + diagnostics = fallbackDiagnostics() + fullReportFailure = safeFailureReason(error) + } + + try { + logTail = deps.logTail() + } catch (error) { + fullReportFailure ??= safeFailureReason(error) + } + try { - if (!deps.feedbackUrl) return - const context = deps.context?.() - const confirmed = await deps.confirm(context) - if (!confirmed) return + sessionExport = await sessionExportWithTimeout(deps, context) + } catch (error) { + sessionExport = { status: "failed", error: errorMessage(error) } + } - let sessionExport: SessionExport + if (!fullReportFailure) { try { - sessionExport = await deps.sessionExport(context) + const report = buildProblemReport( + { diagnostics, logTail, sessionExport }, + { reportId: id, generatedAt, maxBytes: DEFAULT_PROBLEM_REPORT_MAX_BYTES }, + ) + savedReport = await deps.saveReport({ reportId: id, generatedAt, markdown: report.markdown }) } catch (error) { - sessionExport = { status: "failed", error: errorMessage(error) } + fullReportFailure = safeFailureReason(error) } + } - const report = buildProblemReport({ - diagnostics: deps.diagnostics(context), - logTail: deps.logTail(), - sessionExport, - }) + const summary = buildProblemReportSummary({ + reportId: id, + generatedAt, + diagnostics, + reportFileName: savedReport?.fileName ?? null, + reportLocationHint: savedReport?.locationHint ?? null, + fullReportStatus: savedReport ? "ready" : "failed", + failureReason: fullReportFailure, + recentErrors: recentKeyErrors(logTail), + }) + + await deps.copy(summary) + + if (savedReport) { + try { + await deps.showItemInFolder(savedReport.path) + } catch (error) { + deps.onHandledError?.("problem report reveal failed", error) + try { + const openPathError = await deps.openPath(dirname(savedReport.path)) + if (typeof openPathError === "string" && openPathError.length > 0) throw new Error(openPathError) + } catch (openPathError) { + deps.onHandledError?.("problem report directory open failed", openPathError) + } + } + try { + await deps.cleanupReports(savedReport.path) + } catch (error) { + deps.onHandledError?.("problem report cleanup failed", error) + } + } - await deps.copy(report.markdown) + try { await deps.openExternal(deps.feedbackUrl) } catch (error) { - await deps.onError?.(error) + deps.onHandledError?.("feedback form open failed", error) + try { + await deps.showFeedbackUrlFallback(deps.feedbackUrl) + } catch (fallbackError) { + deps.onHandledError?.("feedback form fallback failed", fallbackError) + } } } + + return async function reportProblem() { + if (inFlight) return inFlight + inFlight = runReportProblem() + .catch(async (error) => { + await deps.onError?.(error) + }) + .finally(() => { + inFlight = undefined + }) + return inFlight + } } diff --git a/packages/desktop-electron/src/main/index.ts b/packages/desktop-electron/src/main/index.ts index f49a3d0ff..81e477758 100644 --- a/packages/desktop-electron/src/main/index.ts +++ b/packages/desktop-electron/src/main/index.ts @@ -32,6 +32,7 @@ const APP_IDS: Record = { } const CI_SMOKE_HOME = process.env.PAWWORK_CI_SMOKE_HOME const CI_SMOKE_ENABLED = process.env.PAWWORK_CI_SMOKE === "true" +const FEEDBACK_SESSION_EXPORT_TIMEOUT_MS = 3_000 const userDataRoot = CI_SMOKE_HOME ?? app.getPath("appData") app.setName(app.isPackaged ? APP_NAMES[CHANNEL] : "PawWork Dev") @@ -50,7 +51,6 @@ import type { DesktopContext, InitStep, ServerReadyData, SqliteMigrationProgress import { checkAppExists, resolveAppPath, wslPath } from "./apps" import { CHANNEL, FEEDBACK_FORM_URL, UPDATER_ENABLED } from "./constants" import { createDesktopContextStore } from "./desktop-context-store" -import { errorMessage } from "./error" import { createFeedbackHandler, feedbackDialogLabels } from "./feedback" import { registerIpcHandlers, sendDeepLinks, sendMenuCommand, sendSqliteMigrationProgress } from "./ipc" import { filePath, initLogging, tail } from "./logging" @@ -58,6 +58,7 @@ import { parseMarkdown } from "./markdown" import { createMenu } from "./menu" import { type MenuLocale } from "./menu-labels" import { readStoredMenuLocale, writeStoredMenuLocale } from "./menu-i18n" +import { cleanupProblemReports, problemReportsRoot, writeProblemReportFile } from "./problem-report-files" import { getDefaultServerUrl, getWslConfig, setDefaultServerUrl, setWslConfig, spawnLocalServer } from "./server" import { PAWWORK_RUNTIME } from "./runtime-namespace" import { createUpdaterController } from "./updater" @@ -101,6 +102,7 @@ const pendingDeepLinks: string[] = [] const serverReady = defer() const logger = initLogging() +const problemReportRoot = problemReportsRoot(app.getPath("userData")) const updater = createUpdaterController({ enabled: UPDATER_ENABLED, currentVersion: () => app.getVersion(), @@ -132,7 +134,7 @@ function diagnostics(context = currentDesktopContext()) { } } -async function sessionExport(context = currentDesktopContext()) { +async function sessionExport(context = currentDesktopContext(), signal?: AbortSignal) { if (!context.sessionID) return { status: "none" as const } const ready = await serverReady.promise const sessionID = encodeURIComponent(context.sessionID) @@ -142,9 +144,12 @@ async function sessionExport(context = currentDesktopContext()) { headers.authorization = `Basic ${Buffer.from(`${ready.username ?? "opencode"}:${ready.password ?? ""}`).toString("base64")}` } const controller = new AbortController() + const abort = () => controller.abort() let timeout: ReturnType | undefined let res: Response try { + if (signal?.aborted) controller.abort() + else signal?.addEventListener("abort", abort, { once: true }) const timeoutPromise = new Promise((_, reject) => { timeout = setTimeout(() => { controller.abort() @@ -154,6 +159,7 @@ async function sessionExport(context = currentDesktopContext()) { res = await Promise.race([fetch(url, { headers, signal: controller.signal }), timeoutPromise]) } finally { if (timeout !== undefined) clearTimeout(timeout) + signal?.removeEventListener("abort", abort) } if (!res.ok) throw new Error(`session export failed: ${res.status}`) return { @@ -183,6 +189,7 @@ function feedbackContext(context: unknown): DesktopContext { const reportProblem = createFeedbackHandler({ feedbackUrl: FEEDBACK_FORM_URL, + reportRoot: problemReportRoot, context: currentDesktopContext, confirm: async (context) => { const labels = feedbackDialogLabels(context === undefined ? menuLocale : feedbackContext(context).locale) @@ -200,9 +207,24 @@ const reportProblem = createFeedbackHandler({ openExternal: (url) => { return shell.openExternal(url).then(() => undefined) }, + showFeedbackUrlFallback: async (url) => { + const labels = feedbackDialogLabels(currentDesktopContext().locale) + await dialog.showMessageBox({ + type: "warning", + title: labels.formOpenFailedTitle, + message: labels.formOpenFailedMessage, + detail: url, + }) + }, + showItemInFolder: (path) => shell.showItemInFolder(path), + openPath: (path) => shell.openPath(path), + saveReport: (input) => writeProblemReportFile({ root: problemReportRoot, ...input }), + cleanupReports: (currentPath) => cleanupProblemReports({ root: problemReportRoot, keep: 10, currentPath }), + sessionExportTimeoutMs: FEEDBACK_SESSION_EXPORT_TIMEOUT_MS, diagnostics: (context) => diagnostics(feedbackContext(context)), logTail: tail, - sessionExport: (context) => sessionExport(feedbackContext(context)), + sessionExport: (context, signal) => sessionExport(feedbackContext(context), signal), + onHandledError: (message, error) => logger.error(message, error), onError: async (error) => { logger.error("problem report failed", error) const labels = feedbackDialogLabels(currentDesktopContext().locale) @@ -210,7 +232,6 @@ const reportProblem = createFeedbackHandler({ type: "error", title: labels.failedTitle, message: labels.failedMessage, - detail: errorMessage(error), }) }, }) diff --git a/packages/desktop-electron/src/main/problem-report-files.test.ts b/packages/desktop-electron/src/main/problem-report-files.test.ts new file mode 100644 index 000000000..d3ded005f --- /dev/null +++ b/packages/desktop-electron/src/main/problem-report-files.test.ts @@ -0,0 +1,166 @@ +import { afterEach, describe, expect, test } from "bun:test" +import { existsSync } from "node:fs" +import { mkdir, mkdtemp, readFile, rm, symlink, utimes, writeFile } from "node:fs/promises" +import { tmpdir } from "node:os" +import { join } from "node:path" +import { + cleanupProblemReports, + problemReportFileName, + reportLocationHint, + writeProblemReportFile, +} from "./problem-report-files" + +let tempRoots: string[] = [] + +async function tempRoot() { + const root = await mkdtemp(join(tmpdir(), "pawwork-report-files-")) + tempRoots.push(root) + return root +} + +afterEach(async () => { + for (const root of tempRoots) await rm(root, { recursive: true, force: true }) + tempRoots = [] +}) + +describe("problem report files", () => { + test("builds collision-resistant markdown file names in local time", () => { + const generatedAt = new Date(2026, 3, 23, 9, 2, 3, 4).toISOString() + const first = problemReportFileName({ + reportId: "pwr_abc123", + generatedAt, + }) + const second = problemReportFileName({ + reportId: "pwr_def456", + generatedAt, + }) + + expect(first).toBe("pawwork-problem-report-20260423-090203-004-pwr_abc123.md") + expect(second).toBe("pawwork-problem-report-20260423-090203-004-pwr_def456.md") + }) + + test("rejects report ids that cannot be cleaned up safely", () => { + for (const reportId of ["../escape", "pwr-bad", "pwr.bad", "pwr bad", "pwr/bad"]) { + expect(() => + problemReportFileName({ + reportId, + generatedAt: "2026-04-23T01:02:03.004Z", + }), + ).toThrow("reportId must contain only letters, numbers, and underscores") + } + }) + + test("rejects non-canonical generated timestamps", () => { + for (const generatedAt of ["not a date", "2026-04-23", "2026-04-23T01:02:03Z"]) { + expect(() => + problemReportFileName({ + reportId: "pwr_abc123", + generatedAt, + }), + ).toThrow("generatedAt must be a valid ISO timestamp") + } + }) + + test("writes through a temporary file and does not overwrite existing reports", async () => { + const root = await tempRoot() + const first = await writeProblemReportFile({ + root, + reportId: "pwr_abc123", + generatedAt: "2026-04-23T01:02:03.004Z", + markdown: "first", + }) + await expect( + writeProblemReportFile({ + root, + reportId: "pwr_abc123", + generatedAt: "2026-04-23T01:02:03.004Z", + markdown: "second", + }), + ).rejects.toThrow("Problem report already exists") + + expect(await readFile(first.path, "utf8")).toBe("first") + }) + + test("keeps the saved report usable when temporary cleanup fails after linking", async () => { + const root = await tempRoot() + let cleanupAttempted = false + const report = await writeProblemReportFile({ + root, + reportId: "pwr_cleanup_failed", + generatedAt: "2026-04-23T01:02:03.004Z", + markdown: "report", + removeTemp: async () => { + cleanupAttempted = true + throw new Error("cleanup failed") + }, + }) + + expect(cleanupAttempted).toBe(true) + expect(await readFile(report.path, "utf8")).toBe("report") + }) + + test("creates a user-facing location hint without full local paths", () => { + expect( + reportLocationHint({ + fileName: "pawwork-problem-report-20260423-010203-004-pwr_abc123.md", + platform: "darwin", + }), + ).toBe("PawWork app data/.../problem-reports/pawwork-problem-report-20260423-010203-004-pwr_abc123.md") + expect( + reportLocationHint({ + fileName: "pawwork-problem-report-20260423-010203-004-pwr_abc123.md", + platform: "win32", + }), + ).toBe("%APPDATA%/.../problem-reports/pawwork-problem-report-20260423-010203-004-pwr_abc123.md") + }) + + test("cleanup keeps current report and skips non-regular or non-matching entries", async () => { + const root = await tempRoot() + const current = await writeProblemReportFile({ + root, + reportId: "pwr_current", + generatedAt: "2026-04-23T01:02:03.004Z", + markdown: "current", + }) + const old = join(root, "pawwork-problem-report-20260423-010203-004-pwr_old.md") + const other = join(root, "notes.md") + const dir = join(root, "pawwork-problem-report-20260423-010203-004-pwr_dir.md") + const link = join(root, "pawwork-problem-report-20260423-010203-004-pwr_link.md") + await writeFile(old, "old") + await writeFile(other, "other") + await mkdir(dir) + await symlink(other, link) + + await cleanupProblemReports({ root, keep: 1, currentPath: current.path }) + + expect(existsSync(current.path)).toBe(true) + expect(existsSync(other)).toBe(true) + expect(existsSync(dir)).toBe(true) + expect(existsSync(link)).toBe(true) + expect(existsSync(old)).toBe(false) + }) + + test("cleanup keep count includes the current report", async () => { + const root = await tempRoot() + const current = await writeProblemReportFile({ + root, + reportId: "pwr_current", + generatedAt: "2026-04-23T01:02:03.004Z", + markdown: "current", + }) + const newestArchived = join(root, "pawwork-problem-report-20260423-010203-004-pwr_newest.md") + const oldestArchived = join(root, "pawwork-problem-report-20260423-010203-004-pwr_oldest.md") + await writeFile(newestArchived, "newest") + await writeFile(oldestArchived, "oldest") + + const newestTime = new Date("2026-04-23T01:02:06.004Z") + const oldestTime = new Date("2026-04-23T01:02:04.004Z") + await Promise.all([utimes(newestArchived, newestTime, newestTime), utimes(oldestArchived, oldestTime, oldestTime)]) + + await cleanupProblemReports({ root, keep: 2, currentPath: current.path }) + + expect(existsSync(current.path)).toBe(true) + expect(existsSync(newestArchived)).toBe(true) + expect(existsSync(oldestArchived)).toBe(false) + }) +}) diff --git a/packages/desktop-electron/src/main/problem-report-files.ts b/packages/desktop-electron/src/main/problem-report-files.ts new file mode 100644 index 000000000..43b838a01 --- /dev/null +++ b/packages/desktop-electron/src/main/problem-report-files.ts @@ -0,0 +1,100 @@ +import { link, lstat, mkdir, readdir, rm, writeFile } from "node:fs/promises" +import { basename, join } from "node:path" + +const REPORT_FILE_PATTERN = /^pawwork-problem-report-\d{8}-\d{6}-\d{3}-[a-zA-Z0-9_]+\.md$/ +const REPORT_ID_PATTERN = /^[a-zA-Z0-9_]+$/ + +function isCanonicalIsoTimestamp(value: string) { + const time = Date.parse(value) + return !Number.isNaN(time) && new Date(time).toISOString() === value +} + +export function problemReportFileName(input: { reportId: string; generatedAt: string }) { + if (!REPORT_ID_PATTERN.test(input.reportId)) throw new Error("reportId must contain only letters, numbers, and underscores") + if (!isCanonicalIsoTimestamp(input.generatedAt)) throw new Error("generatedAt must be a valid ISO timestamp") + const date = new Date(input.generatedAt) + const stamp = [ + String(date.getFullYear()).padStart(4, "0"), + String(date.getMonth() + 1).padStart(2, "0"), + String(date.getDate()).padStart(2, "0"), + "-", + String(date.getHours()).padStart(2, "0"), + String(date.getMinutes()).padStart(2, "0"), + String(date.getSeconds()).padStart(2, "0"), + "-", + String(date.getMilliseconds()).padStart(3, "0"), + ].join("") + return `pawwork-problem-report-${stamp}-${input.reportId}.md` +} + +export function problemReportsRoot(userDataPath: string) { + return join(userDataPath, "problem-reports") +} + +export function reportLocationHint(input: { fileName: string; platform: NodeJS.Platform | string }) { + const root = input.platform === "win32" ? "%APPDATA%" : "PawWork app data" + return `${root}/.../problem-reports/${input.fileName}` +} + +export async function writeProblemReportFile(input: { + root: string + reportId: string + generatedAt: string + markdown: string + removeTemp?: (path: string) => Promise +}) { + await mkdir(input.root, { recursive: true }) + const fileName = problemReportFileName({ reportId: input.reportId, generatedAt: input.generatedAt }) + const path = join(input.root, fileName) + const tempPath = join(input.root, `.${fileName}.${process.pid}.${Date.now()}.tmp`) + const removeTemp = input.removeTemp ?? ((path: string) => rm(path, { force: true })) + try { + await writeFile(tempPath, input.markdown, { encoding: "utf8", flag: "wx", mode: 0o600 }) + try { + await link(tempPath, path) + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "EEXIST") throw new Error("Problem report already exists") + throw error + } + await removeTemp(tempPath).catch(() => undefined) + return { + path, + fileName, + locationHint: reportLocationHint({ fileName, platform: process.platform }), + } + } catch (error) { + await removeTemp(tempPath).catch(() => undefined) + throw error + } +} + +export async function cleanupProblemReports(input: { root: string; keep: number; currentPath: string }) { + let entries: Array<{ path: string; mtimeMs: number }> = [] + try { + const names = await readdir(input.root) + for (const name of names) { + if (!REPORT_FILE_PATTERN.test(name)) continue + const path = join(input.root, name) + if (path === input.currentPath) continue + try { + const stat = await lstat(path) + if (!stat.isFile()) continue + entries.push({ path, mtimeMs: stat.mtimeMs }) + } catch { + continue + } + } + } catch { + return + } + + entries = entries.sort((a, b) => b.mtimeMs - a.mtimeMs) + const retainedArchivedReports = Math.max(0, input.keep - 1) + for (const entry of entries.slice(retainedArchivedReports)) { + await rm(entry.path, { force: true }).catch(() => undefined) + } +} + +export function isProblemReportFileName(name: string) { + return REPORT_FILE_PATTERN.test(basename(name)) +} diff --git a/packages/desktop-electron/src/main/problem-report.test.ts b/packages/desktop-electron/src/main/problem-report.test.ts index 1c520e152..15185d275 100644 --- a/packages/desktop-electron/src/main/problem-report.test.ts +++ b/packages/desktop-electron/src/main/problem-report.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test" -import { buildProblemReport, parseProblemReportPayload } from "./problem-report" +import { buildProblemReport, buildProblemReportSummary, parseProblemReportPayload } from "./problem-report" const base = { diagnostics: { @@ -31,15 +31,200 @@ const base = { } describe("problem report", () => { + test("uses caller-provided report id and generated time", () => { + const report = buildProblemReport(base, { + reportId: "pwr_20260423_abc123", + generatedAt: "2026-04-23T01:02:03.004Z", + }) + + const payload = parseProblemReportPayload(report.markdown) + expect(report.reportId).toBe("pwr_20260423_abc123") + expect(report.generatedAt).toBe("2026-04-23T01:02:03.004Z") + expect(payload.reportId).toBe("pwr_20260423_abc123") + expect(payload.generatedAt).toBe("2026-04-23T01:02:03.004Z") + }) + test("creates markdown with valid fenced JSON", () => { const report = buildProblemReport(base) expect(report.markdown).toContain("# PawWork Problem Report") + expect(report.markdown).toContain("Upload this markdown file to the feedback form after reviewing it.") + expect(report.markdown).not.toContain("Paste this report into the feedback form") const payload = parseProblemReportPayload(report.markdown) expect(payload.reportVersion).toBe(1) + expect(payload.reportId).toBe(report.reportId) expect(payload.diagnostics.sessionID).toBe("ses_1") expect(payload.sessionExport.status).toBe("ok") }) + test("builds a short summary without full logs, paths, session export, tool output, or snippets", () => { + const summary = buildProblemReportSummary({ + reportId: "pwr_20260423_abc123", + generatedAt: "2026-04-23T01:02:03.004Z", + diagnostics: base.diagnostics, + reportFileName: "pawwork-problem-report-20260423-090203-004-abc123.md", + reportLocationHint: "PawWork app data/.../problem-reports/pawwork-problem-report-20260423-090203-004-abc123.md", + fullReportStatus: "ready", + recentErrors: ["[error] launch failed", "[warn] retrying"], + }) + + expect(summary).toContain("PawWork Problem Report Summary") + expect(summary).toContain("Report ID: pwr_20260423_abc123") + expect(summary).toContain("Report file: pawwork-problem-report-20260423-090203-004-abc123.md") + expect(summary).toContain("Full report: ready for manual upload") + expect(summary).toContain("[error] launch failed") + expect(summary).not.toContain(base.diagnostics.logPath) + expect(summary).not.toContain(base.diagnostics.directory) + expect(summary).not.toContain("line one") + expect(summary).not.toContain("messages") + expect(summary.split(/\r?\n/).length).toBeLessThanOrEqual(28) + }) + + test("summary explains summary-only submission when the full report is unavailable", () => { + const summary = buildProblemReportSummary({ + reportId: "pwr_20260423_failed", + generatedAt: "2026-04-23T01:02:03.004Z", + diagnostics: base.diagnostics, + reportFileName: null, + reportLocationHint: null, + fullReportStatus: "failed", + failureReason: "file_write_failed", + recentErrors: [], + }) + + expect(summary).toContain("Full report: not generated") + expect(summary).toContain("Submit this summary without an attachment if needed.") + expect(summary).toContain("No recent errors found") + }) + + test("summary keeps recent errors to a small single-line set", () => { + const summary = buildProblemReportSummary({ + reportId: "pwr_20260423_errors", + generatedAt: "2026-04-23T01:02:03.004Z", + diagnostics: base.diagnostics, + reportFileName: "pawwork-problem-report-20260423-090203-004-errors.md", + reportLocationHint: "PawWork app data/.../problem-reports/pawwork-problem-report-20260423-090203-004-errors.md", + fullReportStatus: "ready", + recentErrors: Array.from({ length: 20 }, (_, index) => `[error] failure ${index}\nstack line ${index}`), + }) + + expect(summary).toContain("[error] failure 0") + expect(summary).toContain("[error] failure 9") + expect(summary).not.toContain("[error] failure 10") + expect(summary).not.toContain("stack line") + }) + + test("summary truncates oversized recent error lines", () => { + const toolOutput = "x".repeat(5_000) + const summary = buildProblemReportSummary({ + reportId: "pwr_long_errors", + generatedAt: "2026-04-23T01:02:03.004Z", + diagnostics: base.diagnostics, + reportFileName: "pawwork-problem-report-20260423-090203-004-pwr_long_errors.md", + reportLocationHint: "PawWork app data/.../problem-reports/pawwork-problem-report-20260423-090203-004-pwr_long_errors.md", + fullReportStatus: "ready", + recentErrors: [`[error] tool output ${toolOutput}`], + }) + + expect(summary).toContain("[error] tool output") + expect(summary).toContain("...") + expect(summary).not.toContain(toolOutput) + expect(summary.length).toBeLessThan(1_000) + }) + + test("summary omits prompt query and hash content from routes", () => { + const prompt = "write this exact code snippet ".repeat(200) + const summary = buildProblemReportSummary({ + reportId: "pwr_prompt_route", + generatedAt: "2026-04-23T01:02:03.004Z", + diagnostics: { + ...base.diagnostics, + route: `/session/new?prompt=${encodeURIComponent(prompt)}#${"hash".repeat(200)}`, + }, + reportFileName: "pawwork-problem-report-20260423-090203-004-pwr_prompt_route.md", + reportLocationHint: "PawWork app data/.../problem-reports/pawwork-problem-report-20260423-090203-004-pwr_prompt_route.md", + fullReportStatus: "ready", + recentErrors: [], + }) + + expect(summary).toContain("Route: /session/new") + expect(summary).not.toContain("prompt=") + expect(summary).not.toContain(encodeURIComponent(prompt)) + expect(summary).not.toContain("hashhash") + expect(summary.length).toBeLessThan(1_000) + }) + + test("summary truncates and cleans session ids", () => { + const longSessionID = `ses_${"x".repeat(500)}` + const summary = buildProblemReportSummary({ + reportId: "pwr_long_session", + generatedAt: "2026-04-23T01:02:03.004Z", + diagnostics: { + ...base.diagnostics, + sessionID: `${longSessionID}/C:\\Users\\name\\secret`, + }, + reportFileName: "pawwork-problem-report-20260423-090203-004-pwr_long_session.md", + reportLocationHint: "PawWork app data/.../problem-reports/pawwork-problem-report-20260423-090203-004-pwr_long_session.md", + fullReportStatus: "ready", + recentErrors: [], + }) + + expect(summary).toContain("Session: ses_") + expect(summary).toContain("...") + expect(summary).not.toContain(longSessionID) + expect(summary).not.toContain("C:\\Users\\name") + }) + + test("summary omits raw Windows paths, spaces, and non-ASCII user directories", () => { + const summary = buildProblemReportSummary({ + reportId: "pwr_windows_paths", + generatedAt: "2026-04-23T01:02:03.004Z", + diagnostics: { + ...base.diagnostics, + platform: "win32", + directory: "C:\\Users\\张 三\\Project Space", + logPath: "C:\\Users\\张 三\\AppData\\Roaming\\PawWork\\logs\\main.log", + }, + reportFileName: "pawwork-problem-report-20260423-090203-004-pwr_windows_paths.md", + reportLocationHint: "%APPDATA%/.../problem-reports/pawwork-problem-report-20260423-090203-004-pwr_windows_paths.md", + fullReportStatus: "ready", + recentErrors: ["[error] failed to launch C:\\Users\\张 三\\Project Space\\app.log"], + }) + + expect(summary).toContain("%APPDATA%/.../problem-reports/") + expect(summary).not.toContain("C:\\Users\\张 三") + expect(summary).not.toContain("Project Space") + expect(summary).not.toContain("main.log") + expect(summary).not.toContain("Space\\app.log") + expect(summary).not.toContain("app.log") + }) + + test("summary omits Linux, temp, and network local paths from recent errors", () => { + const summary = buildProblemReportSummary({ + reportId: "pwr_unix_paths", + generatedAt: "2026-04-23T01:02:03.004Z", + diagnostics: { + ...base.diagnostics, + platform: "linux", + directory: "/home/alice/workspace/project", + logPath: "/home/alice/.config/PawWork/logs/main.log", + }, + reportFileName: "pawwork-problem-report-20260423-090203-004-pwr_unix_paths.md", + reportLocationHint: "PawWork app data/.../problem-reports/pawwork-problem-report-20260423-090203-004-pwr_unix_paths.md", + fullReportStatus: "ready", + recentErrors: [ + "[error] failed reading /home/alice/workspace/project/src/index.ts", + "[warn] temp output at /tmp/pawwork/session/output.log", + "[error] network path \\\\server\\share\\alice\\secret.log", + ], + }) + + expect(summary).toContain("[path]") + expect(summary).not.toContain("/home/alice") + expect(summary).not.toContain("/tmp/pawwork") + expect(summary).not.toContain("\\\\server\\share") + expect(summary).not.toContain("secret.log") + }) + test("keeps no-session reports useful", () => { const report = buildProblemReport({ ...base, @@ -148,11 +333,26 @@ describe("problem report", () => { expect(() => buildProblemReport(base, { maxBytes: 0 })).toThrow("maxBytes must be a positive finite number") }) + test("rejects invalid caller-provided report metadata", () => { + expect(() => buildProblemReport(base, { reportId: "" })).toThrow("reportId must be a non-empty string") + expect(() => buildProblemReport(base, { reportId: " " })).toThrow("reportId must be a non-empty string") + expect(() => buildProblemReport(base, { generatedAt: "not a date" })).toThrow( + "generatedAt must be a valid ISO timestamp", + ) + expect(() => buildProblemReport(base, { generatedAt: "2026-04-23" })).toThrow( + "generatedAt must be a valid ISO timestamp", + ) + expect(() => buildProblemReport(base, { generatedAt: "2026-04-23T01:02:03Z" })).toThrow( + "generatedAt must be a valid ISO timestamp", + ) + }) + test("parses only the first JSON fence", () => { const report = [ "```json", JSON.stringify({ reportVersion: 1, + reportId: "pwr_fixture", generatedAt: new Date().toISOString(), diagnostics: base.diagnostics, logTail: "", @@ -180,6 +380,7 @@ describe("problem report", () => { "```json", JSON.stringify({ reportVersion: 1, + reportId: "pwr_fixture", generatedAt: new Date().toISOString(), diagnostics: base.diagnostics, logTail: "", @@ -206,6 +407,7 @@ describe("problem report", () => { "```json", JSON.stringify({ reportVersion: 1, + reportId: "pwr_fixture", generatedAt: new Date().toISOString(), diagnostics: base.diagnostics, logTail: "", @@ -229,6 +431,7 @@ describe("problem report", () => { "```json", JSON.stringify({ reportVersion: 1, + reportId: "pwr_invalid", diagnostics: base.diagnostics, logTail: "", sessionExport: { status: "none" }, diff --git a/packages/desktop-electron/src/main/problem-report.ts b/packages/desktop-electron/src/main/problem-report.ts index fe85c1074..e84b505f9 100644 --- a/packages/desktop-electron/src/main/problem-report.ts +++ b/packages/desktop-electron/src/main/problem-report.ts @@ -1,6 +1,10 @@ -// Bound clipboard payloads while preserving recent logs and session snippets for diagnosis. -// Default clipboard payload limit: 5 MB. -const DEFAULT_MAX_BYTES = 5 * 1024 * 1024 +// Bound full report payloads while preserving recent logs and session snippets for diagnosis. +// Default full report payload limit: 5 MB. +export const DEFAULT_PROBLEM_REPORT_MAX_BYTES = 5 * 1024 * 1024 +const SUMMARY_ERROR_LINE_MAX_CHARS = 220 +const SUMMARY_FAILURE_REASON_MAX_CHARS = 80 +const SUMMARY_ROUTE_MAX_CHARS = 120 +const SUMMARY_SESSION_MAX_CHARS = 80 export type ProblemReportDiagnostics = { appVersion: string @@ -38,10 +42,13 @@ type Input = { type Options = { maxBytes?: number + reportId?: string + generatedAt?: string } type Payload = { reportVersion: 1 + reportId: string generatedAt: string diagnostics: ProblemReportDiagnostics logTail: string @@ -59,6 +66,15 @@ function bytes(value: string) { return Buffer.byteLength(value, "utf8") } +function isCanonicalIsoTimestamp(value: string) { + const time = Date.parse(value) + return !Number.isNaN(time) && new Date(time).toISOString() === value +} + +export function defaultReportId() { + return `pwr_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}` +} + function jsonBytes(value: unknown) { return bytes(JSON.stringify(toJsonSafe(value)) ?? "") } @@ -67,7 +83,7 @@ function markdown(payload: Payload) { return [ "# PawWork Problem Report", "", - "Paste this report into the feedback form after reviewing it.", + "Upload this markdown file to the feedback form after reviewing it.", "", "```json", JSON.stringify(payload, null, 2), @@ -147,8 +163,12 @@ function truncateDiagnostics(diagnostics: ProblemReportDiagnostics, stringLimit: } export function buildProblemReport(input: Input, options: Options = {}) { - const maxBytes = Math.floor(options.maxBytes ?? DEFAULT_MAX_BYTES) + const maxBytes = Math.floor(options.maxBytes ?? DEFAULT_PROBLEM_REPORT_MAX_BYTES) if (!Number.isFinite(maxBytes) || maxBytes <= 0) throw new Error("maxBytes must be a positive finite number") + const reportId = options.reportId ?? defaultReportId() + const generatedAt = options.generatedAt ?? new Date().toISOString() + if (reportId.trim().length === 0) throw new Error("reportId must be a non-empty string") + if (!isCanonicalIsoTimestamp(generatedAt)) throw new Error("generatedAt must be a valid ISO timestamp") const sessionExport = sanitizeSessionExport(input.sessionExport) let diagnostics = input.diagnostics let logTail = input.logTail @@ -163,7 +183,8 @@ export function buildProblemReport(input: Input, options: Options = {}) { const makePayload = (): Payload => ({ reportVersion: 1, - generatedAt: new Date().toISOString(), + reportId, + generatedAt, diagnostics, logTail, sessionExport: withFailedExportError(withMessages(withSessionInfo(sessionExport, sessionInfo ?? null), messages), failedExportError), @@ -224,7 +245,88 @@ export function buildProblemReport(input: Input, options: Options = {}) { throw new Error("Problem report exceeds maxBytes after truncation") } - return { markdown: output } + return { markdown: output, reportId, generatedAt } +} + +type ProblemReportSummaryInput = { + reportId: string + generatedAt: string + diagnostics: ProblemReportDiagnostics + reportFileName: string | null + reportLocationHint: string | null + fullReportStatus: "ready" | "failed" + failureReason?: string + recentErrors: string[] +} + +function oneLine(value: string) { + return (value.split(/\r?\n/)[0] ?? "").replace(/\s+/g, " ").trim() +} + +function redactLocalPathFragments(value: string) { + return value + .replace(/[A-Za-z]:\\[^\r\n]*/g, "[path]") + .replace(/\\\\[^\\\s]+\\[^\r\n]*/g, "[path]") + .replace(/\/(?:Users|home|tmp|var\/folders|private\/tmp)\/[^\r\n]*/g, "[path]") +} + +function truncateSummaryLine(value: string, maxChars: number) { + return value.length > maxChars ? `${value.slice(0, maxChars)}...` : value +} + +function safeSummaryRoute(route: string) { + const pathOnly = oneLine(route).split(/[?#]/)[0] || "/" + return truncateSummaryLine(redactLocalPathFragments(pathOnly), SUMMARY_ROUTE_MAX_CHARS) +} + +function safeSummarySession(sessionID: string | null) { + if (sessionID === null) return "none" + return truncateSummaryLine(oneLine(redactLocalPathFragments(sessionID)), SUMMARY_SESSION_MAX_CHARS) +} + +function safeFailureReason(value: string | undefined) { + if (!value) return "unknown" + return truncateSummaryLine(oneLine(redactLocalPathFragments(value)), SUMMARY_FAILURE_REASON_MAX_CHARS) +} + +function summaryRecentErrors(recentErrors: string[]) { + const lines = recentErrors + .map((line) => truncateSummaryLine(oneLine(redactLocalPathFragments(line)), SUMMARY_ERROR_LINE_MAX_CHARS)) + .filter(Boolean) + .slice(0, 10) + return lines.length > 0 ? lines : ["No recent errors found"] +} + +export function buildProblemReportSummary(input: ProblemReportSummaryInput) { + const fullReportLines = + input.fullReportStatus === "ready" + ? [ + "Full report: ready for manual upload", + `Report file: ${input.reportFileName ?? "unknown"}`, + `Report location: ${input.reportLocationHint ?? "unknown"}`, + ] + : [ + "Full report: not generated", + `Full report failure: ${safeFailureReason(input.failureReason)}`, + "Submit this summary without an attachment if needed.", + ] + + return [ + "PawWork Problem Report Summary", + "", + `Report ID: ${input.reportId}`, + `Generated: ${input.generatedAt}`, + `PawWork: ${input.diagnostics.appVersion} (${input.diagnostics.channel})`, + `Platform: ${input.diagnostics.platform} ${input.diagnostics.osVersion} ${input.diagnostics.arch}`, + `Electron: ${input.diagnostics.electronVersion}`, + `Route: ${safeSummaryRoute(input.diagnostics.route)}`, + `Session: ${safeSummarySession(input.diagnostics.sessionID)}`, + ...fullReportLines, + "", + "Recent key errors:", + ...summaryRecentErrors(input.recentErrors).map((line) => `- ${line}`), + "", + ].join("\n") } function isRecord(value: unknown): value is Record { @@ -281,6 +383,8 @@ function isProblemReportPayload(value: unknown): value is Payload { if (!isRecord(value)) return false return ( value.reportVersion === 1 && + typeof value.reportId === "string" && + value.reportId.length > 0 && typeof value.generatedAt === "string" && !Number.isNaN(Date.parse(value.generatedAt)) && isDiagnostics(value.diagnostics) &&