diff --git a/docs/code/dashboard.md b/docs/code/dashboard.md index c8b0580..633bece 100644 --- a/docs/code/dashboard.md +++ b/docs/code/dashboard.md @@ -28,14 +28,16 @@ The standalone command reads the database in read-only mode, so it is safe to ru ## What it shows -- **Run list**: every run with its status, task key or automation id, origin (tracker task, PR mention, or scheduled), agent harness, PR link, and duration. Filter by status or origin (`origin=scheduled` isolates automation runs). -- **Run detail**: a stage-by-stage timeline for one run: the feasibility verdict, the implementation summary, each self-review iteration, each human change request and how it was handled, and the final outcome. +- **Run list**: every run with its status, task key or automation id, origin (tracker task, PR mention, or scheduled), agent harness, PR link, and duration. The task key links straight to the tracker ticket when the tracker's URL can be derived from your configuration; filter by status or origin (`origin=scheduled` isolates automation runs). +- **Run detail**: the task key header (linked to its tracker ticket when possible) plus a snapshot of the original task description — captured when the run started and rendered as markdown — followed by a stage-by-stage timeline: the feasibility verdict, the implementation summary, each self-review iteration, each human change request and how it was handled, and the final outcome. - **Stats**: runs per week, success and escalation rates, median run duration, and a per-harness breakdown over a selectable window (7, 30, or 90 days, or all time). - **Logs**: the most recent worker log lines (timestamp, severity, message), filterable by level (`everything` / `warnings` / `errors`) with a search box over the loaded window. Lines that mention a task key link straight to that task's latest run in the Runs view. - **Worker status**: whether the daemon is running, queued and failed events, open agent PRs, and per-source poll cursors. Success and escalation rates are computed over finished runs only. Run duration is measured from pickup to PR creation and is a proxy for ticket-to-PR time. Merge rate is not shown yet: the worker records PRs as open or closed but does not track merges separately. +The ticket link and description snapshot work for remote trackers whose web URLs can be derived from base configuration plus the task key (Jira, GitHub Issues, GitLab, Azure DevOps, Asana, Trello). Linear issue links need the organization slug, so those keys stay plain text there; markdown-file runs (including scheduled automations) have no tracker page at all. Descriptions are persisted when the run begins, so history keeps showing what was asked even if the ticket is later edited or deleted. + ## Where the logs come from The worker daemon tees its own console output into `worker.stdout.log` and `worker.stderr.log` in the workspace home (`~/.devintern`), so the Logs tab works no matter how the daemon is launched — a systemd unit, launchd agent, cron wrapper, or a plain terminal — without the service definition having to redirect stdout/stderr. Each file rotates to `.1` past 8 MiB. The Logs tab tails those capture files — whether or not the worker is currently running — and only reads a bounded tail (the most recent ~256 KiB per file, at most 500 lines), so huge logs never slow down or bloat the dashboard. diff --git a/packages/code/src/index.ts b/packages/code/src/index.ts index 6b1d471..56f7fe1 100755 --- a/packages/code/src/index.ts +++ b/packages/code/src/index.ts @@ -84,8 +84,16 @@ import { import { normalizeTaskKeys } from "./lib/normalize-task-keys"; import { LockManager } from "./lib/lock-manager"; import { PRManager } from "./lib/pr-client"; -import { RunStore, beginRun, endRun, recordRunPr, recordRunStage } from "./lib/run-recorder"; +import { + RunStore, + beginRun, + endRun, + recordRunPr, + recordRunStage, + recordRunTicket, +} from "./lib/run-recorder"; import type { RunStatus } from "./lib/run-recorder"; +import { buildTicketUrl } from "./lib/ticket-url"; import { clearRetryState, getRetryState, recordIncompleteAttempt } from "./lib/retry-state"; import { shouldSkipRetry } from "./lib/retry-gate"; import { formatAgentInputNeededMarkdown } from "./lib/trackers/shared/markdown-comment-formatter"; @@ -1428,11 +1436,19 @@ async function processSingleTask(taskKey: string, taskIndex = 0, totalTasks = 1) // Scheduled automations run through this same pipeline with their prompt // materialized as a markdown task; env markers attribute those runs. const scheduledAutomationId = process.env.DEVINTERN_AUTOMATION_ID; + const trackerName = process.env.TASK_TRACKER || "jira"; beginRun({ origin: scheduledAutomationId ? "scheduled" : "task", taskKey: workflowKey, - tracker: process.env.TASK_TRACKER || "jira", + tracker: trackerName, ...(scheduledAutomationId ? { automationId: scheduledAutomationId } : {}), + // Ticket link for remote trackers only: markdown-file inputs and + // materialized automation prompts have no tracker page, and deriving + // URLs from their synthetic keys would point nowhere. + ticketUrl: + markdownInput || scheduledAutomationId + ? undefined + : buildTicketUrl(trackerName, workflowKey), }); if (!isMarkdownTaskTracker(tracker)) { @@ -1476,6 +1492,12 @@ async function processSingleTask(taskKey: string, taskIndex = 0, totalTasks = 1) throw formatError; } + // Snapshot the ticket's description as markdown for the run record so the + // dashboard shows what was asked even if the ticket changes or is deleted. + recordRunTicket({ + description: TaskFormatter.buildTaskDescriptionMarkdown(taskDetails), + }); + // Display summary console.log("\n📋 Task Summary:"); console.log(` Key: ${taskDetails.key}`); diff --git a/packages/code/src/lib/dashboard-api.ts b/packages/code/src/lib/dashboard-api.ts index b2c5070..1c8fd11 100644 --- a/packages/code/src/lib/dashboard-api.ts +++ b/packages/code/src/lib/dashboard-api.ts @@ -144,6 +144,12 @@ export class DashboardData { return this.ensureStores() === null; } + /** + * List runs for `/api/runs`. + * + * Task descriptions are stripped here: they are polled every few seconds + * and would multiply the payload dozens of times; run detail serves them. + */ listRuns(filter: { taskKey?: string; status?: RunStatus; @@ -152,7 +158,7 @@ export class DashboardData { offset: number; }): { runs: RunRecord[]; total: number } { return this.read({ runs: [], total: 0 }, (stores) => ({ - runs: stores.runs.listRuns(filter), + runs: stores.runs.listRuns(filter).map((run) => ({ ...run, taskDescription: undefined })), total: stores.runs.countFilteredRuns(filter), })); } diff --git a/packages/code/src/lib/run-recorder.ts b/packages/code/src/lib/run-recorder.ts index 020eceb..c997cfb 100644 --- a/packages/code/src/lib/run-recorder.ts +++ b/packages/code/src/lib/run-recorder.ts @@ -51,21 +51,28 @@ export interface RunMeta { repo?: string; prNumber?: number; automationId?: string; + /** Tracker-assigned key of the originating ticket (same as `taskKey`). */ + ticketKey?: string; + /** Web URL of the originating ticket (derived from tracker config + key). */ + ticketUrl?: string; /** Explicit attempt for non-task durable events. */ attempt?: number; } +/** Cap persisted ticket descriptions so a huge ticket cannot bloat the DB. */ +const MAX_DESCRIPTION_LENGTH = 20_000; + export interface RunRecord extends RunMeta { id: number; /** 1-based attempt number for the task (null-ish for pr_mention runs). */ attempt?: number; prUrl?: string; - ticketKey?: string; - ticketUrl?: string; status: RunStatus; outcomeReason?: string; startedAt: number; finishedAt?: number; + /** Markdown snapshot of the ticket description captured at run start. */ + taskDescription?: string; } export interface RunStageRecord { @@ -189,7 +196,8 @@ export class RunStore { attempt INTEGER, automation_id TEXT, ticket_key TEXT, - ticket_url TEXT + ticket_url TEXT, + task_description TEXT ) `); @@ -207,6 +215,9 @@ export class RunStore { if (!columns.some((c) => c.name === "ticket_url")) { this.db.run("ALTER TABLE runs ADD COLUMN ticket_url TEXT"); } + if (!columns.some((c) => c.name === "task_description")) { + this.db.run("ALTER TABLE runs ADD COLUMN task_description TEXT"); + } this.db.run("CREATE INDEX IF NOT EXISTS idx_runs_automation_id ON runs(automation_id)"); this.db.run(` @@ -240,8 +251,8 @@ export class RunStore { const attempt = meta.attempt ?? (meta.taskKey ? this.countRuns(meta.taskKey) + 1 : null); const result = this.db.run( `INSERT INTO runs (origin, task_key, tracker, harness, branch, repo, pr_number, - automation_id, status, started_at, attempt) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'in_progress', ?, ?)`, + automation_id, ticket_key, ticket_url, status, started_at, attempt) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'in_progress', ?, ?)`, [ meta.origin, meta.taskKey ?? null, @@ -251,6 +262,8 @@ export class RunStore { meta.repo ?? null, meta.prNumber ?? null, meta.automationId ?? null, + meta.ticketKey ?? null, + meta.ticketUrl ?? null, Date.now(), attempt, ], @@ -313,6 +326,32 @@ export class RunStore { ); } + /** + * Attach the originating tracker ticket to a run. + * + * Fields already set are never clobbered (`COALESCE`), so a late snapshot + * cannot erase metadata recorded at run start. + * + * @param runId - Run id + * @param ticket - Ticket key, web URL, and/or markdown description + */ + setRunTicket(runId: number, ticket: { key?: string; url?: string; description?: string }): void { + const description = ticket.description?.trim() ? ticket.description : null; + this.db.run( + `UPDATE runs SET + ticket_key = COALESCE(?, ticket_key), + ticket_url = COALESCE(?, ticket_url), + task_description = COALESCE(?, task_description) + WHERE id = ?`, + [ + ticket.key ?? null, + ticket.url ?? null, + description?.slice(0, MAX_DESCRIPTION_LENGTH) ?? null, + runId, + ], + ); + } + /** * Mark a run terminal and record an `outcome` stage row. * @@ -583,6 +622,7 @@ export class RunStore { automationId: (row.automation_id as string | null) ?? undefined, ticketKey: (row.ticket_key as string | null) ?? undefined, ticketUrl: (row.ticket_url as string | null) ?? undefined, + taskDescription: (row.task_description as string | null) ?? undefined, }; } @@ -660,6 +700,28 @@ export function recordRunPr(pr: { repo?: string; prNumber?: number; url?: string } } +/** + * Attach the originating tracker ticket to the current run (no-op when no run + * is active). Used to snapshot the ticket's description once task details are + * formatted, preserving what was asked even if the ticket changes later. + * + * @param ticket - Ticket key, web URL, and/or markdown description + */ +export function recordRunTicket(ticket: { + key?: string; + url?: string; + description?: string; +}): void { + if (currentStore === null || currentRunId === null) { + return; + } + try { + currentStore.setRunTicket(currentRunId, ticket); + } catch (error) { + warnOnce("ticket", error); + } +} + /** * Finish the current run and clear the context (no-op when no run is active). * diff --git a/packages/code/src/lib/task-formatter.ts b/packages/code/src/lib/task-formatter.ts index 18eaeb6..2e71758 100644 --- a/packages/code/src/lib/task-formatter.ts +++ b/packages/code/src/lib/task-formatter.ts @@ -403,6 +403,34 @@ If you need clarification on any requirements or if the task description is uncl } } + /** + * Reduce a task's description to markdown, mirroring how the agent prompt + * renders it: rendered HTML goes through the HTML converter, plain strings + * (markdown-native trackers) pass through unchanged, and other rich content + * (e.g. Jira ADF) goes through the per-tracker rich-content converter. + * + * @param taskDetails - Normalized task details (only descriptions are read) + * @param richContentConverter - Converter for tracker-native rich content + * @returns Markdown text, or `undefined` when there is no description + */ + static buildTaskDescriptionMarkdown( + taskDetails: Pick, + richContentConverter: RichContentConverter = convertADFToMarkdown, + ): string | undefined { + if (taskDetails.renderedDescription) { + return this.convertHtmlToMarkdown(taskDetails.renderedDescription).trim() || undefined; + } + const { description } = taskDetails; + if (description === undefined || description === null) { + return undefined; + } + if (typeof description === "string") { + return description.trim() || undefined; + } + const markdown = richContentConverter(description).trim(); + return markdown || undefined; + } + /** Format a byte count as a human-readable size string. */ static formatFileSize(bytes: number): string { if (bytes === 0) return "0 Bytes"; diff --git a/packages/code/src/lib/ticket-url.ts b/packages/code/src/lib/ticket-url.ts new file mode 100644 index 0000000..1546f8a --- /dev/null +++ b/packages/code/src/lib/ticket-url.ts @@ -0,0 +1,101 @@ +/** + * Tracker ticket URL derivation. + * + * The run recorder stores a `ticket_url` per run so the dashboard can link + * straight to the tracker ticket. Most tracker clients do not return the + * issue's web URL in their payloads, so URLs are derived from base + * configuration (environment) plus the task key. Trackers whose web links + * need information not present in configuration (e.g. Linear requires the + * organization slug) yield no URL and the dashboard falls back to plain text. + */ + +/** Strip trailing slashes so a base like `https://acme.atlassian.net/` joins cleanly. */ +function trimTrailingSlash(url: string): string { + return url.replace(/\/+$/, ""); +} + +function isNumericKey(taskKey: string): boolean { + return /^\d+$/.test(taskKey); +} + +function buildJiraUrl(taskKey: string, baseUrl?: string): string | undefined { + if (!baseUrl || !/^[A-Z][A-Z0-9]*-\d+$/.test(taskKey)) { + return undefined; + } + return `${trimTrailingSlash(baseUrl)}/browse/${taskKey}`; +} + +function buildGitHubUrl(taskKey: string, repo?: string): string | undefined { + if (!repo || !isNumericKey(taskKey) || !/^[^/\s]+\/[^/\s]+$/.test(repo)) { + return undefined; + } + return `https://github.com/${repo}/issues/${taskKey}`; +} + +function buildGitLabUrl( + taskKey: string, + projectPath?: string, + baseUrl?: string, +): string | undefined { + if (!projectPath || !isNumericKey(taskKey)) { + return undefined; + } + const base = trimTrailingSlash(baseUrl ?? "https://gitlab.com"); + return `${base}/${projectPath}/-/issues/${taskKey}`; +} + +function buildAzureDevOpsUrl(taskKey: string, org?: string, project?: string): string | undefined { + if (!org || !project || !isNumericKey(taskKey)) { + return undefined; + } + return `https://dev.azure.com/${encodeURIComponent(org)}/${encodeURIComponent(project)}/_workitems/edit/${taskKey}`; +} + +function buildAsanaUrl(taskKey: string): string | undefined { + if (!isNumericKey(taskKey)) { + return undefined; + } + // The /0/0/ form is the canonical shareable link regardless of project. + return `https://app.asana.com/0/0/${taskKey}`; +} + +function buildTrelloUrl(taskKey: string): string | undefined { + // Task keys are card shortLinks or full ids — short alphanumeric slugs. + if (!/^[a-z0-9]{8,}$/i.test(taskKey)) { + return undefined; + } + return `https://trello.com/c/${taskKey}`; +} + +/** + * Derive a ticket's web URL for the given tracker type from base config + + * task key. Returns `undefined` when the tracker has no derivable URL + * (missing config, synthetic keys, or unsupported trackers like linear and + * markdown files). + * + * @param tracker - Tracker type (`TASK_TRACKER` value) + * @param taskKey - Task key assigned by that tracker + * @param env - Environment source; defaults to `process.env` (tests inject one) + */ +export function buildTicketUrl( + tracker: string | null | undefined, + taskKey: string | null | undefined, + env: Record = process.env, +): string | undefined { + switch (tracker) { + case "jira": + return buildJiraUrl(taskKey ?? "", env.JIRA_BASE_URL); + case "github": + return buildGitHubUrl(taskKey ?? "", env.GITHUB_REPO); + case "gitlab": + return buildGitLabUrl(taskKey ?? "", env.GITLAB_PROJECT, env.GITLAB_BASE_URL); + case "azure-devops": + return buildAzureDevOpsUrl(taskKey ?? "", env.AZURE_DEVOPS_ORG, env.AZURE_DEVOPS_PROJECT); + case "asana": + return buildAsanaUrl(taskKey ?? ""); + case "trello": + return buildTrelloUrl(taskKey ?? ""); + default: + return undefined; + } +} diff --git a/packages/code/tests/dashboard-api.test.ts b/packages/code/tests/dashboard-api.test.ts index ad5d7b9..3a830d6 100644 --- a/packages/code/tests/dashboard-api.test.ts +++ b/packages/code/tests/dashboard-api.test.ts @@ -135,6 +135,26 @@ describe("dashboard API", () => { expect(handleRunDetail(data, "abc").status).toBe(400); }); + test("run detail includes the ticket description snapshot; the runs list strips it", () => { + const store = new RunStore(dbPath); + const id = store.createRun({ + origin: "task", + taskKey: "PROJ-2", + tracker: "jira", + ticketUrl: "https://acme.atlassian.net/browse/PROJ-2", + }); + store.setRunTicket(id, { description: "# Task\n\nBuild the thing." }); + store.close(); + + const detail = handleRunDetail(data, String(id)); + const detailBody = detail.body as { run: { taskDescription?: string } }; + expect(detailBody.run.taskDescription).toBe("# Task\n\nBuild the thing."); + + const listed = handleRuns(data, new URLSearchParams({ limit: "10" })); + const listBody = listed.body as { runs: { taskDescription?: string }[] }; + expect(listBody.runs[0].taskDescription).toBeUndefined(); + }); + test("handleStats computes rates over terminal runs only", () => { const store = new RunStore(dbPath); seedRun(store, { status: "succeeded", prUrl: "https://github.com/a/b/pull/1" }); diff --git a/packages/code/tests/run-recorder.test.ts b/packages/code/tests/run-recorder.test.ts index 8dfe1ac..b143b1f 100644 --- a/packages/code/tests/run-recorder.test.ts +++ b/packages/code/tests/run-recorder.test.ts @@ -120,6 +120,46 @@ describe("RunStore", () => { expect(run?.prUrl).toBe("https://github.com/acme/widgets/pull/7"); }); + test("createRun persists the derived ticket URL and setRunTicket snapshots the description", () => { + const id = store.createRun({ + origin: "task", + taskKey: "PROJ-10", + tracker: "jira", + ticketKey: "PROJ-10", + ticketUrl: "https://acme.atlassian.net/browse/PROJ-10", + }); + expect(store.getRun(id)?.ticketUrl).toBe("https://acme.atlassian.net/browse/PROJ-10"); + + store.setRunTicket(id, { description: "# Task\n\nBuild it." }); + + const run = store.getRun(id); + expect(run?.taskDescription).toBe("# Task\n\nBuild it."); + }); + + test("setRunTicket fills missing fields without erasing them and skips blank descriptions", () => { + const id = store.createRun({ + origin: "task", + taskKey: "PROJ-11", + ticketUrl: "https://acme.atlassian.net/browse/PROJ-11", + }); + // No URL passed: the existing one must survive. + store.setRunTicket(id, { key: "PROJ-11" }); + store.setRunTicket(id, { description: " \n\t " }); + + const run = store.getRun(id); + expect(run?.ticketKey).toBe("PROJ-11"); + expect(run?.ticketUrl).toBe("https://acme.atlassian.net/browse/PROJ-11"); + expect(run?.taskDescription).toBeUndefined(); + }); + + test("huge task descriptions are truncated, not rejected", () => { + const id = store.createRun({ origin: "task", taskKey: "PROJ-12" }); + store.setRunTicket(id, { description: "x".repeat(100_000) }); + + const run = store.getRun(id); + expect(run?.taskDescription?.length).toBe(20_000); + }); + test("finishRun marks the run terminal and appends an outcome stage", () => { const id = store.createRun({ origin: "task", taskKey: "PROJ-5" }); store.finishRun(id, "escalated", "implementation incomplete"); @@ -220,6 +260,13 @@ describe("RunStore", () => { expect(migrated.getRun(id)?.attempt).toBe(2); const scheduled = migrated.createRun({ origin: "scheduled", automationId: "new-schedule" }); expect(migrated.getRun(scheduled)?.automationId).toBe("new-schedule"); + // Description snapshots (and their column) work on migrated databases too. + migrated.setRunTicket(id, { + url: "https://acme.atlassian.net/browse/OLD-1", + description: "doc", + }); + expect(migrated.getRun(id)?.ticketUrl).toBe("https://acme.atlassian.net/browse/OLD-1"); + expect(migrated.getRun(id)?.taskDescription).toBe("doc"); migrated.close(); for (const suffix of ["", "-wal", "-shm"]) { rmSync(`${legacyPath}${suffix}`, { force: true }); diff --git a/packages/code/tests/ticket-url.test.ts b/packages/code/tests/ticket-url.test.ts new file mode 100644 index 0000000..e76922a --- /dev/null +++ b/packages/code/tests/ticket-url.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, test } from "bun:test"; + +import { buildTicketUrl } from "../src/lib/ticket-url"; + +describe("buildTicketUrl", () => { + test("derives a Jira browse URL from JIRA_BASE_URL", () => { + expect(buildTicketUrl("jira", "ENG-42", { JIRA_BASE_URL: "https://acme.atlassian.net" })).toBe( + "https://acme.atlassian.net/browse/ENG-42", + ); + }); + + test("strips a trailing slash from the Jira base URL", () => { + expect(buildTicketUrl("jira", "ENG-42", { JIRA_BASE_URL: "https://acme.atlassian.net/" })).toBe( + "https://acme.atlassian.net/browse/ENG-42", + ); + }); + + test("rejects non-Jira-shaped keys even with config", () => { + const env = { JIRA_BASE_URL: "https://acme.atlassian.net" }; + expect(buildTicketUrl("jira", "12345", env)).toBeUndefined(); + expect(buildTicketUrl("jira", "scheduled-20260827T120000", env)).toBeUndefined(); + }); + + test("returns undefined for a Jira run when the base URL is missing", () => { + expect(buildTicketUrl("jira", "ENG-42", {})).toBeUndefined(); + }); + + test("derives GitHub, GitLab, Azure DevOps, Asana, and Trello URLs", () => { + expect(buildTicketUrl("github", "12", { GITHUB_REPO: "acme/widgets" })).toBe( + "https://github.com/acme/widgets/issues/12", + ); + expect(buildTicketUrl("gitlab", "7", { GITLAB_PROJECT: "acme/widgets" })).toBe( + "https://gitlab.com/acme/widgets/-/issues/7", + ); + expect( + buildTicketUrl("gitlab", "7", { + GITLAB_PROJECT: "acme/widgets", + GITLAB_BASE_URL: "https://gitlab.internal.example/", + }), + ).toBe("https://gitlab.internal.example/acme/widgets/-/issues/7"); + expect( + buildTicketUrl("azure-devops", "99", { + AZURE_DEVOPS_ORG: "acme corp", + AZURE_DEVOPS_PROJECT: "Web Widgets", + }), + ).toBe( + `https://dev.azure.com/${encodeURIComponent("acme corp")}/${encodeURIComponent("Web Widgets")}/_workitems/edit/99`, + ); + expect(buildTicketUrl("asana", "1208823756377924", {})).toBe( + "https://app.asana.com/0/0/1208823756377924", + ); + expect(buildTicketUrl("trello", "a1b2c3d4", {})).toBe("https://trello.com/c/a1b2c3d4"); + }); + + test("requires config for derived URLs (GitHub/GitLab/Azure)", () => { + expect(buildTicketUrl("github", "12", {})).toBeUndefined(); + expect( + buildTicketUrl("github", "not-a-number", { GITHUB_REPO: "acme/widgets" }), + ).toBeUndefined(); + expect(buildTicketUrl("gitlab", "7", {})).toBeUndefined(); + expect(buildTicketUrl("azure-devops", "99", { AZURE_DEVOPS_ORG: "acme" })).toBeUndefined(); + }); + + test("linear and markdown trackers have no derivable URL", () => { + expect(buildTicketUrl("linear", "ENG-42", { LINEAR_API_KEY: "key" })).toBeUndefined(); + expect(buildTicketUrl("markdown", "task-20260827", {})).toBeUndefined(); + }); + + test("unknown tracker type and missing inputs yield no URL", () => { + expect(buildTicketUrl("unknown-tracker", "ENG-42", {})).toBeUndefined(); + expect(buildTicketUrl(undefined, "ENG-42", {})).toBeUndefined(); + expect(buildTicketUrl("jira", undefined, {})).toBeUndefined(); + expect(buildTicketUrl("", "", {})).toBeUndefined(); + }); +}); diff --git a/packages/dashboard-ui/src/components/TaskDescription.test.tsx b/packages/dashboard-ui/src/components/TaskDescription.test.tsx new file mode 100644 index 0000000..96b85ac --- /dev/null +++ b/packages/dashboard-ui/src/components/TaskDescription.test.tsx @@ -0,0 +1,39 @@ +import { expect, test } from "bun:test"; +import { createElement } from "react"; +import { renderToStaticMarkup } from "react-dom/server"; + +import { TaskDescription } from "@/components/TaskDescription"; + +test("renders a description as markdown", () => { + const html = renderToStaticMarkup( + createElement(TaskDescription, { description: "# Task\n\nBuild the **thing**." }), + ); + + expect(html).toContain(" { + for (const description of [undefined, "", " \n\t"]) { + const html = renderToStaticMarkup(createElement(TaskDescription, { description })); + + expect(html).toContain("No description provided."); + } +}); + +test("long descriptions are collapsed behind a show-more toggle", () => { + const long = "word ".repeat(500); // > PREVIEW_LENGTH chars + const html = renderToStaticMarkup(createElement(TaskDescription, { description: long })); + + expect(html).toContain("show more"); + expect(html).not.toContain("show less"); +}); + +test("short descriptions need no toggle", () => { + const html = renderToStaticMarkup( + createElement(TaskDescription, { description: "A short task." }), + ); + + expect(html).not.toContain("show more"); + expect(html).toContain("A short task."); +}); diff --git a/packages/dashboard-ui/src/components/TaskDescription.tsx b/packages/dashboard-ui/src/components/TaskDescription.tsx new file mode 100644 index 0000000..9b489e7 --- /dev/null +++ b/packages/dashboard-ui/src/components/TaskDescription.tsx @@ -0,0 +1,55 @@ +import { useState } from "react"; +import { ChevronDown, ChevronRight } from "lucide-react"; + +import { Button } from "@/components/ui/button"; +import { Markdown } from "@/lib/markdown"; + +/** + * Characters shown before a long description is collapsed behind a toggle. + * Generous enough to keep most tickets fully visible without any interaction; + * long transcripts of HTML-converted content degrade to a preview instead of + * pushing the stage timeline off screen. + */ +const PREVIEW_LENGTH = 1500; + +/** + * The ticket's original task description, rendered as markdown so it is + * clearly distinguished from agent-generated step summaries. Blank + * descriptions (tickets with no body) get an explicit placeholder rather + * than an empty card body; callers hide the whole section for non-ticket + * runs (PR mentions, conflict resolutions) by not rendering it at all. + */ +export function TaskDescription({ description }: { description?: string }) { + const [expanded, setExpanded] = useState(false); + + if (!description || !description.trim()) { + return

No description provided.

; + } + + const truncated = !expanded && description.length > PREVIEW_LENGTH; + + return ( +
+
+ + {truncated ? `${description.slice(0, PREVIEW_LENGTH).trimEnd()}…` : description} + + {truncated ? ( +
+ ) : null} +
+ {description.length > PREVIEW_LENGTH ? ( + + ) : null} +
+ ); +} diff --git a/packages/dashboard-ui/src/components/TicketKey.test.tsx b/packages/dashboard-ui/src/components/TicketKey.test.tsx new file mode 100644 index 0000000..f3b26f3 --- /dev/null +++ b/packages/dashboard-ui/src/components/TicketKey.test.tsx @@ -0,0 +1,32 @@ +import { expect, test } from "bun:test"; +import { createElement } from "react"; +import { renderToStaticMarkup } from "react-dom/server"; + +import { TicketKey } from "@/components/TicketKey"; + +test("renders a ticket key as text when no tracker URL exists", () => { + const html = renderToStaticMarkup(createElement(TicketKey, { label: "ENG-42" })); + + expect(html).toContain("ENG-42"); + expect(html).not.toContain(" { + const html = renderToStaticMarkup( + createElement(TicketKey, { + label: "ENG-42", + href: "https://acme.atlassian.net/browse/ENG-42", + }), + ); + + expect(html).toContain("ENG-42"); + expect(html).toContain('href="https://acme.atlassian.net/browse/ENG-42"'); + expect(html).toContain('target="_blank"'); + expect(html).toContain('rel="noreferrer"'); +}); + +test("falls back to a muted dash when there is no label", () => { + const html = renderToStaticMarkup(createElement(TicketKey, { label: undefined })); + + expect(html).not.toContain("–; + if (!href) return {label}; + return ( + event.stopPropagation()} + className="inline-flex items-center gap-1 text-primary hover:underline" + > + {label} + + + ); +} diff --git a/packages/dashboard-ui/src/lib/api.ts b/packages/dashboard-ui/src/lib/api.ts index f6c8ab1..b45b8ee 100644 --- a/packages/dashboard-ui/src/lib/api.ts +++ b/packages/dashboard-ui/src/lib/api.ts @@ -21,6 +21,7 @@ export interface RunRecord { automationId?: string; ticketKey?: string; ticketUrl?: string; + taskDescription?: string; taskKey?: string; tracker?: string; harness?: string; diff --git a/packages/dashboard-ui/src/views/RunDetailView.tsx b/packages/dashboard-ui/src/views/RunDetailView.tsx index 96e4da9..ff54fab 100644 --- a/packages/dashboard-ui/src/views/RunDetailView.tsx +++ b/packages/dashboard-ui/src/views/RunDetailView.tsx @@ -4,8 +4,10 @@ import { ArrowLeft, ChevronDown, ChevronRight } from "lucide-react"; import { RunResult } from "@/components/RunResult"; import { EmptyState, StageBadge, StatusBadge } from "@/components/shared"; import { StageDetailFields } from "@/components/StageDetailFields"; +import { TaskDescription } from "@/components/TaskDescription"; +import { TicketKey } from "@/components/TicketKey"; import { Button } from "@/components/ui/button"; -import { Card, CardContent, CardHeader } from "@/components/ui/card"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Markdown } from "@/lib/markdown"; import { usePoll } from "@/lib/api"; import type { RunDetailResponse, RunStageRecord } from "@/lib/api"; @@ -119,9 +121,14 @@ export function RunDetailView({ runId, onBack }: { runId: number; onBack: () =>

- {data.run.taskKey ?? - data.run.automationId ?? - (data.run.prNumber ? `PR #${data.run.prNumber}` : `Run ${data.run.id}`)} +

@@ -155,6 +162,19 @@ export function RunDetailView({ runId, onBack }: { runId: number; onBack: () => + {(data.run.taskKey || data.run.ticketUrl) && ( + + + + Task description + + + + + + + )} +
{data.stages.map((stage, index) => (
diff --git a/packages/dashboard-ui/src/views/RunsView.tsx b/packages/dashboard-ui/src/views/RunsView.tsx index 74a8bbf..8e08cb0 100644 --- a/packages/dashboard-ui/src/views/RunsView.tsx +++ b/packages/dashboard-ui/src/views/RunsView.tsx @@ -3,6 +3,7 @@ import { ChevronLeft, ChevronRight } from "lucide-react"; import { RunResult } from "@/components/RunResult"; import { EmptyState, FilterGroup, StatusBadge } from "@/components/shared"; +import { TicketKey } from "@/components/TicketKey"; import { Button } from "@/components/ui/button"; import { Card } from "@/components/ui/card"; import { @@ -108,9 +109,14 @@ export function RunsView({ onOpenRun }: { onOpenRun: (id: number) => void }) { - {run.taskKey ?? - run.automationId ?? - (run.prNumber ? `PR #${run.prNumber}` : `run ${run.id}`)} + {formatRunOrigin(run.origin)} diff --git a/packages/pm-desktop/src/main/session.test.ts b/packages/pm-desktop/src/main/session.test.ts index c749ba5..60bc2c3 100644 --- a/packages/pm-desktop/src/main/session.test.ts +++ b/packages/pm-desktop/src/main/session.test.ts @@ -1,7 +1,7 @@ -import { afterEach, describe, expect, test } from "bun:test"; +import { afterAll, afterEach, beforeAll, describe, expect, test } from "bun:test"; import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; -import { join } from "node:path"; +import { delimiter, join } from "node:path"; import type { GitExec } from "./git-sync.ts"; import type { ProjectGitSyncStatus } from "../shared/project-git-sync.ts"; import { findBindingByLocalPath, rememberProjectBinding } from "./project-bindings.ts"; @@ -58,6 +58,25 @@ async function createGitDirectory(projectDir: string): Promise { describe("detectCodeConfig", () => { let tempDir: string; + let previousCeilings: string | undefined; + + beforeAll(() => { + // Host tooling can leave a real `.devintern-code` above the OS tmpdir + // (for example /tmp/.devintern-code); ceiling discovery at tmpdir keeps + // these fixtures hermetic regardless of such ancestor markers. + previousCeilings = process.env.GIT_CEILING_DIRECTORIES; + process.env.GIT_CEILING_DIRECTORIES = [tmpdir(), previousCeilings] + .filter(Boolean) + .join(delimiter); + }); + + afterAll(() => { + if (previousCeilings === undefined) { + delete process.env.GIT_CEILING_DIRECTORIES; + } else { + process.env.GIT_CEILING_DIRECTORIES = previousCeilings; + } + }); afterEach(async () => { if (tempDir) {