From d0e33c7cbf6198818dfe870411ee8408097280a6 Mon Sep 17 00:00:00 2001 From: Daniil Pokrovsky Date: Sat, 14 Nov 2026 14:07:29 +0700 Subject: [PATCH] feat(dashboard): report a bug from a run Add a "Report a problem" action to the run detail view that files the same redacted bug report as `devintern report --run `, with a binding consent preview: - POST /api/runs/:id/report/preview collects and redacts the bundle for the run, keeps the prepared payload in memory under a random preview id (15 min TTL), and returns the attachment summary, omissions, and the full payload. - POST /api/reports submits exactly the stored payload (anonymous reports add only the typed contact email), spools it locally first, and returns the report id or the saved path with a --from retry hint. Shared pieces move out of report/cli.ts into report/submission.ts (spooled submit, contact validation, install kind) and report/preview.ts (payload summary). Collection accepts an explicit run database and worker log dirs so the dashboard reads the run from its own database. Co-Authored-By: Claude Opus 5.5 --- docs/code/dashboard.md | 13 +- docs/code/reporting-bugs.md | 14 +- packages/code/src/dashboard-server.ts | 22 + .../src/lib/observability/dashboard-report.ts | 343 +++++++++++ packages/code/src/lib/report/api.ts | 2 + packages/code/src/lib/report/cli.ts | 86 +-- packages/code/src/lib/report/collect.ts | 21 +- packages/code/src/lib/report/preview.ts | 102 ++++ packages/code/src/lib/report/submission.ts | 78 +++ packages/code/src/lib/report/types.ts | 4 + packages/code/tests/dashboard-report.test.ts | 376 ++++++++++++ packages/dashboard-ui/src/lib/api.ts | 111 ++++ .../dashboard-ui/src/views/RunDetailView.tsx | 2 + .../views/run-detail/ReportProblem.test.tsx | 271 +++++++++ .../src/views/run-detail/ReportProblem.tsx | 546 ++++++++++++++++++ .../src/views/run-detail/report-flow.ts | 121 ++++ 16 files changed, 2047 insertions(+), 65 deletions(-) create mode 100644 packages/code/src/lib/observability/dashboard-report.ts create mode 100644 packages/code/src/lib/report/preview.ts create mode 100644 packages/code/src/lib/report/submission.ts create mode 100644 packages/code/tests/dashboard-report.test.ts create mode 100644 packages/dashboard-ui/src/views/run-detail/ReportProblem.test.tsx create mode 100644 packages/dashboard-ui/src/views/run-detail/ReportProblem.tsx create mode 100644 packages/dashboard-ui/src/views/run-detail/report-flow.ts diff --git a/docs/code/dashboard.md b/docs/code/dashboard.md index 8b4f8aea..20438da4 100644 --- a/docs/code/dashboard.md +++ b/docs/code/dashboard.md @@ -23,6 +23,7 @@ It starts automatically with `devintern worker`. Open [http://localhost:4400](ht - Read the raw agent transcript for any harness execution under the run (clarity, implementation, review, and more) - Open the task or pull request behind a run - Understand why a run failed and retry it after fixing the cause +- Report a DevIntern bug for a run, with a preview of exactly what is sent - Run a recurring automation immediately instead of waiting for its next scheduled time - Hand the worker a one-off task with **New task**, without creating a tracker ticket - Review recent worker logs when something needs attention @@ -74,6 +75,12 @@ Retry after correcting the underlying problem, for example missing credentials, Ad-hoc tasks created with `devintern worker add` show **Re-queue task** instead: the queue row is put back as `pending` under its existing key, so the worker resumes the interrupted attempt's branch and report rather than opening a second branch and pull request. The worker also auto-resumes an interrupted ad-hoc task once at startup when its prior local run recorded a resumable branch with either an implementation report or commits ahead of the repo's base (unless the interrupted attempt's child process is still alive; the row then stays `running` and is re-checked at the next startup, and re-queueing is refused with the pid to stop first); **Re-queue task** remains available for rows that were not resumable or whose automatic-resume budget is exhausted. If the row was already pruned (30 days by default) or is still queued/running, the run detail explains why it cannot be re-queued and points at `devintern worker add`. +## Report a problem with a run + +When a run fails in a way the output does not explain, or completes with the wrong result, open it and choose **Report a problem**. It files the same redacted bug report as `devintern report --run `; see [Reporting Bugs](reporting-bugs.md#report-from-the-dashboard). + +Missing credentials, usage limits, and failed verification checks are configuration issues, not DevIntern bugs: fix those and retry instead. + ## Run an automation now Open **Automations**, find the automation, and choose **Run now**. When automations target more than one repository, the list groups them: automations without a `repo` come first under **General**, then one group per repository in name order, with enabled automations ahead of disabled ones and the soonest next run first. The worker runs it through the same pipeline as a scheduled occurrence, including its normal repository, environment, and overlap protection. @@ -130,7 +137,7 @@ The dashboard and its data stay on the worker machine. It listens only on a loop The API also refuses requests that did not come from the dashboard page itself, so a website open in your browser cannot use it behind your back: - Every `/api` request must be addressed to `localhost`, `127.0.0.1`, or `[::1]` (any port, so remapped SSH tunnels work), or to an [allowed origin](#allowed-origins). Any other host name gets `403`. This blocks DNS-rebinding attacks, where a hostile domain is pointed at your loopback address. -- Requests that change something (retry, run now, new task, and any other non-GET action) must come from the same origin as the address they were sent to, for example `Origin: http://localhost:8440` for `localhost:8440`, and must send `Content-Type: application/json`. Cross-origin requests get `403`; other content types get `415`. A page served on another local port cannot read the API or act on it. +- Requests that change something (retry, run now, new task, sending a bug report, and any other non-GET action) must come from the same origin as the address they were sent to, for example `Origin: http://localhost:8440` for `localhost:8440`, and must send `Content-Type: application/json`. Cross-origin requests get `403`; other content types get `415`. A page served on another local port cannot read the API or act on it. Worker run history lives in the worker's workspace, alongside its other local state. The embedded dashboard remains available when an expired Worker Pilot pauses acquisition. Starting a standalone dashboard requires an active Worker Pilot or automation license. @@ -146,7 +153,7 @@ dashboard = false Watching a long run from your laptop or phone does not require opening the dashboard port. Put an authenticating boundary in front of the loopback listener instead — your SSH client, your Tailscale tailnet, or a reverse proxy with its own login. All three options below leave the dashboard bound to `127.0.0.1`, and they work the same for the worker-embedded dashboard and for `devintern dashboard`. -**Treat the dashboard as sensitive.** Run transcripts, logs, and artifacts can contain credentials or customer data, and `POST /api/runs/:id/retry`, `POST /api/automations/:id/run`, and `POST /api/tasks` act with your repository credentials. That is why the recipes below add a login rather than just moving the bind address. +**Treat the dashboard as sensitive.** Run transcripts, logs, and artifacts can contain credentials or customer data. `POST /api/runs/:id/retry`, `POST /api/automations/:id/run`, and `POST /api/tasks` act with your repository credentials, and `POST /api/reports` sends a bug report as your signed-in DevIntern account. That is why the recipes below add a login rather than just moving the bind address. ### Option 1: SSH tunnel @@ -217,7 +224,7 @@ An allowed origin is a full `scheme://host[:port]` the dashboard trusts in addit | Environment variable (comma-separated) | `DASHBOARD_ALLOWED_ORIGINS=https://dash.example.internal,http://192.168.1.50:8088` | | `devintern dashboard` flag (repeatable) | `--allow-origin https://dash.example.internal` | -A malformed entry stops the worker or dashboard at startup with an error naming it. `[dashboard]` is startup-only: restart the worker after changing it. When the list is not empty, the startup log prints it with a warning that anyone who can reach those addresses gets full dashboard access, including write actions. +A malformed entry stops the worker or dashboard at startup with an error naming it. `[dashboard]` is startup-only: restart the worker after changing it. When the list is not empty, the startup log prints it with a warning that anyone who can reach those addresses gets full dashboard access, including write actions such as retries and bug reports. Allowed origins stop other websites from using your browser against the dashboard. They do not authenticate anyone. Who can reach the address is still decided by your Tailscale ACLs or by the proxy's login (for example Caddy `basic_auth`). diff --git a/docs/code/reporting-bugs.md b/docs/code/reporting-bugs.md index bb4db0e2..cae04d63 100644 --- a/docs/code/reporting-bugs.md +++ b/docs/code/reporting-bugs.md @@ -1,10 +1,10 @@ --- title: "Reporting Bugs" sidebarLabel: "Bug Reports" -description: "File a redacted DevIntern bug report from the CLI, attach a relevant run, and preview the evidence before sending." +description: "File a redacted DevIntern bug report from the CLI or the dashboard, attach a relevant run, and preview the evidence before sending." section: "Automation Reference" order: 2 -dateModified: 2026-10-21 +dateModified: 2026-11-14 tags: ["troubleshooting", "bug-report", "diagnostics", "privacy"] --- @@ -47,6 +47,16 @@ devintern report --last --log /tmp/devintern-run.log Log attachment is bounded (64 KB per source) and credentials are masked. +## Report from the dashboard + +The [worker dashboard](dashboard.md) can file the same report for a run without a terminal. Open the run and choose **Report a problem** (shown prominently for failed, escalated, and abandoned runs and as a secondary action for runs that completed with the wrong behavior). + +1. Enter a title and describe what you expected and what happened. Do not paste secrets, credentials, or customer data. Leave **Share identifiers** off unless the task key and repository slug help triage (it matches `--share-identifiers`). +2. Choose **Preview report**. The dashboard collects the bundle exactly like `devintern report --run ` and shows what is attached, what is never sent, and the full redacted payload. Nothing has been sent yet. +3. Choose **Send report**. The dashboard submits the payload you previewed. Anonymous reports need a contact email on this step, which is added as the report contact; signed-in users follow up through their account. + +The preview is held in the dashboard's memory for 15 minutes; after that, preview again. On success the dialog shows the report id. If the endpoint cannot be reached, the report is saved to `.devintern-code/reports/` like a CLI submission and the dialog shows the `devintern report --from ` command to retry later. + ## Preview, save, and retry ```bash diff --git a/packages/code/src/dashboard-server.ts b/packages/code/src/dashboard-server.ts index 670429ad..e0b92a05 100644 --- a/packages/code/src/dashboard-server.ts +++ b/packages/code/src/dashboard-server.ts @@ -32,6 +32,8 @@ import { } from "./lib/observability/dashboard-api"; import { handleOverview } from "./lib/observability/dashboard-overview"; import { handleCreateTask } from "./lib/observability/dashboard-tasks"; +import { DashboardReports } from "./lib/observability/dashboard-report"; +import type { DashboardReportDeps } from "./lib/observability/dashboard-report"; import { handleWorkspace } from "./lib/observability/dashboard-workspace"; import type { ApiResponse, @@ -202,6 +204,8 @@ export interface DashboardServerOptions { tailscaleAutodetect?: boolean; /** Replacement for `tailscale serve status --json` (tests). */ tailscaleProbe?: TailscaleStatusProbe; + /** Collaborator overrides for bug-report preview/submission (tests). */ + reportDeps?: DashboardReportDeps; } /** Log trusted origins and the access warning they carry. */ @@ -258,6 +262,15 @@ async function createTaskFromRequest(data: DashboardData, request: Request): Pro return handleCreateTask(data, payload); } +/** Parse a JSON request body; undefined when it is missing or malformed. */ +async function readJsonBody(request: Request): Promise { + try { + return await request.json(); + } catch { + return undefined; + } +} + /** Serve a file from the UI dir, confined to it; null on miss/traversal. */ function serveStatic(uiDir: string, pathname: string): Response | null { const relative = pathname === "/" ? "index.html" : pathname.slice(1); @@ -307,6 +320,7 @@ export function startDashboardServer( automationActions: options.automationActions, logDirs: options.logDirs, }); + const reports = new DashboardReports(data, options.reportDeps); const uiDir = resolveUiDir(); const runDetailPattern = /^\/api\/runs\/([^/]+)$/; @@ -316,6 +330,7 @@ export function startDashboardServer( const runArtifactFilePattern = /^\/api\/runs\/([1-9][0-9]*)\/artifacts\/([a-f0-9-]{36})\/file$/; const runArtifactPattern = /^\/api\/runs\/([^/]+)\/artifacts\/([^/]+)$/; const runRetryPattern = /^\/api\/runs\/([^/]+)\/retry$/; + const runReportPreviewPattern = /^\/api\/runs\/([^/]+)\/report\/preview$/; const automationRunPattern = /^\/api\/automations\/([^/]+)\/run$/; const envOrigins = parseDashboardAllowedOriginsEnv(process.env.DASHBOARD_ALLOWED_ORIGINS); @@ -354,6 +369,13 @@ export function startDashboardServer( if (pathname === "/api/tasks") { return createTaskFromRequest(data, request); } + const reportPreview = pathname.match(runReportPreviewPattern); + if (reportPreview) { + return reports.preview(reportPreview[1], await readJsonBody(request)); + } + if (pathname === "/api/reports") { + return reports.submit(await readJsonBody(request)); + } return { status: 405, body: { error: "method not allowed" } }; }; diff --git a/packages/code/src/lib/observability/dashboard-report.ts b/packages/code/src/lib/observability/dashboard-report.ts new file mode 100644 index 00000000..ed78ab69 --- /dev/null +++ b/packages/code/src/lib/observability/dashboard-report.ts @@ -0,0 +1,343 @@ +/** + * Dashboard bug reports: the dashboard's equivalent of `devintern report --run `. + * + * The flow is two-step so the consent preview stays binding: + * + * 1. `POST /api/runs/:id/report/preview` collects and redacts the bundle for + * that run (the same collection as the CLI), stores the prepared payload + * in memory under a random preview id, and returns it for review. + * 2. `POST /api/reports` submits exactly the stored payload. The only field the + * send step may add is the anonymous contact email the user typed there. + * + * Submission spools the payload locally first, like the CLI, so a failed send + * leaves a copy for `devintern report --from `. + */ + +import { randomUUID } from "node:crypto"; + +import { VERSION } from "../cli/bootstrap"; +import { resolveRuntimeStateDir } from "../config/config-dir"; +import { createBugReporter, resolveReportEndpoint, serializePayload } from "../report/api"; +import { resolveIdentity } from "../report/identity"; +import { resolveReportDir } from "../report/paths"; +import { summarizeReportPayload } from "../report/preview"; +import type { ReportPreviewSummary } from "../report/preview"; +import type { ReportIdentity } from "../report/session"; +import { pruneSpool } from "../report/store"; +import { currentInstallKind, isValidContactEmail, submitWithSpool } from "../report/submission"; +import type { BugReporter, BugReportPayload, PreparedReport } from "../report/types"; +import type { ApiResponse, DashboardData } from "./dashboard-api"; + +/** How long a preview stays submittable. */ +const DEFAULT_PREVIEW_TTL_MS = 15 * 60 * 1000; +/** Previews held at once; the oldest is evicted beyond this. */ +const MAX_PREVIEWS = 20; +/** Raw input caps; the payload builder applies the tighter wire caps. */ +const MAX_TITLE_INPUT = 1000; +const MAX_DESCRIPTION_INPUT = 50_000; + +/** Validated `POST /api/runs/:id/report/preview` body. */ +export interface ReportPreviewRequest { + title: string; + description: string; + shareIdentifiers: boolean; +} + +/** Validated `POST /api/reports` body. */ +export interface ReportSubmitRequest { + previewId: string; + email?: string; +} + +type Parsed = { ok: true; value: T } | { ok: false; error: string }; + +function asRecord(value: unknown): Record | null { + return value !== null && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : null; +} + +/** Parse a preview request body. */ +export function parseReportPreviewRequest(value: unknown): Parsed { + const record = asRecord(value); + if (!record) return { ok: false, error: "request body must be a JSON object" }; + const { title, description, shareIdentifiers } = record; + if (typeof title !== "string" || !title.trim()) { + return { ok: false, error: "title is required" }; + } + if (title.length > MAX_TITLE_INPUT) { + return { ok: false, error: `title must be at most ${MAX_TITLE_INPUT} characters` }; + } + if (typeof description !== "string" || !description.trim()) { + return { ok: false, error: "description is required" }; + } + if (description.length > MAX_DESCRIPTION_INPUT) { + return { + ok: false, + error: `description must be at most ${MAX_DESCRIPTION_INPUT} characters`, + }; + } + if (shareIdentifiers !== undefined && typeof shareIdentifiers !== "boolean") { + return { ok: false, error: "shareIdentifiers must be a boolean" }; + } + return { + ok: true, + value: { + title: title.trim(), + description: description.trim(), + shareIdentifiers: shareIdentifiers === true, + }, + }; +} + +/** Parse a submit request body. */ +export function parseReportSubmitRequest(value: unknown): Parsed { + const record = asRecord(value); + if (!record) return { ok: false, error: "request body must be a JSON object" }; + const { previewId, email } = record; + if (typeof previewId !== "string" || !previewId.trim()) { + return { ok: false, error: "previewId is required" }; + } + if (email !== undefined && email !== null && typeof email !== "string") { + return { ok: false, error: "email must be a string" }; + } + const trimmedEmail = typeof email === "string" ? email.trim() : ""; + return { + ok: true, + value: { + previewId: previewId.trim(), + ...(trimmedEmail ? { email: trimmedEmail } : {}), + }, + }; +} + +/** Injectable collaborators (tests stub identity and the network). */ +export interface DashboardReportDeps { + /** Resolve the reporter identity (default: refresh the stored session). */ + resolveIdentity?: (configDir: string) => Promise; + /** Transport for the report endpoint (default: global fetch). */ + fetchImpl?: typeof fetch; + /** Config dir holding the session and `reports/` spool (default: runtime state dir). */ + configDir?: string; + /** Report endpoint override (default: `DEVINTERN_REPORT_URL` or devintern.com). */ + endpoint?: string; + /** Clock for preview expiry. */ + now?: () => number; + /** Preview lifetime. */ + previewTtlMs?: number; + /** Version reported in the environment block. */ + cliVersion?: string; +} + +interface StoredPreview { + runId: number; + prepared: PreparedReport; + reporter: BugReporter; + signedIn: boolean; + expiresAt: number; + submitting: boolean; +} + +/** Preview response: what will be sent, and the payload itself. */ +export interface ReportPreviewResponse { + previewId: string; + expiresAt: number; + runId: number; + signedIn: boolean; + /** Anonymous reports need a contact email on submit. */ + contactRequired: boolean; + destination: string; + title: string; + description: string; + bytes: number; + attachments: Omit; + omitted: string[]; + payload: BugReportPayload; +} + +function badRequest(error: string): ApiResponse { + return { status: 400, body: { error } }; +} + +/** + * Report previews and submissions for one dashboard server. + * + * Previews live in memory only: they hold the access token the submission + * will use and are never written to disk until the user presses Send. + */ +export class DashboardReports { + private readonly previews = new Map(); + private readonly now: () => number; + private readonly ttlMs: number; + + constructor( + private readonly data: DashboardData, + private readonly deps: DashboardReportDeps = {}, + ) { + this.now = deps.now ?? Date.now; + this.ttlMs = deps.previewTtlMs ?? DEFAULT_PREVIEW_TTL_MS; + } + + private get configDir(): string { + return this.deps.configDir ?? resolveRuntimeStateDir(this.data.workingDir); + } + + private prune(): void { + const now = this.now(); + for (const [id, preview] of this.previews) { + if (preview.expiresAt <= now) this.previews.delete(id); + } + while (this.previews.size > MAX_PREVIEWS) { + const oldest = this.previews.keys().next().value; + if (oldest === undefined) break; + this.previews.delete(oldest); + } + } + + /** `POST /api/runs/:id/report/preview`. */ + async preview(idParam: string, body: unknown): Promise { + const runId = Number.parseInt(idParam, 10); + if (!Number.isFinite(runId) || runId <= 0 || String(runId) !== idParam) { + return badRequest("run id must be a positive integer"); + } + const parsed = parseReportPreviewRequest(body); + if (!parsed.ok) return badRequest(parsed.error); + if (!this.data.getRunDetail(runId)) { + return { status: 404, body: { error: `run ${runId} not found` } }; + } + + const configDir = this.configDir; + const identity = await (this.deps.resolveIdentity ?? resolveIdentity)(configDir); + const reporter = createBugReporter({ + accessToken: identity.accessToken, + signedIn: identity.signedIn, + cliVersion: this.deps.cliVersion ?? VERSION, + installKind: currentInstallKind(), + cwd: this.data.workingDir, + configDir, + runDbPath: this.data.dbPath, + workerLogDirs: [this.data.workspaceDir], + ...(this.deps.endpoint ? { endpoint: this.deps.endpoint } : {}), + ...(this.deps.fetchImpl ? { fetchImpl: this.deps.fetchImpl } : {}), + }); + + let prepared: PreparedReport; + try { + prepared = await reporter.prepare({ + title: parsed.value.title, + description: parsed.value.description, + source: "cli", + runId, + diagnostics: true, + logs: true, + shareIdentifiers: parsed.value.shareIdentifiers, + }); + } catch (error) { + return { + status: 422, + body: { error: `Could not prepare the report: ${(error as Error).message}` }, + }; + } + + const previewId = randomUUID(); + const expiresAt = this.now() + this.ttlMs; + this.previews.set(previewId, { + runId, + prepared, + reporter, + signedIn: identity.signedIn, + expiresAt, + submitting: false, + }); + this.prune(); + + const { omitted, ...attachments } = summarizeReportPayload(prepared.payload, { + shareIdentifiers: parsed.value.shareIdentifiers, + }); + const response: ReportPreviewResponse = { + previewId, + expiresAt, + runId, + signedIn: identity.signedIn, + contactRequired: !identity.signedIn, + destination: resolveReportEndpoint(this.deps.endpoint), + title: prepared.payload.title, + description: prepared.payload.description, + bytes: prepared.bytes, + attachments, + omitted, + payload: prepared.payload, + }; + return { status: 200, body: response }; + } + + /** `POST /api/reports`. */ + async submit(body: unknown): Promise { + const parsed = parseReportSubmitRequest(body); + if (!parsed.ok) return badRequest(parsed.error); + this.prune(); + const stored = this.previews.get(parsed.value.previewId); + if (!stored) { + return { + status: 404, + body: { error: "This preview expired or does not exist. Preview the report again." }, + }; + } + if (stored.submitting) { + return { status: 409, body: { error: "This report is already being sent." } }; + } + + const email = parsed.value.email; + if (!stored.signedIn && !isValidContactEmail(email)) { + return badRequest( + "Anonymous reports need a valid contact email. Enter one or run `devintern login`.", + ); + } + + // Anonymous reports carry the contact typed on the send step; everything + // else is the previewed payload, unchanged. + let prepared = stored.prepared; + if (!stored.signedIn && email) { + const payload: BugReportPayload = { + ...prepared.payload, + contact: { email, signedIn: false }, + }; + prepared = { payload, bytes: Buffer.byteLength(serializePayload(payload), "utf8") }; + } + + const reportDir = resolveReportDir(this.configDir); + pruneSpool(reportDir); + stored.submitting = true; + try { + const outcome = await submitWithSpool(stored.reporter, prepared, reportDir); + if (outcome.ok) { + this.previews.delete(parsed.value.previewId); + const { result } = outcome; + return { + status: 200, + body: { + status: "sent", + reportId: result.id, + shortId: result.id.slice(0, 8), + ...(result.url ? { url: result.url } : {}), + ...(result.receipt ? { receipt: result.receipt } : {}), + savedPath: outcome.savedPath, + }, + }; + } + // Keep the preview so the user can try again from the dialog. + return { + status: 502, + body: { + status: "saved", + error: outcome.error, + authExpired: outcome.authExpired, + savedPath: outcome.savedPath, + retryCommand: `devintern report --from ${outcome.savedPath}`, + }, + }; + } finally { + stored.submitting = false; + } + } +} diff --git a/packages/code/src/lib/report/api.ts b/packages/code/src/lib/report/api.ts index 7b13da27..9e27e105 100644 --- a/packages/code/src/lib/report/api.ts +++ b/packages/code/src/lib/report/api.ts @@ -142,6 +142,8 @@ export function createBugReporter(options: BugReporterOptions = {}): BugReporter last: draft.last, shareIdentifiers: draft.shareIdentifiers, command: draft.command, + ...(options.runDbPath ? { runDbPath: options.runDbPath } : {}), + ...(options.workerLogDirs ? { workerLogDirs: options.workerLogDirs } : {}), }); const redaction = redactionFor(draft, collected, cwd); const contactEmail = (draft.contactEmail ?? options.contactEmail)?.trim(); diff --git a/packages/code/src/lib/report/cli.ts b/packages/code/src/lib/report/cli.ts index dda8558d..2adaa7fa 100644 --- a/packages/code/src/lib/report/cli.ts +++ b/packages/code/src/lib/report/cli.ts @@ -10,30 +10,25 @@ import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { dirname, resolve } from "node:path"; import { createInterface } from "node:readline/promises"; -import { detectInstallKind } from "@devintern/utils"; import { VERSION, loadEnvironment } from "../cli/bootstrap"; import { resolveRuntimeStateDir } from "../config/config-dir"; import { createBugReporter, resolveReportEndpoint } from "./api"; import { resolveReportDir } from "./paths"; -import { DEFAULT_REPORT_ENDPOINT, ReportSubmitError } from "./client"; +import { DEFAULT_REPORT_ENDPOINT } from "./client"; import { resolveIdentity } from "./identity"; import { readStoredIdentity } from "./session"; +import { REPORT_IDENTIFIERS, summarizeReportPayload } from "./preview"; import { findSpoolEntry, - findSpoolEntryFile, isBugReportPayload, isSpoolEntry, listSpoolEntries, - markSpoolError, - markSpoolSubmitted, pruneSpool, readSpoolFile, - saveSpoolEntry, } from "./store"; +import { currentInstallKind, isValidContactEmail, submitWithSpool } from "./submission"; import type { BugReportPayload, PreparedReport } from "./types"; -const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; - interface ReportCliOptions { title?: string; body?: string; @@ -298,13 +293,16 @@ async function promptMultiline(): Promise { /** Human-readable preview of what the payload contains. */ function renderPreview(payload: BugReportPayload, bytes: number): string { + const summary = summarizeReportPayload(payload, { + shareIdentifiers: Boolean(payload.context?.run?.taskKey), + }); + const { environment, run, logs } = summary; const lines = ["", "Attachments:"]; lines.push( - ` ✓ environment: devintern ${payload.environment.cliVersion}, ` + - `${payload.environment.runtime.os}/${payload.environment.runtime.arch}` + - (payload.environment.harness ? `, harness ${payload.environment.harness.name}` : ""), + ` ✓ environment: devintern ${environment.cliVersion}, ` + + `${environment.os}/${environment.arch}` + + (environment.harness ? `, harness ${environment.harness}` : ""), ); - const run = payload.context?.run; if (run) { lines.push( ` ✓ run #${run.id ?? "?"}: ${run.status}` + @@ -312,36 +310,23 @@ function renderPreview(payload: BugReportPayload, bytes: number): string { (run.durationMs !== undefined ? `, ${Math.round(run.durationMs / 1000)}s` : ""), ); } - const logs = payload.context?.logs ?? []; if (logs.length > 0) { - const logBytes = Buffer.byteLength(JSON.stringify(logs), "utf8"); + const logBytes = Buffer.byteLength(JSON.stringify(payload.context?.logs ?? []), "utf8"); lines.push(` ✓ logs: ${logs.length} attachment(s), ${formatBytes(logBytes)}`); } - if (payload.context?.evidence?.length) { - lines.push(` ✓ evidence: ${payload.context.evidence.length} item(s)`); + if (summary.evidenceCount > 0) { + lines.push(` ✓ evidence: ${summary.evidenceCount} item(s)`); } lines.push(" ✗ never sent: task description, prompts, diffs, env values, credentials"); lines.push( - payload.context?.run?.taskKey - ? " • identifiers: task key and repository included" - : " • identifiers: task key and repository redacted", + summary.identifiersShared + ? ` • identifiers: ${REPORT_IDENTIFIERS} included` + : ` • identifiers: ${REPORT_IDENTIFIERS} redacted`, ); lines.push("", `Payload: ${formatBytes(bytes)}`); return lines.join("\n"); } -/** Best-effort install kind for triage. */ -function currentInstallKind(): string { - try { - return detectInstallKind({ - scriptPath: process.argv[1] ?? "", - packageName: "@getdevintern/code", - }); - } catch { - return "unknown"; - } -} - /** Print a report and exit. */ function finish(code: number): never { process.exit(code); @@ -439,7 +424,7 @@ async function resolveContactEmail( if (needsContact && !email && interactive) { email = (await promptLine("Contact email (so we can follow up): "))?.trim(); } - if (needsContact && (!email || !EMAIL_PATTERN.test(email))) { + if (needsContact && !isValidContactEmail(email)) { console.error( "❌ Anonymous reports need a valid contact email. " + "Use --email
or run `devintern login`.", @@ -456,14 +441,9 @@ async function submitReport( context: { options: ReportCliOptions; reportDir: string }, ): Promise { const { options, reportDir } = context; - const reportId = prepared.payload.reportId; - let savedPath = findSpoolEntryFile(reportDir, reportId)?.path; - if (!savedPath) { - savedPath = saveSpoolEntry(reportDir, prepared.payload); - } - try { - const result = await reporter.submitPrepared(prepared); - markSpoolSubmitted(reportDir, reportId, result); + const outcome = await submitWithSpool(reporter, prepared, reportDir); + if (outcome.ok) { + const { result } = outcome; if (options.json) { console.log(JSON.stringify({ ok: true, ...result })); } else { @@ -476,22 +456,20 @@ async function submitReport( console.log(" A saved copy is in your local report history (`devintern report list`)."); } finish(0); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - markSpoolError(reportDir, reportId, message); - if (options.json) { - console.log(JSON.stringify({ ok: false, error: message, savedPath })); - } else { - console.error(`\n❌ ${message}`); - if (error instanceof ReportSubmitError && error.status === 401) { - console.error(" Your sign-in may have expired. Run `devintern login` and retry."); - console.error(" To file anonymously instead, run `devintern logout` and add --email."); - } - console.error(` Saved the report to ${savedPath}`); - console.error(` Retry with: devintern report --from ${savedPath}`); + } + const { error: message, savedPath } = outcome; + if (options.json) { + console.log(JSON.stringify({ ok: false, error: message, savedPath })); + } else { + console.error(`\n❌ ${message}`); + if (outcome.authExpired) { + console.error(" Your sign-in may have expired. Run `devintern login` and retry."); + console.error(" To file anonymously instead, run `devintern logout` and add --email."); } - finish(1); + console.error(` Saved the report to ${savedPath}`); + console.error(` Retry with: devintern report --from ${savedPath}`); } + finish(1); } /** Handle `--from`: resubmit an already prepared payload. */ diff --git a/packages/code/src/lib/report/collect.ts b/packages/code/src/lib/report/collect.ts index 0093fafb..4e87e732 100644 --- a/packages/code/src/lib/report/collect.ts +++ b/packages/code/src/lib/report/collect.ts @@ -49,6 +49,10 @@ export interface CollectDiagnosticsOptions { last?: boolean; shareIdentifiers?: boolean; command?: string; + /** Run database to read; defaults to `WEBHOOK_QUEUE_DB` or the nearest `queue.db`. */ + runDbPath?: string; + /** Directories holding worker capture files; defaults to the workspace home. */ + workerLogDirs?: string[]; } export interface CollectedDiagnostics { @@ -139,6 +143,7 @@ function collectFileLogs( /** Read the worker capture files when a workspace exists on this host. */ function collectWorkerLogs(options: { + dirs: string[]; cwd: string; repoSlug: string | null; taskKey: string | null; @@ -146,7 +151,7 @@ function collectWorkerLogs(options: { }): ReportLogAttachment[] { try { const result = readWorkerLogs({ - dirs: [resolveWorkspaceDir()], + dirs: options.dirs, limit: 200, maxBytesPerFile: DEFAULT_LOG_BYTES, }); @@ -200,15 +205,16 @@ interface StageRow { } /** Resolve the local queue database without importing the CLI's path helpers. */ -function resolveRunDbPath(cwd: string): string { +function resolveRunDbPath(cwd: string, explicit?: string): string { + if (explicit?.trim()) return resolve(explicit.trim()); const override = process.env.WEBHOOK_QUEUE_DB?.trim(); if (override) return override; return join(resolveReportConfigDir(undefined, cwd), "queue.db"); } /** Open the run database read-only, or return null when it does not exist. */ -function openRunDb(cwd: string): Database | null { - const path = resolveRunDbPath(cwd); +function openRunDb(cwd: string, explicit?: string): Database | null { + const path = resolveRunDbPath(cwd, explicit); if (!existsSync(path)) return null; try { return new Database(path, { readonly: true }); @@ -400,7 +406,7 @@ export async function collectDiagnostics( return { environment, repoSlug, taskKey: options.taskKey ?? null }; } - const db = openRunDb(cwd); + const db = openRunDb(cwd, options.runDbPath); const run = db ? selectRun(db, options) : null; // Redact the run's task key even when it is omitted from the payload. A // `--last` report has no explicit --task value, but the failure reason and @@ -431,7 +437,10 @@ export async function collectDiagnostics( if (options.logs !== false) { const logs = [ ...collectFileLogs(options.logPaths ?? [], redaction), - ...collectWorkerLogs(redaction), + ...collectWorkerLogs({ + ...redaction, + dirs: options.workerLogDirs ?? [resolveWorkspaceDir()], + }), ]; if (logs.length > 0) context.logs = logs; } diff --git a/packages/code/src/lib/report/preview.ts b/packages/code/src/lib/report/preview.ts new file mode 100644 index 00000000..d194f411 --- /dev/null +++ b/packages/code/src/lib/report/preview.ts @@ -0,0 +1,102 @@ +/** + * Consent preview of a prepared report. + * + * Both the CLI preview and the dashboard's preview step describe a payload + * from the same summary, so "what will be sent" reads identically wherever + * the user files a report. + */ + +import type { BugReportPayload } from "./types"; + +/** Content classes the collector never reads into a report. */ +export const REPORT_NEVER_SENT = [ + "task description", + "prompts", + "agent transcripts", + "diffs", + "source files", + "environment values", + "credentials", +] as const; + +/** Withheld unless the user opts into sharing identifiers. */ +export const REPORT_IDENTIFIERS = "task key and repository slug"; + +export interface ReportPreviewEnvironment { + cliVersion: string; + installKind: string; + os: string; + arch: string; + ci: boolean; + harness?: string; + tracker?: string; + sandbox?: string; + signedIn: boolean; +} + +export interface ReportPreviewRun { + id?: number; + status: string; + failedStep?: string; + durationMs?: number; + stageCount: number; + hasReason: boolean; +} + +export interface ReportPreviewLog { + source: string; + lines: number; + bytes: number; + truncated: boolean; +} + +/** Structured "what is attached / what is omitted" view of a payload. */ +export interface ReportPreviewSummary { + environment: ReportPreviewEnvironment; + run: ReportPreviewRun | null; + logs: ReportPreviewLog[]; + evidenceCount: number; + identifiersShared: boolean; + omitted: string[]; +} + +/** Summarize a payload for a consent preview. */ +export function summarizeReportPayload( + payload: BugReportPayload, + options: { shareIdentifiers: boolean }, +): ReportPreviewSummary { + const env = payload.environment; + const run = payload.context?.run; + return { + environment: { + cliVersion: env.cliVersion, + installKind: env.installKind, + os: env.runtime.os, + arch: env.runtime.arch, + ci: env.runtime.ci, + ...(env.harness ? { harness: env.harness.name } : {}), + ...(env.tracker ? { tracker: env.tracker } : {}), + ...(env.sandbox ? { sandbox: env.sandbox } : {}), + signedIn: env.signedIn, + }, + run: run + ? { + ...(run.id !== undefined ? { id: run.id } : {}), + status: run.status, + ...(run.failedStep ? { failedStep: run.failedStep } : {}), + ...(run.durationMs !== undefined ? { durationMs: run.durationMs } : {}), + stageCount: run.stages.length, + hasReason: Boolean(run.reason), + } + : null, + logs: (payload.context?.logs ?? []).map((log) => ({ + source: log.source, + lines: log.lines, + bytes: Buffer.byteLength(log.text, "utf8"), + truncated: log.truncated === true, + })), + evidenceCount: payload.context?.evidence?.length ?? 0, + identifiersShared: options.shareIdentifiers, + omitted: [...REPORT_NEVER_SENT, ...(options.shareIdentifiers ? [] : [REPORT_IDENTIFIERS])], + }; +} diff --git a/packages/code/src/lib/report/submission.ts b/packages/code/src/lib/report/submission.ts new file mode 100644 index 00000000..a8ad80ba --- /dev/null +++ b/packages/code/src/lib/report/submission.ts @@ -0,0 +1,78 @@ +/** + * Submission helpers shared by every manual report entry point. + * + * `devintern report` and the dashboard's "Report a problem" action both + * spool the payload before sending, record the outcome, and validate the + * contact address the same way. Keeping that here means the dashboard does + * not reimplement CLI behavior and the CLI stays a thin argv/stdout layer. + */ + +import { detectInstallKind } from "@devintern/utils"; +import { ReportSubmitError } from "./client"; +import { findSpoolEntryFile, markSpoolError, markSpoolSubmitted, saveSpoolEntry } from "./store"; +import type { BugReporter, PreparedReport, SubmittedReport } from "./types"; + +const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + +/** Whether an address is acceptable as an anonymous report contact. */ +export function isValidContactEmail(email: string | undefined): email is string { + return typeof email === "string" && EMAIL_PATTERN.test(email.trim()); +} + +/** Best-effort install kind for triage. */ +export function currentInstallKind(): string { + try { + return detectInstallKind({ + scriptPath: process.argv[1] ?? "", + packageName: "@getdevintern/code", + }); + } catch { + return "unknown"; + } +} + +/** Outcome of a spooled submission. Both branches name the local copy. */ +export type SpooledSubmission = + | { ok: true; result: SubmittedReport; savedPath: string } + | { + ok: false; + error: string; + /** HTTP status from the report endpoint, when it answered. */ + status?: number; + /** The endpoint rejected the access token (sign-in expired). */ + authExpired: boolean; + savedPath: string; + }; + +/** + * Save a prepared payload locally, submit it, and record the outcome. + * + * The payload is written before the request so a failure always leaves a copy + * the user can resend with `devintern report --from `. Resubmitting + * the same report id reuses its existing spool entry. + */ +export async function submitWithSpool( + reporter: Pick, + prepared: PreparedReport, + reportDir: string, +): Promise { + const reportId = prepared.payload.reportId; + const savedPath = + findSpoolEntryFile(reportDir, reportId)?.path ?? saveSpoolEntry(reportDir, prepared.payload); + try { + const result = await reporter.submitPrepared(prepared); + markSpoolSubmitted(reportDir, reportId, result); + return { ok: true, result, savedPath }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + markSpoolError(reportDir, reportId, message); + const status = error instanceof ReportSubmitError ? error.status : undefined; + return { + ok: false, + error: message, + ...(status !== undefined ? { status } : {}), + authExpired: status === 401, + savedPath, + }; + } +} diff --git a/packages/code/src/lib/report/types.ts b/packages/code/src/lib/report/types.ts index c82e06f8..f1575714 100644 --- a/packages/code/src/lib/report/types.ts +++ b/packages/code/src/lib/report/types.ts @@ -151,6 +151,10 @@ export interface BugReporterOptions { now?: () => Date; /** Submission timeout. Default 10 seconds. */ timeoutMs?: number; + /** Run database for run lookup; defaults to `WEBHOOK_QUEUE_DB` or the nearest `queue.db`. */ + runDbPath?: string; + /** Directories holding worker capture files; defaults to the workspace home. */ + workerLogDirs?: string[]; } export interface BugReporter { diff --git a/packages/code/tests/dashboard-report.test.ts b/packages/code/tests/dashboard-report.test.ts new file mode 100644 index 00000000..9ad28aad --- /dev/null +++ b/packages/code/tests/dashboard-report.test.ts @@ -0,0 +1,376 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { startDashboardServer } from "../src/dashboard-server"; +import { DashboardData } from "../src/lib/observability/dashboard-api"; +import { + DashboardReports, + parseReportPreviewRequest, + parseReportSubmitRequest, +} from "../src/lib/observability/dashboard-report"; +import type { + DashboardReportDeps, + ReportPreviewResponse, +} from "../src/lib/observability/dashboard-report"; +import type { ReportIdentity } from "../src/lib/report/session"; +import { RunStore } from "../src/lib/state/run-recorder"; + +interface Captured { + url: string; + body: unknown; + headers: Record; +} + +let dir: string; +let dbPath: string; +let configDir: string; +let workspaceDir: string; +let data: DashboardData; + +beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), "dashboard-report-")); + dbPath = join(dir, "queue.db"); + configDir = join(dir, ".devintern-code"); + workspaceDir = join(dir, "workspace"); + mkdirSync(configDir, { recursive: true }); + mkdirSync(workspaceDir, { recursive: true }); + data = new DashboardData({ dbPath, workingDir: dir, workspaceDir, logDirs: [] }); +}); + +afterEach(() => { + data.close(); + rmSync(dir, { recursive: true, force: true }); +}); + +function seedFailedRun(taskKey = "PROJ-77"): number { + const store = new RunStore(dbPath); + const id = store.createRun({ origin: "task", taskKey, harness: "claude-code" }); + const stepStartedAt = store.startStep(id, "implement"); + store.completeStep(id, "implement", stepStartedAt); + store.finishRun(id, "failed", `agent exited non-zero while working on ${taskKey}`); + store.close(); + return id; +} + +function harness( + options: { + identity?: ReportIdentity; + respond?: (captured: Captured) => Response; + now?: () => number; + } = {}, +): { reports: DashboardReports; requests: Captured[]; deps: DashboardReportDeps } { + const requests: Captured[] = []; + const fetchImpl = (async (input: Parameters[0], init?: RequestInit) => { + const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url; + const captured: Captured = { + url, + body: JSON.parse(String(init?.body ?? "null")), + headers: (init?.headers ?? {}) as Record, + }; + requests.push(captured); + return ( + options.respond?.(captured) ?? + new Response(JSON.stringify({ id: "rep_1234567890abcdef", receipt: "rcpt-1" }), { + status: 201, + }) + ); + }) as typeof fetch; + const deps: DashboardReportDeps = { + configDir, + endpoint: "https://reports.example.test/api/reports", + cliVersion: "9.9.9", + fetchImpl, + resolveIdentity: async () => options.identity ?? { signedIn: false }, + ...(options.now ? { now: options.now } : {}), + }; + return { reports: new DashboardReports(data, deps), requests, deps }; +} + +const DRAFT = { + title: "Worker marks run failed after PR opened", + description: "Expected success; the PR was opened but the run shows failed.", +}; + +describe("request parsing", () => { + test("preview requires a title and description", () => { + expect(parseReportPreviewRequest(null).ok).toBe(false); + expect(parseReportPreviewRequest({ title: " ", description: "x" }).ok).toBe(false); + expect(parseReportPreviewRequest({ title: "t" }).ok).toBe(false); + expect(parseReportPreviewRequest({ ...DRAFT, shareIdentifiers: "yes" }).ok).toBe(false); + expect(parseReportPreviewRequest(DRAFT)).toEqual({ + ok: true, + value: { ...DRAFT, shareIdentifiers: false }, + }); + }); + + test("submit requires a preview id", () => { + expect(parseReportSubmitRequest({}).ok).toBe(false); + expect(parseReportSubmitRequest({ previewId: "p", email: 4 }).ok).toBe(false); + expect(parseReportSubmitRequest({ previewId: "p", email: " a@b.co " })).toEqual({ + ok: true, + value: { previewId: "p", email: "a@b.co" }, + }); + }); +}); + +describe("report preview", () => { + test("builds the redacted payload for the run and summarizes it", async () => { + const runId = seedFailedRun(); + const { reports, requests } = harness(); + + const response = await reports.preview(String(runId), DRAFT); + expect(response.status).toBe(200); + const body = response.body as ReportPreviewResponse; + + expect(body.previewId).toMatch(/^[0-9a-f-]{36}$/); + expect(body.runId).toBe(runId); + expect(body.signedIn).toBe(false); + expect(body.contactRequired).toBe(true); + expect(body.destination).toBe("https://reports.example.test/api/reports"); + expect(body.title).toBe(DRAFT.title); + expect(body.attachments.environment.cliVersion).toBe("9.9.9"); + expect(body.attachments.run).toMatchObject({ id: runId, status: "failed", hasReason: true }); + expect(body.attachments.identifiersShared).toBe(false); + expect(body.omitted).toContain("credentials"); + expect(body.omitted).toContain("task key and repository slug"); + + // Same collection as `--run `: the run summary comes from the + // dashboard's database and the task key is redacted by default. + expect(body.payload.context?.run?.id).toBe(runId); + expect(body.payload.context?.run?.taskKey).toBeUndefined(); + expect(body.payload.context?.run?.reason).not.toContain("PROJ-77"); + expect(body.payload.contact).toBeUndefined(); + // Nothing is sent until the user submits. + expect(requests).toHaveLength(0); + }); + + test("shareIdentifiers keeps the task key", async () => { + const runId = seedFailedRun(); + const { reports } = harness(); + const body = (await reports.preview(String(runId), { ...DRAFT, shareIdentifiers: true })) + .body as ReportPreviewResponse; + expect(body.payload.context?.run?.taskKey).toBe("PROJ-77"); + expect(body.attachments.identifiersShared).toBe(true); + expect(body.omitted).not.toContain("task key and repository slug"); + }); + + test("rejects bad ids, unknown runs, and invalid bodies", async () => { + seedFailedRun(); + const { reports } = harness(); + expect((await reports.preview("abc", DRAFT)).status).toBe(400); + expect((await reports.preview("0", DRAFT)).status).toBe(400); + expect((await reports.preview("999", DRAFT)).status).toBe(404); + expect((await reports.preview("1", { title: "only a title" })).status).toBe(400); + expect((await reports.preview("1", undefined)).status).toBe(400); + }); +}); + +describe("report submission", () => { + test("submits exactly the previewed payload plus the typed contact", async () => { + const runId = seedFailedRun(); + const { reports, requests } = harness(); + const preview = (await reports.preview(String(runId), DRAFT)).body as ReportPreviewResponse; + + const response = await reports.submit({ + previewId: preview.previewId, + email: "dev@example.com", + }); + expect(response.status).toBe(200); + expect(response.body).toMatchObject({ + status: "sent", + reportId: "rep_1234567890abcdef", + shortId: "rep_1234", + receipt: "rcpt-1", + }); + + expect(requests).toHaveLength(1); + expect(requests[0]!.body).toEqual({ + ...preview.payload, + contact: { email: "dev@example.com", signedIn: false }, + }); + + // Previews are single-use once sent. + expect((await reports.submit({ previewId: preview.previewId, email: "a@b.co" })).status).toBe( + 404, + ); + }); + + test("signed-in users submit without an email and forward the token", async () => { + const runId = seedFailedRun(); + const { reports, requests } = harness({ + identity: { signedIn: true, accessToken: "token-abc", email: "me@example.com" }, + }); + const preview = (await reports.preview(String(runId), DRAFT)).body as ReportPreviewResponse; + expect(preview.signedIn).toBe(true); + expect(preview.contactRequired).toBe(false); + + const response = await reports.submit({ previewId: preview.previewId }); + expect(response.status).toBe(200); + expect(requests[0]!.body).toEqual(preview.payload); + expect(requests[0]!.headers.authorization).toBe("Bearer token-abc"); + }); + + test("anonymous submissions without a valid email are rejected", async () => { + const runId = seedFailedRun(); + const { reports, requests } = harness(); + const preview = (await reports.preview(String(runId), DRAFT)).body as ReportPreviewResponse; + + expect((await reports.submit({ previewId: preview.previewId })).status).toBe(400); + expect( + (await reports.submit({ previewId: preview.previewId, email: "not-an-email" })).status, + ).toBe(400); + expect(requests).toHaveLength(0); + }); + + test("unknown and expired preview ids are rejected", async () => { + const runId = seedFailedRun(); + let now = 1_000_000; + const { reports, requests } = harness({ now: () => now }); + expect((await reports.submit({ previewId: "nope", email: "a@b.co" })).status).toBe(404); + + const preview = (await reports.preview(String(runId), DRAFT)).body as ReportPreviewResponse; + now += 16 * 60 * 1000; + expect((await reports.submit({ previewId: preview.previewId, email: "a@b.co" })).status).toBe( + 404, + ); + expect(requests).toHaveLength(0); + }); + + test("a failed send saves the payload locally with a --from retry hint", async () => { + const runId = seedFailedRun(); + const { reports } = harness({ + respond: () => new Response(JSON.stringify({ error: "maintenance" }), { status: 503 }), + }); + const preview = (await reports.preview(String(runId), DRAFT)).body as ReportPreviewResponse; + + const response = await reports.submit({ previewId: preview.previewId, email: "a@b.co" }); + expect(response.status).toBe(502); + const body = response.body as { + status: string; + error: string; + savedPath: string; + retryCommand: string; + authExpired: boolean; + }; + expect(body.status).toBe("saved"); + expect(body.error).toContain("maintenance"); + expect(body.authExpired).toBe(false); + expect(body.savedPath.startsWith(join(configDir, "reports"))).toBe(true); + expect(body.retryCommand).toBe(`devintern report --from ${body.savedPath}`); + expect(existsSync(body.savedPath)).toBe(true); + const saved = JSON.parse(readFileSync(body.savedPath, "utf8")) as { + status: string; + error?: string; + payload: { reportId: string }; + }; + expect(saved.status).toBe("pending"); + expect(saved.error).toContain("maintenance"); + expect(saved.payload.reportId).toBe(preview.payload.reportId); + + // The preview survives a failed send so the dialog can try again. + const retried = await reports.submit({ previewId: preview.previewId, email: "a@b.co" }); + expect(retried.status).toBe(502); + expect((retried.body as { savedPath: string }).savedPath).toBe(body.savedPath); + }); + + test("an expired sign-in is flagged", async () => { + const runId = seedFailedRun(); + const { reports } = harness({ + identity: { signedIn: true, accessToken: "stale" }, + respond: () => new Response(JSON.stringify({ error: "invalid token" }), { status: 401 }), + }); + const preview = (await reports.preview(String(runId), DRAFT)).body as ReportPreviewResponse; + const response = await reports.submit({ previewId: preview.previewId }); + expect((response.body as { authExpired: boolean }).authExpired).toBe(true); + }); +}); + +describe("dashboard server routes", () => { + function startServer(deps: DashboardReportDeps) { + return startDashboardServer({ + port: 0, + dbPath, + workingDir: dir, + workspaceDir, + logDirs: [], + reportDeps: deps, + }); + } + + /** Same-origin write headers, as the dashboard page sends them. */ + function writeHeaders(base: string): Record { + return { "Content-Type": "application/json", Origin: new URL(base).origin }; + } + + test("preview and submit are served as JSON POSTs", async () => { + const runId = seedFailedRun(); + const { deps, requests } = harness(); + const server = startServer(deps); + try { + const base = `http://127.0.0.1:${server.port}`; + const previewResponse = await fetch(`${base}/api/runs/${runId}/report/preview`, { + method: "POST", + headers: writeHeaders(base), + body: JSON.stringify(DRAFT), + }); + expect(previewResponse.status).toBe(200); + const preview = (await previewResponse.json()) as ReportPreviewResponse; + + const malformed = await fetch(`${base}/api/reports`, { + method: "POST", + headers: writeHeaders(base), + body: "{not json", + }); + expect(malformed.status).toBe(400); + + const submit = await fetch(`${base}/api/reports`, { + method: "POST", + headers: writeHeaders(base), + body: JSON.stringify({ previewId: preview.previewId, email: "dev@example.com" }), + }); + expect(submit.status).toBe(200); + expect(requests).toHaveLength(1); + + // POST-only, like the other action routes. + expect((await fetch(`${base}/api/reports`)).status).toBe(404); + } finally { + server.stop(); + } + }); + + test("cross-origin report requests are refused before reaching the handlers", async () => { + const runId = seedFailedRun(); + const { deps, requests } = harness(); + const server = startServer(deps); + try { + const base = `http://127.0.0.1:${server.port}`; + const crossOrigin = { "Content-Type": "application/json", Origin: "https://evil.example" }; + const previewResponse = await fetch(`${base}/api/runs/${runId}/report/preview`, { + method: "POST", + headers: crossOrigin, + body: JSON.stringify(DRAFT), + }); + expect(previewResponse.status).toBe(403); + + // Even with a valid preview id, a cross-origin submit never sends. + const valid = (await ( + await fetch(`${base}/api/runs/${runId}/report/preview`, { + method: "POST", + headers: writeHeaders(base), + body: JSON.stringify(DRAFT), + }) + ).json()) as ReportPreviewResponse; + const submit = await fetch(`${base}/api/reports`, { + method: "POST", + headers: crossOrigin, + body: JSON.stringify({ previewId: valid.previewId, email: "dev@example.com" }), + }); + expect(submit.status).toBe(403); + expect(requests).toHaveLength(0); + } finally { + server.stop(); + } + }); +}); diff --git a/packages/dashboard-ui/src/lib/api.ts b/packages/dashboard-ui/src/lib/api.ts index 0dd3261e..d46a8a1d 100644 --- a/packages/dashboard-ui/src/lib/api.ts +++ b/packages/dashboard-ui/src/lib/api.ts @@ -199,6 +199,117 @@ export async function triggerRunRetry(runId: number): Promise<{ return { ok: response.ok, body }; } +/** What the user typed in the "Report a problem" form. */ +export interface ReportDraft { + title: string; + description: string; + shareIdentifiers: boolean; +} + +/** Redacted bug-report payload exactly as it will be sent (opaque to the UI). */ +export interface ReportPayload { + reportId: string; + title: string; + description: string; + [key: string]: unknown; +} + +/** `POST /api/runs/:id/report/preview` response: what will be sent. */ +export interface ReportPreview { + previewId: string; + expiresAt: number; + runId: number; + signedIn: boolean; + /** Anonymous reports need a contact email to submit. */ + contactRequired: boolean; + destination: string; + title: string; + description: string; + bytes: number; + attachments: { + environment: { + cliVersion: string; + installKind: string; + os: string; + arch: string; + ci: boolean; + harness?: string; + tracker?: string; + sandbox?: string; + signedIn: boolean; + }; + run: { + id?: number; + status: string; + failedStep?: string; + durationMs?: number; + stageCount: number; + hasReason: boolean; + } | null; + logs: { source: string; lines: number; bytes: number; truncated: boolean }[]; + evidenceCount: number; + identifiersShared: boolean; + }; + omitted: string[]; + payload: ReportPayload; +} + +/** `POST /api/reports` outcome. */ +export type ReportSubmitResult = + | { status: "sent"; reportId: string; shortId: string; url?: string; receipt?: string } + | { + status: "saved"; + error: string; + authExpired: boolean; + savedPath: string; + retryCommand: string; + }; + +/** Read an error message from a JSON error body. */ +function errorMessage(body: unknown, fallback: string): string { + if (body !== null && typeof body === "object" && "error" in body) { + const { error } = body as { error: unknown }; + if (typeof error === "string" && error.trim()) return error; + } + return fallback; +} + +/** + * Build the redacted report for a run without sending it. Throws with the + * server's message on failure. + */ +export async function previewRunReport(runId: number, draft: ReportDraft): Promise { + const response = await fetch(`/api/runs/${runId}/report/preview`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(draft), + }); + const body: unknown = await response.json().catch(() => ({})); + if (!response.ok) { + throw new Error(errorMessage(body, `Preview failed (HTTP ${response.status})`)); + } + return body as ReportPreview; +} + +/** + * Send a previewed report. A failed delivery that was saved locally resolves + * with `status: "saved"`; validation errors (missing email, expired preview) + * throw with the server's message. + */ +export async function submitReport(previewId: string, email?: string): Promise { + const response = await fetch("/api/reports", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(email ? { previewId, email } : { previewId }), + }); + const body: unknown = await response.json().catch(() => ({})); + if (body !== null && typeof body === "object" && "status" in body) { + const { status } = body as { status: unknown }; + if (status === "sent" || status === "saved") return body as ReportSubmitResult; + } + throw new Error(errorMessage(body, `Send failed (HTTP ${response.status})`)); +} + /** One configured scheduled automation as served by `GET /api/automations`. */ export interface AutomationSchedule { id: string; diff --git a/packages/dashboard-ui/src/views/RunDetailView.tsx b/packages/dashboard-ui/src/views/RunDetailView.tsx index 96b5a646..384798d1 100644 --- a/packages/dashboard-ui/src/views/RunDetailView.tsx +++ b/packages/dashboard-ui/src/views/RunDetailView.tsx @@ -24,6 +24,7 @@ import { runPrHref, runWorkLink } from "@/lib/run-work"; import { cn, formatDuration, formatTime } from "@/lib/utils"; import { attemptInfo, runTitle, statusTone } from "./run-detail/helpers"; import { Reference } from "./run-detail/Reference"; +import { ReportProblem } from "./run-detail/ReportProblem"; import { RetryAuditList, RetryConfirm, RetryFeedback } from "./run-detail/RetryParts"; import { Timeline } from "./run-detail/Timeline"; // oxlint-disable-next-line import/no-unassigned-import -- view styles @@ -303,6 +304,7 @@ function RunHeader({ data, retry }: { data: RunDetailResponse; retry: RetryState ) : null} + diff --git a/packages/dashboard-ui/src/views/run-detail/ReportProblem.test.tsx b/packages/dashboard-ui/src/views/run-detail/ReportProblem.test.tsx new file mode 100644 index 00000000..41ac1535 --- /dev/null +++ b/packages/dashboard-ui/src/views/run-detail/ReportProblem.test.tsx @@ -0,0 +1,271 @@ +import { describe, expect, test } from "bun:test"; +import { createElement } from "react"; +import { renderToStaticMarkup } from "react-dom/server"; + +import type { ReportPreview } from "@/lib/api"; +import { REPORT_DOCS_URL, ReportDialogBody } from "./ReportProblem"; +import { + canPreview, + canSend, + INITIAL_REPORT_FLOW, + isReportBusy, + isReportDraftDirty, + reportFlowReducer, +} from "./report-flow"; +import type { ReportFlowAction, ReportFlowState } from "./report-flow"; + +function preview(overrides: Partial = {}): ReportPreview { + return { + previewId: "11111111-2222-3333-4444-555555555555", + expiresAt: 1_800_000_000_000, + runId: 42, + signedIn: false, + contactRequired: true, + destination: "https://devintern.com/api/reports", + title: "Run failed after PR opened", + description: "Expected success.", + bytes: 2048, + attachments: { + environment: { + cliVersion: "1.2.3", + installKind: "bun-global", + os: "linux", + arch: "x64", + ci: false, + harness: "claude-code", + signedIn: false, + }, + run: { + id: 42, + status: "failed", + failedStep: "implement", + durationMs: 61_000, + stageCount: 3, + hasReason: true, + }, + logs: [{ source: "worker.stderr.log", lines: 12, bytes: 900, truncated: true }], + evidenceCount: 0, + identifiersShared: false, + }, + omitted: ["prompts", "credentials", "task key and repository slug"], + payload: { reportId: "rid-1", title: "Run failed after PR opened", description: "x" }, + ...overrides, + }; +} + +function run(actions: ReportFlowAction[], start = INITIAL_REPORT_FLOW): ReportFlowState { + return actions.reduce(reportFlowReducer, start); +} + +function render(state: ReportFlowState): string { + return renderToStaticMarkup( + createElement(ReportDialogBody, { + state, + dispatch: () => {}, + onPreview: () => {}, + onSend: () => {}, + onClose: () => {}, + }), + ); +} + +const DRAFTED: ReportFlowAction[] = [ + { type: "edit", draft: { title: "Run failed", description: "Expected a PR." } }, +]; + +describe("report flow", () => { + test("preview needs a title and description", () => { + expect(canPreview(INITIAL_REPORT_FLOW)).toBe(false); + expect(canPreview(run([{ type: "edit", draft: { title: "t" } }]))).toBe(false); + expect(canPreview(run(DRAFTED))).toBe(true); + // previewStart is ignored until the form is complete. + expect(run([{ type: "previewStart" }]).phase).toEqual({ step: "compose", busy: false }); + }); + + test("compose → preview → sent", () => { + let state = run([...DRAFTED, { type: "previewStart" }]); + expect(state.phase).toEqual({ step: "compose", busy: true }); + state = run([{ type: "previewDone", preview: preview() }], state); + expect(state.phase.step).toBe("preview"); + + // Anonymous: Send stays disabled until a valid email is entered. + expect(canSend(state)).toBe(false); + state = run([{ type: "email", email: "nope" }], state); + expect(canSend(state)).toBe(false); + state = run([{ type: "email", email: "dev@example.com" }], state); + expect(canSend(state)).toBe(true); + + state = run([{ type: "sendStart" }], state); + expect(state.phase).toMatchObject({ step: "preview", busy: true }); + state = run( + [ + { + type: "sendDone", + result: { status: "sent", reportId: "abc12345xyz", shortId: "abc12345" }, + }, + ], + state, + ); + expect(state.phase.step).toBe("sent"); + }); + + test("signed-in previews can be sent without an email", () => { + const state = run([ + ...DRAFTED, + { type: "previewStart" }, + { type: "previewDone", preview: preview({ signedIn: true, contactRequired: false }) }, + ]); + expect(canSend(state)).toBe(true); + }); + + test("editing is blocked once previewed; Back returns to the form", () => { + let state = run([ + ...DRAFTED, + { type: "previewStart" }, + { type: "previewDone", preview: preview() }, + ]); + expect(run([{ type: "edit", draft: { title: "changed" } }], state)).toBe(state); + state = run([{ type: "back" }], state); + expect(state.phase.step).toBe("compose"); + expect(state.draft.title).toBe("Run failed"); + }); + + test("a saved-locally result can be retried with the same preview", () => { + const p = preview({ signedIn: true, contactRequired: false }); + let state = run([ + ...DRAFTED, + { type: "previewStart" }, + { type: "previewDone", preview: p }, + { type: "sendStart" }, + { + type: "sendDone", + result: { + status: "saved", + error: "Could not reach endpoint", + authExpired: false, + savedPath: "/tmp/r.json", + retryCommand: "devintern report --from /tmp/r.json", + }, + }, + ]); + expect(state.phase.step).toBe("saved"); + state = run([{ type: "sendStart" }], state); + expect(state.phase).toEqual({ step: "preview", preview: p, busy: true }); + }); + + test("errors keep the user on the current step", () => { + const failed = run([ + ...DRAFTED, + { type: "previewStart" }, + { type: "previewFailed", error: "boom" }, + ]); + expect(failed.phase).toEqual({ step: "compose", busy: false, error: "boom" }); + }); + + test("a typed, unsent draft is dirty; in-flight requests block closing", () => { + expect(isReportDraftDirty(INITIAL_REPORT_FLOW)).toBe(false); + expect(isReportDraftDirty(run([{ type: "edit", draft: { shareIdentifiers: true } }]))).toBe( + false, + ); + expect(isReportDraftDirty(run(DRAFTED))).toBe(true); + + const previewing = run([...DRAFTED, { type: "previewStart" }]); + expect(isReportBusy(previewing)).toBe(true); + const previewed = run([{ type: "previewDone", preview: preview() }], previewing); + expect(isReportBusy(previewed)).toBe(false); + expect(isReportDraftDirty(previewed)).toBe(true); + const sending = run([{ type: "email", email: "a@b.co" }, { type: "sendStart" }], previewed); + expect(isReportBusy(sending)).toBe(true); + + const sent = run( + [{ type: "sendDone", result: { status: "sent", reportId: "r", shortId: "r" } }], + sending, + ); + expect(isReportDraftDirty(sent)).toBe(false); + expect(isReportBusy(sent)).toBe(false); + }); +}); + +describe("report dialog", () => { + test("compose step shows guidance, secret warning, and identifier opt-in", () => { + const html = render(INITIAL_REPORT_FLOW); + expect(html).toContain("configuration issues"); + expect(html).toContain(`href="${REPORT_DOCS_URL}"`); + expect(html).toContain("What did you expect, and what happened?"); + expect(html).toContain("Do not paste secrets"); + expect(html).toContain("task key and repository slug"); + // Shared shadcn field components, labels bound to their controls. + expect(html).toContain('data-slot="field"'); + expect(html).toMatch(/]*for="report-title"/); + expect(html).toMatch(/]*data-slot="input"[^>]*id="report-title"/); + expect(html).toMatch(/]*data-slot="textarea"[^>]*id="report-description"/); + // Share identifiers is a Radix checkbox, unchecked by default. + expect(html).toMatch( + /role="checkbox"[^>]*aria-checked="false"[^>]*id="report-share-identifiers"/, + ); + expect(html).toContain('data-slot="dialog-footer"'); + expect(html).toContain("Cancel"); + expect(html).toContain("Preview report"); + }); + + test("a shared-identifiers draft renders the checkbox checked", () => { + const html = render(run([{ type: "edit", draft: { shareIdentifiers: true } }])); + expect(html).toMatch(/role="checkbox"[^>]*aria-checked="true"/); + }); + + test("preview step lists attachments, omissions, payload, and an explicit Send", () => { + const html = render( + run([...DRAFTED, { type: "previewStart" }, { type: "previewDone", preview: preview() }]), + ); + expect(html).toContain("devintern 1.2.3"); + expect(html).toContain("Run #42: failed at implement"); + expect(html).toContain("worker.stderr.log"); + expect(html).toContain("prompts, credentials, task key and repository slug"); + expect(html).toContain("Show full payload (2.0 KB)"); + expect(html).toContain(""reportId": "rid-1""); + expect(html).toContain("Contact email"); + expect(html).toContain("Send report"); + }); + + test("result steps show the report id or the saved path and retry command", () => { + const base = run([ + ...DRAFTED, + { type: "previewStart" }, + { type: "previewDone", preview: preview() }, + ]); + const sent = render( + run( + [ + { + type: "sendDone", + result: { status: "sent", reportId: "abc12345xyz", shortId: "abc12345" }, + }, + ], + base, + ), + ); + expect(sent).toContain("abc12345"); + + const saved = render( + run( + [ + { + type: "sendDone", + result: { + status: "saved", + error: "Report endpoint rejected the report (503)", + authExpired: true, + savedPath: "/w/.devintern-code/reports/r.json", + retryCommand: "devintern report --from /w/.devintern-code/reports/r.json", + }, + }, + ], + base, + ), + ); + expect(saved).toContain("saved locally"); + expect(saved).toContain("devintern report --from /w/.devintern-code/reports/r.json"); + expect(saved).toContain("devintern login"); + expect(saved).toContain("Try again"); + }); +}); diff --git a/packages/dashboard-ui/src/views/run-detail/ReportProblem.tsx b/packages/dashboard-ui/src/views/run-detail/ReportProblem.tsx new file mode 100644 index 00000000..7b7a20db --- /dev/null +++ b/packages/dashboard-ui/src/views/run-detail/ReportProblem.tsx @@ -0,0 +1,546 @@ +import { useReducer, useState } from "react"; +import type { Dispatch, ReactNode } from "react"; +import { Bug, CircleCheck, ExternalLink, Info, Save, Send, TriangleAlert, X } from "lucide-react"; + +import { Button } from "@/components/ui/button"; +import { Checkbox } from "@/components/ui/checkbox"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { + Field, + FieldContent, + FieldDescription, + FieldError, + FieldGroup, + FieldLabel, +} from "@/components/ui/field"; +import { Input } from "@/components/ui/input"; +import { Textarea } from "@/components/ui/textarea"; +import { previewRunReport, submitReport } from "@/lib/api"; +import type { ReportPreview, RunRecord, RunStatus } from "@/lib/api"; +import { formatDuration } from "@/lib/utils"; +import { + canPreview, + canSend, + INITIAL_REPORT_FLOW, + isReportBusy, + isReportDraftDirty, + reportFlowReducer, +} from "./report-flow"; +import type { ReportFlowAction, ReportFlowState, SavedResult, SentResult } from "./report-flow"; + +export const REPORT_DOCS_URL = "https://devintern.com/docs/code/reporting-bugs"; + +/** Runs whose outcome is a failure; others get a secondary report action. */ +const FAILED_STATUSES: RunStatus[] = ["failed", "escalated", "abandoned"]; + +const DISCARD_PROMPT = "Discard this bug report draft?"; + +function formatBytes(bytes: number): string { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; +} + +/** Step 1: guidance, title, description, identifier opt-in. */ +export function ComposeStep({ + state, + dispatch, + onPreview, + onCancel, +}: { + state: ReportFlowState; + dispatch: Dispatch; + onPreview: () => void; + onCancel: () => void; +}) { + const { draft, phase } = state; + const busy = phase.step === "compose" && phase.busy; + const error = phase.step === "compose" ? phase.error : undefined; + return ( +
{ + event.preventDefault(); + onPreview(); + }} + > +
+
+ + + Title + dispatch({ type: "edit", draft: { title: event.target.value } })} + /> + + + + What did you expect, and what happened? + +