Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions docs/code/dashboard.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<name>.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.
Expand Down
26 changes: 24 additions & 2 deletions packages/code/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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)) {
Expand Down Expand Up @@ -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}`);
Expand Down
8 changes: 7 additions & 1 deletion packages/code/src/lib/dashboard-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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),
}));
}
Expand Down
72 changes: 67 additions & 5 deletions packages/code/src/lib/run-recorder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -189,7 +196,8 @@ export class RunStore {
attempt INTEGER,
automation_id TEXT,
ticket_key TEXT,
ticket_url TEXT
ticket_url TEXT,
task_description TEXT
)
`);

Expand All @@ -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(`
Expand Down Expand Up @@ -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,
Expand All @@ -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,
],
Expand Down Expand Up @@ -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.
*
Expand Down Expand Up @@ -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,
};
}

Expand Down Expand Up @@ -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).
*
Expand Down
28 changes: 28 additions & 0 deletions packages/code/src/lib/task-formatter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<FormattedTaskDetails, "renderedDescription" | "description">,
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";
Expand Down
101 changes: 101 additions & 0 deletions packages/code/src/lib/ticket-url.ts
Original file line number Diff line number Diff line change
@@ -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/<gid> 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<string, string | undefined> = 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;
}
}
20 changes: 20 additions & 0 deletions packages/code/tests/dashboard-api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" });
Expand Down
Loading
Loading