diff --git a/docs/code/dashboard.md b/docs/code/dashboard.md index acc98ec..cf7d961 100644 --- a/docs/code/dashboard.md +++ b/docs/code/dashboard.md @@ -3,7 +3,7 @@ title: "Observability Dashboard" description: "A local web dashboard for worker run history: per-task timelines, stage-by-stage outcomes, aggregate stats, run retries, and worker logs" section: "Server Automation" order: 2 -dateModified: 2026-08-27 +dateModified: 2026-08-29 --- # Observability Dashboard @@ -28,11 +28,14 @@ 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, scheduled, or estimate), 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; `origin=estimate` isolates story-point sweeps). +- **Run list**: every run with its status, task key or automation id, origin (tracker task, PR mention, scheduled, or estimate), agent harness, git branch, 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; `origin=estimate` isolates story-point sweeps). The harness and branch are recorded when the run starts, so runs from before they were recorded show `–`. - **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. +- **Agent PRs**: every pull request the worker created that is still open — repo, PR number, branch, linked ticket key, and age — with a direct link to each PR on GitHub. The worker reconciles this list with GitHub on every poll cycle, so PRs merged or closed outside the worker drop out automatically. - **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. +- **Worker status**: whether the daemon is running, queued and failed events, the open agent PR count (linked to the Agent PRs view), and per-source poll cursors. + +Worker liveness is read from the daemon's lock file, in the project's `.devintern-code/` directory and in the workspace home (`~/.devintern`), so the header is accurate whether the worker runs in the foreground or as a launchd/systemd service. When no lock file is found in either location, the header says "worker status unknown" instead of "stopped" — the dashboard may simply be pointed at a different directory than the worker. 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. @@ -106,9 +109,10 @@ The dashboard is backed by a small read-only JSON API you can use directly, for | --------------------------- | --------------------------------------------------------------------- | | `GET /api/runs` | Paginated run list (`limit`, `offset`, `status`, `origin`, `taskKey`); `origin=scheduled` and `origin=estimate` are supported | | `GET /api/runs/:id` | One run with its stage timeline and retry metadata | +| `GET /api/agent-prs` | Open agent-created PRs with GitHub links, branches, and ticket keys | | `POST /api/runs/:id/retry` | Schedule a re-run of the task behind a failed/escalated/abandoned run (requires sign-in) | | `GET /api/stats?window=30d` | Aggregate stats (`7d`, `30d`, `90d`, or `all`) | -| `GET /api/worker` | Worker liveness, queue counts, agent PRs, poll cursors | +| `GET /api/worker` | Worker liveness (`running`, `stopped`, or `unknown`), queue counts, agent PR counts, poll cursors | | `GET /api/logs` | Recent worker log entries (`limit` 1–1000, default 500; `level` all/info/warn/error) | | `GET /api/health` | Health check | diff --git a/docs/code/worker.md b/docs/code/worker.md index 0068207..a8e5e83 100644 --- a/docs/code/worker.md +++ b/docs/code/worker.md @@ -222,7 +222,7 @@ Unattended automation is exactly where sandboxing the agent matters most: set `A ## Review feedback on the agent's PRs -In polling mode the worker also watches the pull requests it created (no webhook needed). When a human requests changes or leaves new inline review comments on one of the agent's own PRs, the worker addresses the feedback automatically; no mention is required on its own PRs. Closed and merged PRs leave the watch list on their own. +In polling mode the worker also watches the pull requests it created (no webhook needed). When a human requests changes or leaves new inline review comments on one of the agent's own PRs, the worker addresses the feedback automatically; no mention is required on its own PRs. Closed and merged PRs leave the watch list on their own: the watch list is reconciled with GitHub on every poll cycle, so PRs merged or closed outside the worker (and PRs that disappear because a repository was renamed, transferred, or deleted) drop out of the open count within one poll. The watch list is scoped to repos listed in `workspace.toml`. Registry entries for any other repo — typically left behind when a repository is renamed or transferred — are unwatched automatically at startup instead of being polled (and failing auth) forever. diff --git a/packages/code/src/dashboard-server.ts b/packages/code/src/dashboard-server.ts index 7625960..0afe21e 100644 --- a/packages/code/src/dashboard-server.ts +++ b/packages/code/src/dashboard-server.ts @@ -16,6 +16,7 @@ import { join, normalize, resolve } from "path"; import { DashboardData, + handleAgentPrs, handleLogs, handleRetryRun, handleRuns, @@ -149,6 +150,9 @@ export function startDashboardServer( if (pathname === "/api/worker") { return json(handleWorkerStatus(data)); } + if (pathname === "/api/agent-prs") { + return json(handleAgentPrs(data)); + } if (pathname === "/api/logs") { return json(handleLogs(data, url.searchParams)); } diff --git a/packages/code/src/index.ts b/packages/code/src/index.ts index 88877d1..e19f43f 100755 --- a/packages/code/src/index.ts +++ b/packages/code/src/index.ts @@ -88,6 +88,7 @@ import { RunStore, beginRun, endRun, + recordRunBranch, recordRunPr, recordRunStage, recordRunTicket, @@ -1441,6 +1442,8 @@ async function processSingleTask(taskKey: string, taskIndex = 0, totalTasks = 1) origin: scheduledAutomationId ? "scheduled" : "task", taskKey: workflowKey, tracker: trackerName, + // The harness that will implement this run (resolved at startup). + harness: resolvedAgent.harness.name, ...(scheduledAutomationId ? { automationId: scheduledAutomationId } : {}), // Ticket link for remote trackers only: markdown-file inputs and // materialized automation prompts have no tracker page, and deriving @@ -1682,6 +1685,9 @@ async function processSingleTask(taskKey: string, taskIndex = 0, totalTasks = 1) if (branchResult.success) { console.log(`✅ ${branchResult.message}`); + // Record the actual branch (it can gain an attempt suffix) so the + // dashboard shows which branch a run worked on. + recordRunBranch(branchResult.branchName); } else { // Branch creation failed - this is critical for safety console.error(`\n❌ Failed to create feature branch: ${branchResult.message}`); diff --git a/packages/code/src/lib/address-review.ts b/packages/code/src/lib/address-review.ts index 0dc83b7..5da4cf3 100644 --- a/packages/code/src/lib/address-review.ts +++ b/packages/code/src/lib/address-review.ts @@ -598,6 +598,7 @@ export async function addressReview( repo: `${owner}/${repo}`, prNumber, branch: pr.head.ref, + harness: resolveHarness({ warnDeprecated: false }).harness.name, }); recordRunStage("change_request", { status: "succeeded", diff --git a/packages/code/src/lib/agent-pr-reconciler.ts b/packages/code/src/lib/agent-pr-reconciler.ts new file mode 100644 index 0000000..9ba571b --- /dev/null +++ b/packages/code/src/lib/agent-pr-reconciler.ts @@ -0,0 +1,151 @@ +/** + * Agent PR Reconciler + * + * Keeps the `agent_prs` registry (worker-state.ts) truthful against GitHub. + * The dashboard's "N agent PRs open" count reads this registry, so a PR + * closed or deleted outside the worker (merged by a human, closed from the + * GitHub UI, repo renamed or transferred) must leave the registry within one + * poll cycle instead of being counted as open forever. + * + * The review poller fetches every watched PR's state on each tick anyway, so + * `applyAgentPrFetch` folds reconciliation into that fetch (no extra API + * call). `reconcileOpenAgentPrs` is the batch form: one conditional GET per + * watched PR — 304s are rate-limit-free, so the steady-state sync costs + * nothing against GitHub's limits (App installs or PATs alike) — and it + * shares its fetch results with the caller's per-PR poll loop via `fresh`. + */ + +import type { AgentPr, WorkerState } from "./worker-state"; + +/** The subset of a GitHub PR payload the poller and reconciler rely on. */ +export interface PolledPr { + state: string; + /** GitHub's computed merge state; `"dirty"` means merge conflicts. */ + mergeable_state?: string; + head?: { sha: string; ref?: string; repo?: { full_name: string } | null }; + base?: { sha: string; ref?: string }; +} + +/** Result of a conditional (ETag-cached) GitHub GET. */ +export interface ConditionalResult { + data: T | null; + etag?: string; + notModified: boolean; + /** + * GitHub answered 404: the PR or repo is gone (renamed, transferred, + * deleted, or the credential has no access). Such rows can never be + * fetched again and must not stay open in the registry. + */ + gone?: boolean; +} + +/** GitHub access the reconciler needs (satisfied by the review poller's client). */ +export interface AgentPrReconcileGitHub { + fetchPr(repo: string, prNumber: number, etag?: string): Promise>; +} + +/** One registry row closed by reconciliation. */ +export interface AgentPrClosure { + repo: string; + prNumber: number; + /** Why the row was closed: the PR's GitHub state, or "gone from GitHub". */ + reason: string; +} + +export interface AgentPrReconcileSummary { + /** Open registry rows examined (foreign repos excluded). */ + checked: number; + /** Rows left open because GitHub could not be reached this pass. */ + failed: number; + closed: AgentPrClosure[]; +} + +/** Cursor source holding the PR-state ETag shared by poller and reconciler. */ +export function agentPrStateCursorSource(repo: string, prNumber: number): string { + return `github:pr:${repo}#${prNumber}`; +} + +/** Stable map key for a PR (same shape as the poller's in-memory cache). */ +export function agentPrKey(repo: string, prNumber: number): string { + return `${repo.toLowerCase()}#${prNumber}`; +} + +/** + * Apply one fetched PR state to the registry: persist the ETag cursor and + * close the row when the PR is no longer open on GitHub or is gone. + * + * @returns The closure record, or `null` when the PR stays watched. + */ +export function applyAgentPrFetch( + workerState: WorkerState, + pr: { repo: string; prNumber: number }, + result: ConditionalResult, +): AgentPrClosure | null { + if (!result.notModified && result.etag) { + workerState.setCursor(agentPrStateCursorSource(pr.repo, pr.prNumber), "state", result.etag); + } + if (result.gone || (result.data && result.data.state !== "open")) { + const reason = result.gone ? "gone from GitHub" : (result.data?.state ?? "closed"); + workerState.markAgentPrClosed(pr.repo, pr.prNumber); + return { repo: pr.repo, prNumber: pr.prNumber, reason }; + } + return null; +} + +/** + * Reconcile the open-PR registry with GitHub. + * + * Every watched row that belongs to an allowed repo is checked once; rows + * whose PR is closed/merged or gone are closed. Failures (network errors, + * rate limits) leave the row open — it is retried next pass, never closed + * on missing information. + * + * @param options.workerState - Registry store + * @param options.github - GitHub client (the review poller's, so App auth applies) + * @param options.watched - Open registry rows to verify + * @param options.allowedRepos - Repos this worker manages; foreign rows are skipped + * @param options.etagFor - Stored ETag per PR; defaults to the shared `github:pr:` cursor + * @param options.fresh - Collect fetch results here (keyed by {@link agentPrKey}) + * so the caller can poll each PR without a second request + */ +export async function reconcileOpenAgentPrs(options: { + workerState: WorkerState; + github: AgentPrReconcileGitHub; + watched: AgentPr[]; + allowedRepos?: string[]; + etagFor?: (repo: string, prNumber: number) => string | undefined; + fresh?: Map>; +}): Promise { + const { workerState, github, watched, allowedRepos, fresh } = options; + const etagFor = + options.etagFor ?? + ((repo: string, prNumber: number) => + workerState.getCursor(agentPrStateCursorSource(repo, prNumber))?.etag); + + const summary: AgentPrReconcileSummary = { checked: 0, failed: 0, closed: [] }; + for (const pr of watched) { + if (allowedRepos && allowedRepos.length > 0 && !allowedRepos.includes(pr.repo)) { + continue; + } + summary.checked += 1; + let result: ConditionalResult; + try { + result = await github.fetchPr(pr.repo, pr.prNumber, etagFor(pr.repo, pr.prNumber)); + } catch { + // Transient (network, rate limit): leave the row open and retry next + // pass — reconciliation must never close a row on missing information. + summary.failed += 1; + continue; + } + const closure = applyAgentPrFetch(workerState, pr, result); + if (closure) { + summary.closed.push(closure); + fresh?.delete(agentPrKey(pr.repo, pr.prNumber)); + continue; + } + if (result.data && fresh) { + fresh.set(agentPrKey(pr.repo, pr.prNumber), result); + } + } + return summary; +} diff --git a/packages/code/src/lib/dashboard-api.ts b/packages/code/src/lib/dashboard-api.ts index 445ae68..94e971c 100644 --- a/packages/code/src/lib/dashboard-api.ts +++ b/packages/code/src/lib/dashboard-api.ts @@ -12,6 +12,7 @@ */ import { LockManager } from "./lock-manager"; +import type { LockStatus } from "./lock-manager"; import { RunStore } from "./run-recorder"; import type { RunOrigin, RunRecord, RunStageRecord, RunStats, RunStatus } from "./run-recorder"; import { @@ -111,6 +112,29 @@ export interface EnrichedLogEntry extends LogEntry { runStatus?: RunStatus; } +/** Worker liveness as reported by `GET /api/worker`. */ +export type WorkerLiveness = "running" | "stopped" | "unknown"; + +export interface WorkerStatusView { + status: WorkerLiveness; + pid?: number; + startedAt?: string; + /** Lock file the status was read from; absent when undeterminable. */ + lockFile?: string; +} + +/** One open agent PR as served by `GET /api/agent-prs`. */ +export interface OpenAgentPrView { + repo: string; + prNumber: number; + prUrl: string; + branch?: string; + taskKey?: string; + ticketUrl?: string; + createdAt: number; + updatedAt: number; +} + /** * Lazily opened read-only view over the worker's SQLite database. * @@ -288,6 +312,28 @@ export class DashboardData { return this.read({ open: 0, closed: 0 }, (stores) => stores.state.countAgentPrs()); } + /** + * Open agent-created PRs with their GitHub URLs (`GET /api/agent-prs`). + * Ticket links are frozen in the registry by the worker at PR-creation + * time (from the tracker configured then), so they survive tracker + * switches and stay correct even when this dashboard process runs + * without tracker configuration. + */ + getOpenAgentPrs(): OpenAgentPrView[] { + return this.read([], (stores) => + stores.state.listOpenAgentPrs().map((pr) => ({ + repo: pr.repo, + prNumber: pr.prNumber, + prUrl: `https://github.com/${pr.repo}/pull/${pr.prNumber}`, + branch: pr.branch, + taskKey: pr.taskKey, + ticketUrl: pr.ticketUrl, + createdAt: pr.createdAt, + updatedAt: pr.updatedAt, + })), + ); + } + getCursors(): Cursor[] { return this.read([], (stores) => stores.state.listCursors()); } @@ -618,18 +664,48 @@ export function handleStats(data: DashboardData, params: URLSearchParams): ApiRe return { status: 200, body: { window, stats } }; } +/** + * Resolve worker liveness from the daemon's lock file. + * + * The worker writes `.worker.lock` under `.devintern-code/` of its working + * directory (simple mode) or directly into the workspace home (fleet mode), + * so both locations are consulted. A readable lock with a dead pid (a stale + * lock left behind by a crashed worker) must not shadow a live lock in the + * other location: the live lock wins, and `stopped` is only reported when + * every readable lock belongs to a dead process. When no readable lock file + * exists in either location, liveness is `unknown`: the worker may be + * running elsewhere, and claiming "stopped" would be wrong. + * + * @param workingDir - Project root the dashboard was started from + */ +function resolveWorkerStatus(workingDir: string): WorkerStatusView { + const locks = [ + LockManager.readLockStatus(workingDir, WORKER_LOCK_FILE), + LockManager.readLockStatus(resolveWorkspaceDir(), WORKER_LOCK_FILE, { plainDir: true }), + ].filter((lock): lock is LockStatus => lock !== null); + if (locks.length === 0) { + return { status: "unknown" }; + } + // A live lock beats a stale one regardless of which location it sits in. + const lock = locks.find((candidate) => candidate.running) ?? locks[0]; + return { + status: lock.running ? "running" : "stopped", + pid: lock.pid, + startedAt: lock.startedAt, + lockFile: lock.path, + }; +} + /** * `GET /api/worker` — worker liveness, queue counts, agent PRs, poll cursors. * * @param data - Dashboard data source */ export function handleWorkerStatus(data: DashboardData): ApiResponse { - const lock = LockManager.readLockStatus(data.workingDir, WORKER_LOCK_FILE); return { status: 200, body: { - worker: - lock === null ? null : { running: lock.running, pid: lock.pid, startedAt: lock.startedAt }, + worker: resolveWorkerStatus(data.workingDir), queue: data.getQueueStats(), agentPrs: data.getAgentPrCounts(), cursors: data.getCursors().map((cursor) => ({ @@ -643,6 +719,18 @@ export function handleWorkerStatus(data: DashboardData): ApiResponse { }; } +/** + * `GET /api/agent-prs` — open agent-created PRs with GitHub links. + * + * The registry is reconciled with GitHub by the worker's review polling, so + * PRs merged or closed outside the worker drop out within one poll cycle. + * + * @param data - Dashboard data source + */ +export function handleAgentPrs(data: DashboardData): ApiResponse { + return { status: 200, body: { prs: data.getOpenAgentPrs() } }; +} + /** * `GET /api/logs` — the most recent worker log entries, tailed from the * capture files with an entry-count bound. diff --git a/packages/code/src/lib/github-reviews.ts b/packages/code/src/lib/github-reviews.ts index 25fe613..a3c0499 100644 --- a/packages/code/src/lib/github-reviews.ts +++ b/packages/code/src/lib/github-reviews.ts @@ -52,6 +52,20 @@ export interface FileContent { sha: string; } +/** + * Whether an error thrown by a GitHub API client call is an HTTP 404 + * (`Not Found`): the repo or PR was renamed, transferred, or deleted, or + * the credential has no access. Callers mapping PR fetches to the agent PR + * registry use this to stop watching rows that can never be fetched again + * instead of erroring on every poll tick. + * + * @param error - Error thrown by `apiRequest` / `conditionalGet` + */ +export function isGitHubNotFound(error: unknown): boolean { + const message = error instanceof Error ? error.message : String(error); + return message.includes("GitHub API error (404)"); +} + /** * Client for interacting with GitHub's PR review APIs. */ diff --git a/packages/code/src/lib/lock-manager.ts b/packages/code/src/lib/lock-manager.ts index c961c84..51d66e3 100644 --- a/packages/code/src/lib/lock-manager.ts +++ b/packages/code/src/lib/lock-manager.ts @@ -7,6 +7,8 @@ export interface LockStatus { pid?: number; /** ISO timestamp the lock was taken. */ startedAt?: string; + /** Absolute path of the lock file the status was read from. */ + path?: string; } export class LockManager { @@ -126,13 +128,20 @@ export class LockManager { * * @param workingDir - Project root used to locate the lock file * @param lockFileName - Lock file name (e.g. `.worker.lock`) + * @param options - `plainDir` reads the lock directly from `workingDir` + * instead of nesting `.devintern-code/` (workspace locks + * live in `~/.devintern/`, which is not a project root) * @returns Lock status, or `null` when no lock file exists (or it is unreadable) */ static readLockStatus( workingDir: string = process.cwd(), lockFileName = ".pid.lock", + options: { plainDir?: boolean } = {}, ): LockStatus | null { - const lockFilePath = join(resolve(workingDir, ".devintern-code"), lockFileName); + const configDir = options.plainDir + ? resolve(workingDir) + : resolve(workingDir, ".devintern-code"); + const lockFilePath = join(configDir, lockFileName); if (!existsSync(lockFilePath)) { return null; } @@ -146,6 +155,7 @@ export class LockManager { running: LockManager.isPidRunning(pid), pid, startedAt: typeof lockData.timestamp === "string" ? lockData.timestamp : undefined, + path: lockFilePath, }; } catch { return null; diff --git a/packages/code/src/lib/review-polling-acquirer.ts b/packages/code/src/lib/review-polling-acquirer.ts index 76d1228..5fe51bc 100644 --- a/packages/code/src/lib/review-polling-acquirer.ts +++ b/packages/code/src/lib/review-polling-acquirer.ts @@ -2,8 +2,9 @@ * Review polling acquirer (worker Mode 1, Tier 1): watch the agent's own PRs. * * Each tick, for every open PR in the `agent_prs` registry: - * 1. Conditional GET on the PR itself — closed/merged PRs leave the watch - * list; 304s (rate-limit-free) reuse cached metadata for base-sync checks. + * 1. The reconciler conditionally GETs the PR itself — closed/merged/gone + * PRs leave the watch list (and the dashboard's open count); 304s + * (rate-limit-free) reuse cached metadata for base-sync checks. * 2. Conditional GET on the review list — a new `changes_requested` review * by a human is implicitly addressed to the agent (its own PR), no * @mention required. @@ -25,6 +26,13 @@ import { spawn } from "child_process"; +import { + agentPrKey, + agentPrStateCursorSource, + applyAgentPrFetch, + reconcileOpenAgentPrs, +} from "./agent-pr-reconciler"; +import type { ConditionalResult, PolledPr } from "./agent-pr-reconciler"; import { nextScheduleOccurrence } from "./automation-config"; import type { CronOrIntervalSchedule } from "./automation-config"; import { parseEnvInteger } from "./env-integer"; @@ -34,6 +42,10 @@ import type { WorkerState } from "./worker-state"; import type { ConflictResolutionMode } from "./workspace/config"; import type { Acquirer } from "../worker"; +// The PR-state protocol types live with the reconciler, which shares them; +// re-exported so poller consumers keep their existing import paths. +export type { ConditionalResult, PolledPr }; + export interface PolledReview { id: number; state: string; @@ -46,20 +58,6 @@ export interface PolledComment { created_at: string; } -export interface ConditionalResult { - data: T | null; - etag?: string; - notModified: boolean; -} - -export interface PolledPr { - state: string; - /** GitHub's computed merge state; `"dirty"` means merge conflicts. */ - mergeable_state?: string; - head?: { sha: string; ref?: string; repo?: { full_name: string } | null }; - base?: { sha: string; ref?: string }; -} - export interface AutomaticResolveResult { outcome: "clean" | "resolved" | "skipped" | "failed" | "deferred"; message: string; @@ -471,9 +469,33 @@ export class ReviewPollingAcquirer implements Acquirer { try { this.syncConflictWindow(); - const allowedRepos = this.options.allowedRepos; - const watchedPrs = this.options.workerState.listOpenAgentPrs(); - const watchedKeys = new Set(watchedPrs.map((pr) => this.prKey(pr.repo, pr.prNumber))); + const { workerState, allowedRepos } = this.options; + + // Reconcile the registry with GitHub first: one conditional GET per + // watched PR whose result is shared with the poll loop below, so PRs + // closed or deleted outside the worker leave the watch list (and the + // dashboard's open count) within this tick even if per-PR polling + // later errors out. + const fresh = new Map>(); + const reconciliation = await reconcileOpenAgentPrs({ + workerState, + github: this.options.github, + watched: workerState.listOpenAgentPrs(), + allowedRepos, + etagFor: (repo, prNumber) => + this.prCache.has(agentPrKey(repo, prNumber)) + ? workerState.getCursor(agentPrStateCursorSource(repo, prNumber))?.etag + : undefined, + fresh, + }); + for (const closure of reconciliation.closed) { + console.log( + `🧹 [${this.name}] ${closure.repo}#${closure.prNumber} is ${closure.reason}; unwatching`, + ); + } + + const watchedPrs = workerState.listOpenAgentPrs(); + const watchedKeys = new Set(watchedPrs.map((pr) => agentPrKey(pr.repo, pr.prNumber))); for (const key of this.prCache.keys()) { if (!watchedKeys.has(key)) this.clearPrCache(key); } @@ -484,7 +506,12 @@ export class ReviewPollingAcquirer implements Acquirer { continue; } try { - await this.pollPr(pr.repo, pr.prNumber, pr.createdAt); + await this.pollPr( + pr.repo, + pr.prNumber, + pr.createdAt, + fresh.get(agentPrKey(pr.repo, pr.prNumber)), + ); } catch (error) { console.warn( `⚠️ [${this.name}] polling ${pr.repo}#${pr.prNumber} failed: ${(error as Error).message}`, @@ -497,31 +524,36 @@ export class ReviewPollingAcquirer implements Acquirer { } /** Poll a single PR; triggers at most one address-review run. */ - private async pollPr(repo: string, prNumber: number, watchedSinceMs: number): Promise { + private async pollPr( + repo: string, + prNumber: number, + watchedSinceMs: number, + prefetched?: ConditionalResult, + ): Promise { const { workerState, queue, github, addressPr, resolveConflicts } = this.options; - // 1. PR state (ETag-cached): unwatch closed/merged PRs. - const prSource = `github:pr:${repo}#${prNumber}`; - const prCursor = workerState.getCursor(prSource); - const prKey = this.prKey(repo, prNumber); - // Hydrate once per process even when an ETag survived a restart, then use - // conditional requests on normal polling ticks. - const prResult = await github.fetchPr( - repo, - prNumber, - this.prCache.has(prKey) ? prCursor?.etag : undefined, - ); - if (!prResult.notModified) { - if (prResult.etag) { - workerState.setCursor(prSource, "state", prResult.etag); - } - if (prResult.data && prResult.data.state !== "open") { - console.log(`👁️ [${this.name}] ${repo}#${prNumber} is ${prResult.data.state}; unwatching`); - workerState.markAgentPrClosed(repo, prNumber); + // 1. PR state (ETag-cached): unwatch closed/merged/gone PRs. When the + // reconciliation pass already fetched this PR, its result is reused + // (and already applied), so no second request is spent here. + const prSource = agentPrStateCursorSource(repo, prNumber); + const prKey = agentPrKey(repo, prNumber); + const prResult = + prefetched ?? + (await github.fetchPr( + repo, + prNumber, + this.prCache.has(prKey) ? workerState.getCursor(prSource)?.etag : undefined, + )); + if (!prefetched) { + const closure = applyAgentPrFetch(workerState, { repo, prNumber }, prResult); + if (closure) { + console.log(`👁️ [${this.name}] ${repo}#${prNumber} is ${closure.reason}; unwatching`); this.clearPrCache(prKey); return; } + } + if (!prResult.notModified) { if (prResult.data) this.prCache.set(prKey, prResult.data); if (resolveConflicts && prResult.data) { @@ -752,10 +784,6 @@ export class ReviewPollingAcquirer implements Acquirer { } } - private prKey(repo: string, prNumber: number): string { - return `${repo.toLowerCase()}#${prNumber}`; - } - /** * Advance the durable scheduled-window state for this tick. Auto mode * (no schedule) is a no-op. The first tick after enabling scheduled mode diff --git a/packages/code/src/lib/run-recorder.ts b/packages/code/src/lib/run-recorder.ts index 1f50f93..ab89edb 100644 --- a/packages/code/src/lib/run-recorder.ts +++ b/packages/code/src/lib/run-recorder.ts @@ -326,6 +326,21 @@ export class RunStore { ); } + /** + * Attach the working branch to a run. + * + * The branch is recorded once the pipeline has created (or resumed) it, + * because the actual branch name can gain an attempt suffix. Fields already + * set are never clobbered (`COALESCE`), so pr_mention runs that recorded + * their branch at start keep it. + * + * @param runId - Run id + * @param branch - Git branch the run operates on + */ + setRunBranch(runId: number, branch: string): void { + this.db.run(`UPDATE runs SET branch = COALESCE(branch, ?) WHERE id = ?`, [branch, runId]); + } + /** * Attach the originating tracker ticket to a run. * @@ -701,6 +716,25 @@ export function recordRunPr(pr: { repo?: string; prNumber?: number; url?: string } } +/** + * Attach the working branch to the current run (no-op when no run is active). + * Called once the pipeline has created or resumed the feature branch, since + * the actual branch name can gain an attempt suffix that is unknowable at + * `beginRun` time. + * + * @param branch - Git branch the run operates on + */ +export function recordRunBranch(branch: string): void { + if (currentStore === null || currentRunId === null) { + return; + } + try { + currentStore.setRunBranch(currentRunId, branch); + } catch (error) { + warnOnce("branch", error); + } +} + /** * 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 diff --git a/packages/code/src/lib/worker-state.ts b/packages/code/src/lib/worker-state.ts index ae6e5e5..77a9738 100644 --- a/packages/code/src/lib/worker-state.ts +++ b/packages/code/src/lib/worker-state.ts @@ -15,6 +15,7 @@ */ import { Database } from "bun:sqlite"; +import { buildTicketUrl } from "./ticket-url"; import { prepareQueueDbDirectory, resolveQueueDbPath } from "./webhook-queue"; export interface Cursor { @@ -33,6 +34,13 @@ export interface AgentPr { prNumber: number; branch?: string; taskKey?: string; + /** + * Tracker ticket link, derived from the tracker configured when the PR was + * created and frozen here. The dashboard replays it verbatim, so switching + * `TASK_TRACKER` later (or running the dashboard without tracker env) never + * breaks links for already-created PRs. + */ + ticketUrl?: string; state: AgentPrState; createdAt: number; updatedAt: number; @@ -98,6 +106,7 @@ export class WorkerState { pr_number INTEGER NOT NULL, branch TEXT, task_key TEXT, + ticket_url TEXT, state TEXT NOT NULL DEFAULT 'open', created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL, @@ -105,6 +114,16 @@ export class WorkerState { ) `); + // Additive migration for databases created before the ticket_url column + // (the readonly dashboard cannot migrate, so reads must tolerate its + // absence — see `listOpenAgentPrs`). + const prColumns = this.db.query("PRAGMA table_info(agent_prs)").all() as Array<{ + name: string; + }>; + if (!prColumns.some((c) => c.name === "ticket_url")) { + this.db.run("ALTER TABLE agent_prs ADD COLUMN ticket_url TEXT"); + } + this.db.run(` CREATE INDEX IF NOT EXISTS idx_agent_prs_state ON agent_prs(state) @@ -195,18 +214,27 @@ export class WorkerState { * Register a PR created by the pipeline (upsert; reopening resets state). * * @param pr - Repo slug, PR number, and optional branch/task metadata + * including the ticket URL derived from the tracker active at + * creation time */ - recordAgentPr(pr: { repo: string; prNumber: number; branch?: string; taskKey?: string }): void { + recordAgentPr(pr: { + repo: string; + prNumber: number; + branch?: string; + taskKey?: string; + ticketUrl?: string; + }): void { const now = Date.now(); this.db.run( - `INSERT INTO agent_prs (repo, pr_number, branch, task_key, state, created_at, updated_at) - VALUES (?, ?, ?, ?, 'open', ?, ?) + `INSERT INTO agent_prs (repo, pr_number, branch, task_key, ticket_url, state, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, 'open', ?, ?) ON CONFLICT(repo, pr_number) DO UPDATE SET branch = excluded.branch, task_key = excluded.task_key, + ticket_url = excluded.ticket_url, state = 'open', updated_at = excluded.updated_at`, - [pr.repo, pr.prNumber, pr.branch ?? null, pr.taskKey ?? null, now, now], + [pr.repo, pr.prNumber, pr.branch ?? null, pr.taskKey ?? null, pr.ticketUrl ?? null, now, now], ); } @@ -216,19 +244,17 @@ export class WorkerState { * @param repo - Optional repo slug filter */ listOpenAgentPrs(repo?: string): AgentPr[] { + // `SELECT *` (like the run store's reads) so a readonly dashboard can + // still list PRs from a database that predates the ticket_url column. const rows = ( repo ? this.db .query( - `SELECT repo, pr_number, branch, task_key, state, created_at, updated_at - FROM agent_prs WHERE state = 'open' AND repo = ? ORDER BY created_at ASC`, + `SELECT * FROM agent_prs WHERE state = 'open' AND repo = ? ORDER BY created_at ASC`, ) .all(repo) : this.db - .query( - `SELECT repo, pr_number, branch, task_key, state, created_at, updated_at - FROM agent_prs WHERE state = 'open' ORDER BY created_at ASC`, - ) + .query(`SELECT * FROM agent_prs WHERE state = 'open' ORDER BY created_at ASC`) .all() ) as Record[]; @@ -237,6 +263,7 @@ export class WorkerState { prNumber: row.pr_number as number, branch: (row.branch as string | null) ?? undefined, taskKey: (row.task_key as string | null) ?? undefined, + ticketUrl: (row.ticket_url as string | null) ?? undefined, state: row.state as AgentPrState, createdAt: row.created_at as number, updatedAt: row.updated_at as number, @@ -357,6 +384,11 @@ export class WorkerState { * Never throws — a bookkeeping failure must not fail the run that just * successfully created a PR. * + * The ticket URL is derived here, in the worker/CLI process that has the + * project's tracker configuration loaded, and frozen in the registry so the + * dashboard never has to re-derive it from its own (possibly unrelated) + * environment. + * * @param prUrl - PR URL returned by the PR client * @param branch - Source branch of the PR * @param taskKey - Task tracker key the PR implements @@ -367,9 +399,10 @@ export function recordAgentPrFromUrl(prUrl: string, branch?: string, taskKey?: s if (!parsed) { return; // non-GitHub host; review polling is GitHub-first } + const ticketUrl = buildTicketUrl(process.env.TASK_TRACKER, taskKey); const state = new WorkerState(); try { - state.recordAgentPr({ ...parsed, branch, taskKey }); + state.recordAgentPr({ ...parsed, branch, taskKey, ticketUrl }); } finally { state.close(); } diff --git a/packages/code/src/lib/workspace/workspace-worker.ts b/packages/code/src/lib/workspace/workspace-worker.ts index 1e03be7..cb1164c 100644 --- a/packages/code/src/lib/workspace/workspace-worker.ts +++ b/packages/code/src/lib/workspace/workspace-worker.ts @@ -801,6 +801,7 @@ export async function buildFleetEventAcquirers(options: { // Tier 1: the agent's own PRs (central agent_prs registry is repo-keyed, // so one acquirer covers the whole fleet). const { ReviewPollingAcquirer } = await import("../review-polling-acquirer"); + const { isGitHubNotFound } = await import("../github-reviews"); const runStore = new RunStore(state.dbPath); acquirers.push( new ReviewPollingAcquirer({ @@ -808,8 +809,24 @@ export async function buildFleetEventAcquirers(options: { workerState: state.workerState, queue: state.queue, github: { - fetchPr: (repo, n, etag) => - gh.conditionalGet(`/repos/${repo}/pulls/${n}`, ownerOf(repo), nameOf(repo), etag), + fetchPr: async (repo, n, etag) => { + try { + return await gh.conditionalGet( + `/repos/${repo}/pulls/${n}`, + ownerOf(repo), + nameOf(repo), + etag, + ); + } catch (error) { + if (isGitHubNotFound(error)) { + // Renamed/transferred/deleted repo or PR (or lost App + // access): report gone so the reconciler unregisters the + // row instead of erroring on every tick. + return { data: null, notModified: false, gone: true }; + } + throw error; + } + }, fetchReviews: (repo, n, etag) => gh.conditionalGet( `/repos/${repo}/pulls/${n}/reviews?per_page=100`, diff --git a/packages/code/tests/agent-pr-reconciler.test.ts b/packages/code/tests/agent-pr-reconciler.test.ts new file mode 100644 index 0000000..50287ed --- /dev/null +++ b/packages/code/tests/agent-pr-reconciler.test.ts @@ -0,0 +1,177 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { rmSync } from "fs"; +import { join } from "path"; +import { tmpdir } from "os"; + +import { + agentPrKey, + agentPrStateCursorSource, + applyAgentPrFetch, + reconcileOpenAgentPrs, +} from "../src/lib/agent-pr-reconciler"; +import type { ConditionalResult, PolledPr } from "../src/lib/agent-pr-reconciler"; +import { WorkerState } from "../src/lib/worker-state"; + +describe("agent PR reconciler", () => { + let dbPath: string; + let workerState: WorkerState; + + beforeEach(() => { + dbPath = join(tmpdir(), `apr-test-${Date.now()}-${Math.random().toString(36).slice(2)}.db`); + workerState = new WorkerState(dbPath); + }); + + afterEach(() => { + workerState.close(); + for (const suffix of ["", "-wal", "-shm"]) { + rmSync(`${dbPath}${suffix}`, { force: true }); + } + }); + + function open(result: Partial = {}, etag = 'W/"1"'): ConditionalResult { + return { data: { state: "open", ...result }, etag, notModified: false }; + } + + test("a PR merged outside the worker is closed within one pass", async () => { + workerState.recordAgentPr({ repo: "acme/widgets", prNumber: 7, branch: "feature/dev-1" }); + const summary = await reconcileOpenAgentPrs({ + workerState, + github: { + async fetchPr() { + return { data: { state: "closed" }, etag: 'W/"2"', notModified: false }; + }, + }, + watched: workerState.listOpenAgentPrs(), + }); + + expect(summary.checked).toBe(1); + expect(summary.failed).toBe(0); + expect(summary.closed).toEqual([{ repo: "acme/widgets", prNumber: 7, reason: "closed" }]); + expect(workerState.listOpenAgentPrs()).toHaveLength(0); + expect(workerState.countAgentPrs()).toEqual({ open: 0, closed: 1 }); + }); + + test("a gone PR (renamed, deleted, or inaccessible) is closed", async () => { + workerState.recordAgentPr({ repo: "acme/widgets", prNumber: 8 }); + const summary = await reconcileOpenAgentPrs({ + workerState, + github: { + async fetchPr() { + return { data: null, notModified: false, gone: true }; + }, + }, + watched: workerState.listOpenAgentPrs(), + }); + + expect(summary.closed).toEqual([ + { repo: "acme/widgets", prNumber: 8, reason: "gone from GitHub" }, + ]); + expect(workerState.listOpenAgentPrs()).toHaveLength(0); + }); + + test("failures (rate limits, network) leave the row open", async () => { + workerState.recordAgentPr({ repo: "acme/widgets", prNumber: 9 }); + const summary = await reconcileOpenAgentPrs({ + workerState, + github: { + async fetchPr() { + throw new Error("GitHub API error (403): rate limit exceeded"); + }, + }, + watched: workerState.listOpenAgentPrs(), + }); + + expect(summary.checked).toBe(1); + expect(summary.failed).toBe(1); + expect(summary.closed).toEqual([]); + expect(workerState.listOpenAgentPrs()).toHaveLength(1); + }); + + test("a 304 (unchanged PR) keeps the row open at no extra state", async () => { + workerState.recordAgentPr({ repo: "acme/widgets", prNumber: 10 }); + let calls = 0; + const summary = await reconcileOpenAgentPrs({ + workerState, + github: { + async fetchPr() { + calls += 1; + return { data: null, notModified: true }; + }, + }, + watched: workerState.listOpenAgentPrs(), + }); + + expect(calls).toBe(1); + expect(summary.closed).toEqual([]); + expect(workerState.listOpenAgentPrs()).toHaveLength(1); + }); + + test("open PRs stay watched and their fetch results are shared via fresh", async () => { + workerState.recordAgentPr({ repo: "acme/widgets", prNumber: 11 }); + const fresh = new Map>(); + const summary = await reconcileOpenAgentPrs({ + workerState, + github: { + async fetchPr() { + return open({ mergeable_state: "dirty" }); + }, + }, + watched: workerState.listOpenAgentPrs(), + fresh, + }); + + expect(summary.closed).toEqual([]); + expect(fresh.get(agentPrKey("acme/widgets", 11))?.data?.mergeable_state).toBe("dirty"); + expect(workerState.listOpenAgentPrs()).toHaveLength(1); + }); + + test("foreign repos are skipped when allowedRepos is set", async () => { + workerState.recordAgentPr({ repo: "acme/widgets", prNumber: 12 }); + workerState.recordAgentPr({ repo: "other/widgets", prNumber: 13 }); + let checked: number[] = []; + const summary = await reconcileOpenAgentPrs({ + workerState, + github: { + async fetchPr(_repo, n) { + checked.push(n); + return open(); + }, + }, + watched: workerState.listOpenAgentPrs(), + allowedRepos: ["acme/widgets"], + }); + + expect(checked).toEqual([12]); + expect(summary.checked).toBe(1); + // The foreign row is left alone (startup pruning handles it). + expect(workerState.listOpenAgentPrs("other/widgets")).toHaveLength(1); + }); + + test("stored ETags are sent and refreshed so steady-state syncs are conditional", async () => { + workerState.recordAgentPr({ repo: "acme/widgets", prNumber: 14 }); + workerState.setCursor(agentPrStateCursorSource("acme/widgets", 14), "state", 'W/"old"'); + const seen: (string | undefined)[] = []; + const summary = await reconcileOpenAgentPrs({ + workerState, + github: { + async fetchPr(_repo, _n, etag) { + seen.push(etag); + return open(); + }, + }, + watched: workerState.listOpenAgentPrs(), + }); + + expect(summary.failed).toBe(0); + expect(seen).toEqual(['W/"old"']); + expect(workerState.getCursor(agentPrStateCursorSource("acme/widgets", 14))?.etag).toBe('W/"1"'); + }); + + test("applyAgentPrFetch persists the new ETag when the PR is still open", () => { + workerState.recordAgentPr({ repo: "acme/widgets", prNumber: 15 }); + const closure = applyAgentPrFetch(workerState, { repo: "acme/widgets", prNumber: 15 }, open()); + + expect(closure).toBeNull(); + expect(workerState.getCursor(agentPrStateCursorSource("acme/widgets", 15))?.etag).toBe('W/"1"'); + }); +}); diff --git a/packages/code/tests/dashboard-api.test.ts b/packages/code/tests/dashboard-api.test.ts index 40ca169..5881f96 100644 --- a/packages/code/tests/dashboard-api.test.ts +++ b/packages/code/tests/dashboard-api.test.ts @@ -5,6 +5,7 @@ import { tmpdir } from "os"; import { DashboardData, + handleAgentPrs, handleLogs, handleRuns, handleRunDetail, @@ -21,19 +22,32 @@ const WEEK_MS = 7 * 24 * 60 * 60 * 1000; describe("dashboard API", () => { let dir: string; + let workspaceDir: string; let dbPath: string; let data: DashboardData; + let savedWorkspaceDir: string | undefined; beforeEach(() => { dir = join(tmpdir(), `dash-test-${Date.now()}-${Math.random().toString(36).slice(2)}`); - mkdirSync(dir, { recursive: true }); + workspaceDir = join(tmpdir(), `dash-ws-${Date.now()}-${Math.random().toString(36).slice(2)}`); + mkdirSync(workspaceDir, { recursive: true }); + // Pin the workspace home so the worker-status workspace fallback cannot + // see a developer's real ~/.devintern lock file. + savedWorkspaceDir = process.env.DEVINTERN_WORKSPACE_DIR; + process.env.DEVINTERN_WORKSPACE_DIR = workspaceDir; dbPath = join(dir, "queue.db"); data = new DashboardData({ dbPath, workingDir: dir }); }); afterEach(() => { data.close(); + if (savedWorkspaceDir === undefined) { + delete process.env.DEVINTERN_WORKSPACE_DIR; + } else { + process.env.DEVINTERN_WORKSPACE_DIR = savedWorkspaceDir; + } rmSync(dir, { recursive: true, force: true }); + rmSync(workspaceDir, { recursive: true, force: true }); }); /** Seed a finished run and return its id. */ @@ -226,36 +240,140 @@ describe("dashboard API", () => { const response = handleWorkerStatus(data); const body = response.body as { - worker: { running: boolean; pid: number } | null; + worker: { status: string; pid: number; lockFile?: string }; queue: { pending: number }; agentPrs: { open: number; closed: number }; cursors: { source: string }[]; dbMissing: boolean; }; - expect(body.worker?.running).toBe(true); - expect(body.worker?.pid).toBe(process.pid); + expect(body.worker.status).toBe("running"); + expect(body.worker.pid).toBe(process.pid); + expect(body.worker.lockFile).toContain(join(".devintern-code", ".worker.lock")); expect(body.queue.pending).toBe(1); expect(body.agentPrs).toEqual({ open: 1, closed: 1 }); expect(body.cursors.map((c) => c.source)).toEqual(["jira"]); expect(body.dbMissing).toBe(false); - // Dead pid → not running. + // Dead pid → stopped (a stale lock is determinable, not unknown). writeFileSync( join(configDir, ".worker.lock"), JSON.stringify({ pid: 999999999, timestamp: new Date().toISOString() }), ); const dead = handleWorkerStatus(data); - expect((dead.body as { worker: { running: boolean } }).worker.running).toBe(false); + expect((dead.body as { worker: { status: string } }).worker.status).toBe("stopped"); + }); + + test("worker liveness falls back to the workspace home lock (fleet mode)", () => { + // The fleet daemon locks the workspace home directly, without nesting + // .devintern-code/ — a dashboard started from a repo checkout must still + // see it. + writeFileSync( + join(workspaceDir, ".worker.lock"), + JSON.stringify({ pid: process.pid, timestamp: new Date().toISOString() }), + ); + + const body = handleWorkerStatus(data).body as { + worker: { status: string; pid: number; lockFile?: string }; + }; + expect(body.worker.status).toBe("running"); + expect(body.worker.pid).toBe(process.pid); + expect(body.worker.lockFile).toBe(join(workspaceDir, ".worker.lock")); }); - test("worker status without a lock file reports no worker", () => { + test("a stale project-dir lock does not shadow a live workspace lock", () => { + // A crashed worker leaves a lock whose pid is dead. If the current worker + // (fleet mode) has since taken the workspace-home lock, the stale lock + // must not win just because it was checked first. + const configDir = join(dir, ".devintern-code"); + mkdirSync(configDir, { recursive: true }); + writeFileSync( + join(configDir, ".worker.lock"), + JSON.stringify({ pid: 999999999, timestamp: "2026-08-26T06:03:49.220Z" }), + ); + writeFileSync( + join(workspaceDir, ".worker.lock"), + JSON.stringify({ pid: process.pid, timestamp: new Date().toISOString() }), + ); + + const body = handleWorkerStatus(data).body as { + worker: { status: string; pid: number; lockFile?: string }; + }; + expect(body.worker.status).toBe("running"); + expect(body.worker.pid).toBe(process.pid); + expect(body.worker.lockFile).toBe(join(workspaceDir, ".worker.lock")); + }); + + test("stopped is reported when every readable lock is stale", () => { + const configDir = join(dir, ".devintern-code"); + mkdirSync(configDir, { recursive: true }); + writeFileSync( + join(configDir, ".worker.lock"), + JSON.stringify({ pid: 999999999, timestamp: "2026-08-26T06:03:49.220Z" }), + ); + writeFileSync( + join(workspaceDir, ".worker.lock"), + JSON.stringify({ pid: 999999998, timestamp: "2026-08-26T06:03:49.220Z" }), + ); + + const body = handleWorkerStatus(data).body as { worker: { status: string } }; + expect(body.worker.status).toBe("stopped"); + }); + + test("worker status without any lock file is unknown, not stopped", () => { // Give the empty-DB path something to read gracefully too. const response = handleWorkerStatus(data); - const body = response.body as { worker: unknown; dbMissing: boolean }; - expect(body.worker).toBeNull(); + const body = response.body as { worker: { status: string }; dbMissing: boolean }; + // The worker may run against a different directory than this dashboard, + // so a missing lock file must not be reported as "stopped". + expect(body.worker.status).toBe("unknown"); expect(body.dbMissing).toBe(true); }); + test("handleAgentPrs lists open PRs with links and drops closed ones", () => { + const state = new WorkerState(dbPath); + // The worker freezes the ticket link at PR-creation time from the tracker + // configured then; the dashboard only replays it, so no tracker env is + // needed here (and switching trackers cannot break existing links). + state.recordAgentPr({ + repo: "acme/webapp", + prNumber: 7, + branch: "feature/dev-1", + taskKey: "DEV-1", + ticketUrl: "https://acme.atlassian.net/browse/DEV-1", + }); + state.recordAgentPr({ repo: "acme/webapp", prNumber: 8, taskKey: "DEV-2" }); + state.markAgentPrClosed("acme/webapp", 8); + state.close(); + + const response = handleAgentPrs(data); + expect(response.status).toBe(200); + const body = response.body as { + prs: { + repo: string; + prNumber: number; + prUrl: string; + branch?: string; + taskKey?: string; + ticketUrl?: string; + }[]; + }; + expect(body.prs).toHaveLength(1); + expect(body.prs[0]).toMatchObject({ + repo: "acme/webapp", + prNumber: 7, + prUrl: "https://github.com/acme/webapp/pull/7", + branch: "feature/dev-1", + taskKey: "DEV-1", + ticketUrl: "https://acme.atlassian.net/browse/DEV-1", + }); + }); + + test("handleAgentPrs degrades to an empty list without a database", () => { + const response = handleAgentPrs(data); + expect(response.status).toBe(200); + expect(response.body).toEqual({ prs: [] }); + }); + test("all handlers return empty states when the DB does not exist", () => { const runs = handleRuns(data, new URLSearchParams()); expect(runs.status).toBe(200); @@ -369,22 +487,39 @@ describe("logs endpoint", () => { describe("dashboard server", () => { let dir: string; + let workspaceDir: string; let dbPath: string; + let savedWorkspaceDir: string | undefined; beforeEach(() => { dir = join(tmpdir(), `dash-srv-${Date.now()}-${Math.random().toString(36).slice(2)}`); - mkdirSync(dir, { recursive: true }); + workspaceDir = join( + tmpdir(), + `dash-srv-ws-${Date.now()}-${Math.random().toString(36).slice(2)}`, + ); + mkdirSync(workspaceDir, { recursive: true }); + savedWorkspaceDir = process.env.DEVINTERN_WORKSPACE_DIR; + process.env.DEVINTERN_WORKSPACE_DIR = workspaceDir; dbPath = join(dir, "queue.db"); }); afterEach(() => { + if (savedWorkspaceDir === undefined) { + delete process.env.DEVINTERN_WORKSPACE_DIR; + } else { + process.env.DEVINTERN_WORKSPACE_DIR = savedWorkspaceDir; + } rmSync(dir, { recursive: true, force: true }); + rmSync(workspaceDir, { recursive: true, force: true }); }); test("serves the JSON API end-to-end", async () => { const store = new RunStore(dbPath); const id = store.createRun({ origin: "task", taskKey: "PROJ-1", harness: "claude-code" }); store.finishRun(id, "succeeded"); + const state = new WorkerState(dbPath); + state.recordAgentPr({ repo: "acme/webapp", prNumber: 7, taskKey: "DEV-1" }); + state.close(); store.close(); const server = startDashboardServer({ port: 0, dbPath, workingDir: dir }); @@ -400,8 +535,15 @@ describe("dashboard server", () => { const detail = await fetch(`${base}/api/runs/${id}`); expect(detail.status).toBe(200); - const worker = (await (await fetch(`${base}/api/worker`)).json()) as { worker: unknown }; - expect(worker.worker).toBeNull(); + const worker = (await (await fetch(`${base}/api/worker`)).json()) as { + worker: { status: string }; + }; + expect(worker.worker.status).toBe("unknown"); + + const agentPrs = (await (await fetch(`${base}/api/agent-prs`)).json()) as { + prs: { prUrl: string }[]; + }; + expect(agentPrs.prs.map((pr) => pr.prUrl)).toEqual(["https://github.com/acme/webapp/pull/7"]); const missing = await fetch(`${base}/api/nope`); expect(missing.status).toBe(404); diff --git a/packages/code/tests/lock-manager.test.ts b/packages/code/tests/lock-manager.test.ts index 64eff85..9764b1d 100644 --- a/packages/code/tests/lock-manager.test.ts +++ b/packages/code/tests/lock-manager.test.ts @@ -175,3 +175,51 @@ describe("LockManager custom lock file", () => { } }); }); + +describe("LockManager.readLockStatus", () => { + test("reads a nested project lock and reports liveness, pid, and path", () => { + const dir = join( + tmpdir(), + `lock-status-${Date.now()}-${Math.random().toString(36).substring(7)}`, + ); + mkdirSync(dir, { recursive: true }); + try { + const lock = new LockManager(dir, ".worker.lock"); + expect(lock.acquire().success).toBe(true); + + const status = LockManager.readLockStatus(dir, ".worker.lock"); + expect(status?.running).toBe(true); + expect(status?.pid).toBe(process.pid); + expect(status?.path).toBe(join(dir, ".devintern-code", ".worker.lock")); + + lock.release(); + expect(LockManager.readLockStatus(dir, ".worker.lock")).toBeNull(); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + test("plainDir reads workspace locks that sit directly in the directory", () => { + const dir = join( + tmpdir(), + `lock-status-plain-${Date.now()}-${Math.random().toString(36).substring(7)}`, + ); + mkdirSync(dir, { recursive: true }); + try { + const workspaceLock = new LockManager(dir, ".worker.lock", { plainDir: true }); + expect(workspaceLock.acquire().success).toBe(true); + expect(existsSync(join(dir, ".worker.lock"))).toBe(true); + + // Nested lookup misses the plain lock; plainDir finds it. + expect(LockManager.readLockStatus(dir, ".worker.lock")).toBeNull(); + const status = LockManager.readLockStatus(dir, ".worker.lock", { plainDir: true }); + expect(status?.running).toBe(true); + expect(status?.pid).toBe(process.pid); + expect(status?.path).toBe(join(dir, ".worker.lock")); + + workspaceLock.release(); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); +}); diff --git a/packages/code/tests/review-polling-acquirer.test.ts b/packages/code/tests/review-polling-acquirer.test.ts index 2018d6e..f98aeda 100644 --- a/packages/code/tests/review-polling-acquirer.test.ts +++ b/packages/code/tests/review-polling-acquirer.test.ts @@ -945,6 +945,66 @@ describe("ReviewPollingAcquirer", () => { await acquirer.tick(); expect(addressed).toEqual(["acme/widgets#42"]); }); + + test("a PR deleted on GitHub (404 → gone) is unwatched within one tick", async () => { + workerState.recordAgentPr({ repo: "acme/widgets", prNumber: 42 }); + const acquirer = new ReviewPollingAcquirer({ + intervalSeconds: 60, + workerState, + queue, + github: { + // The workspace adapter maps 404 responses to `gone`. + async fetchPr() { + return { data: null, notModified: false, gone: true }; + }, + async fetchReviews() { + throw new Error("must not be called for a gone PR"); + }, + async fetchReviewCommentsSince() { + throw new Error("must not be called for a gone PR"); + }, + }, + addressPr: async () => { + throw new Error("must not be addressed"); + }, + }); + + await acquirer.tick(); + expect(workerState.listOpenAgentPrs()).toHaveLength(0); + expect(workerState.countAgentPrs().closed).toBe(1); + }); + + test("reconciliation shares one PR fetch with the poll loop", async () => { + workerState.recordAgentPr({ repo: "acme/widgets", prNumber: 42 }); + let prFetches = 0; + const acquirer = new ReviewPollingAcquirer({ + intervalSeconds: 60, + workerState, + queue, + github: { + async fetchPr() { + prFetches += 1; + return { + data: { state: "open", mergeable_state: "clean" }, + etag: 'W/"pr-1"', + notModified: false, + }; + }, + async fetchReviews() { + return { data: [], notModified: false }; + }, + async fetchReviewCommentsSince() { + return []; + }, + }, + addressPr: async () => true, + }); + + await acquirer.tick(); + // One reconciliation fetch feeds the poll loop; a second would double + // the API cost of every tick. + expect(prFetches).toBe(1); + }); }); describe("runResolveConflictsViaCli", () => { diff --git a/packages/code/tests/run-recorder.test.ts b/packages/code/tests/run-recorder.test.ts index 569bbcb..26215c6 100644 --- a/packages/code/tests/run-recorder.test.ts +++ b/packages/code/tests/run-recorder.test.ts @@ -230,6 +230,20 @@ describe("RunStore", () => { reopened.close(); }); + test("setRunBranch attaches the working branch without clobbering", () => { + // Task runs learn their branch only after createFeatureBranch succeeds + // (the name can gain an attempt suffix), so it is attached post-hoc. + const id = store.createRun({ origin: "task", taskKey: "PROJ-11", harness: "claude-code" }); + store.setRunBranch(id, "feature/proj-11"); + expect(store.getRun(id)?.branch).toBe("feature/proj-11"); + + // pr_mention runs record their branch at beginRun; a late write must not + // replace it. + const mention = store.createRun({ origin: "pr_mention", branch: "agent/task" }); + store.setRunBranch(mention, "feature/proj-11"); + expect(store.getRun(mention)?.branch).toBe("agent/task"); + }); + test("attempt numbers count per task key", () => { const first = store.createRun({ origin: "task", taskKey: "PROJ-9" }); const second = store.createRun({ origin: "task", taskKey: "PROJ-9" }); diff --git a/packages/code/tests/worker-state.test.ts b/packages/code/tests/worker-state.test.ts index 9254c00..4f381c0 100644 --- a/packages/code/tests/worker-state.test.ts +++ b/packages/code/tests/worker-state.test.ts @@ -4,7 +4,7 @@ import { join } from "path"; import { tmpdir } from "os"; import { WebhookQueue } from "../src/lib/webhook-queue"; -import { WorkerState, parseGitHubPrUrl } from "../src/lib/worker-state"; +import { WorkerState, parseGitHubPrUrl, recordAgentPrFromUrl } from "../src/lib/worker-state"; describe("WorkerState", () => { let dbPath: string; @@ -63,6 +63,7 @@ describe("WorkerState", () => { prNumber: 42, branch: "feature/proj-1", taskKey: "PROJ-1", + ticketUrl: "https://acme.atlassian.net/browse/PROJ-1", }); const open = state.listOpenAgentPrs(); @@ -71,6 +72,7 @@ describe("WorkerState", () => { expect(open[0]?.prNumber).toBe(42); expect(open[0]?.branch).toBe("feature/proj-1"); expect(open[0]?.taskKey).toBe("PROJ-1"); + expect(open[0]?.ticketUrl).toBe("https://acme.atlassian.net/browse/PROJ-1"); expect(open[0]?.state).toBe("open"); }); @@ -124,6 +126,77 @@ describe("WorkerState", () => { expect(state.closeForeignAgentPrs([])).toEqual([]); expect(state.listOpenAgentPrs()).toHaveLength(1); }); + + test("opening a pre-ticket-url database adds the column and keeps rows readable", () => { + const legacyPath = join( + tmpdir(), + `ws-legacy-${Date.now()}-${Math.random().toString(36).slice(2)}.db`, + ); + const { Database } = require("bun:sqlite"); + const legacy = new Database(legacyPath); + legacy.run(` + CREATE TABLE agent_prs ( + repo TEXT NOT NULL, + pr_number INTEGER NOT NULL, + branch TEXT, + task_key TEXT, + state TEXT NOT NULL DEFAULT 'open', + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + PRIMARY KEY (repo, pr_number) + ) + `); + legacy.run( + `INSERT INTO agent_prs (repo, pr_number, task_key, created_at, updated_at) + VALUES ('acme/old', 1, 'OLD-1', 1, 1)`, + ); + legacy.close(); + + const migrated = new WorkerState(legacyPath); + const open = migrated.listOpenAgentPrs(); + expect(open).toHaveLength(1); + expect(open[0]?.taskKey).toBe("OLD-1"); + expect(open[0]?.ticketUrl).toBeUndefined(); + migrated.recordAgentPr({ + repo: "acme/old", + prNumber: 2, + taskKey: "OLD-2", + ticketUrl: "https://acme.atlassian.net/browse/OLD-2", + }); + expect(migrated.listOpenAgentPrs().find((pr) => pr.prNumber === 2)?.ticketUrl).toBe( + "https://acme.atlassian.net/browse/OLD-2", + ); + migrated.close(); + for (const suffix of ["", "-wal", "-shm"]) { + rmSync(`${legacyPath}${suffix}`, { force: true }); + } + }); + + test("recordAgentPrFromUrl freezes the ticket URL from the tracker active at record time", () => { + const prevTracker = process.env.TASK_TRACKER; + const prevJira = process.env.JIRA_BASE_URL; + process.env.TASK_TRACKER = "jira"; + process.env.JIRA_BASE_URL = "https://acme.atlassian.net"; + try { + recordAgentPrFromUrl("https://github.com/acme/widgets/pull/9", "feature/proj-3", "PROJ-3"); + } finally { + // Simulate a later tracker switch: the stored link must not change. + if (prevTracker === undefined) delete process.env.TASK_TRACKER; + else process.env.TASK_TRACKER = prevTracker; + if (prevJira === undefined) delete process.env.JIRA_BASE_URL; + else process.env.JIRA_BASE_URL = prevJira; + } + // recordAgentPrFromUrl resolves the shared queue db; the isolation + // preload pins it to a unique temp path for this test process. + const shared = new WorkerState(); + try { + const recorded = shared.listOpenAgentPrs("acme/widgets").find((pr) => pr.prNumber === 9); + expect(recorded?.taskKey).toBe("PROJ-3"); + expect(recorded?.ticketUrl).toBe("https://acme.atlassian.net/browse/PROJ-3"); + } finally { + shared.close(); + } + }); }); describe("addressed_comments", () => { diff --git a/packages/dashboard-ui/src/App.tsx b/packages/dashboard-ui/src/App.tsx index fe8e364..bba5b76 100644 --- a/packages/dashboard-ui/src/App.tsx +++ b/packages/dashboard-ui/src/App.tsx @@ -2,15 +2,21 @@ import { useEffect, useState } from "react"; import { StatusStrip } from "@/components/StatusStrip"; import { buttonVariants } from "@/components/ui/button"; +import { AgentPrsView } from "@/views/AgentPrsView"; import { LogsView } from "@/views/LogsView"; import { RunDetailView } from "@/views/RunDetailView"; import { RunsView } from "@/views/RunsView"; import { StatsView } from "@/views/StatsView"; import { cn } from "@/lib/utils"; -type Route = { view: "runs" } | { view: "run"; id: number } | { view: "stats" } | { view: "logs" }; +type Route = + | { view: "runs" } + | { view: "run"; id: number } + | { view: "prs" } + | { view: "stats" } + | { view: "logs" }; -/** Parse the location hash (#/, #/runs/:id, #/stats, #/logs) into a route. */ +/** Parse the location hash (#/, #/runs/:id, #/prs, #/stats, #/logs) into a route. */ function parseHash(): Route { const hash = window.location.hash; const runMatch = hash.match(/^#\/runs\/(\d+)$/); @@ -20,6 +26,9 @@ function parseHash(): Route { if (hash === "#/stats") { return { view: "stats" }; } + if (hash === "#/prs") { + return { view: "prs" }; + } if (hash === "#/logs") { return { view: "logs" }; } @@ -37,6 +46,7 @@ export function App() { const tabs = [ { label: "Runs", hash: "#/", active: route.view === "runs" || route.view === "run" }, + { label: "PRs", hash: "#/prs", active: route.view === "prs" }, { label: "Stats", hash: "#/stats", active: route.view === "stats" }, { label: "Logs", hash: "#/logs", active: route.view === "logs" }, ]; @@ -74,6 +84,7 @@ export function App() { {route.view === "run" ? ( (window.location.hash = "#/")} /> ) : null} + {route.view === "prs" ? : null} {route.view === "stats" ? : null} {route.view === "logs" ? : null} diff --git a/packages/dashboard-ui/src/components/StatusStrip.test.tsx b/packages/dashboard-ui/src/components/StatusStrip.test.tsx new file mode 100644 index 0000000..bf62755 --- /dev/null +++ b/packages/dashboard-ui/src/components/StatusStrip.test.tsx @@ -0,0 +1,35 @@ +import { expect, test } from "bun:test"; +import { createElement } from "react"; +import { renderToStaticMarkup } from "react-dom/server"; + +import { WorkerStatusIndicator } from "@/components/StatusStrip"; +import type { WorkerStatus } from "@/lib/api"; + +function render(worker: WorkerStatus): string { + return renderToStaticMarkup(createElement(WorkerStatusIndicator, { worker })); +} + +test("a live worker lock shows running with its pid", () => { + const html = render({ status: "running", pid: 4242 }); + + expect(html).toContain("worker running (pid 4242)"); + // The chart color class marks the healthy state. + expect(html).toContain("text-chart-4"); +}); + +test("a stale worker lock (dead pid) shows stopped", () => { + const html = render({ status: "stopped", pid: 999999999 }); + + expect(html).toContain("worker stopped"); + expect(html).not.toContain("text-chart-4"); +}); + +test("an undeterminable worker says unknown instead of claiming stopped", () => { + const html = render({ status: "unknown" }); + + // No lock file in any known location (different directory than the worker) + // must not be reported as a stopped worker. + expect(html).toContain("worker status unknown"); + expect(html).not.toContain("worker stopped"); + expect(html).toContain("title="); +}); diff --git a/packages/dashboard-ui/src/components/StatusStrip.tsx b/packages/dashboard-ui/src/components/StatusStrip.tsx index ed45bee..e0d7279 100644 --- a/packages/dashboard-ui/src/components/StatusStrip.tsx +++ b/packages/dashboard-ui/src/components/StatusStrip.tsx @@ -1,26 +1,55 @@ -import { Activity, CircleCheck, CircleOff, GitPullRequest, Inbox } from "lucide-react"; +import { Activity, CircleCheck, CircleHelp, CircleOff, GitPullRequest, Inbox } from "lucide-react"; import { usePoll } from "@/lib/api"; -import type { WorkerResponse } from "@/lib/api"; +import type { WorkerResponse, WorkerStatus } from "@/lib/api"; import { cn } from "@/lib/utils"; function Item({ icon, label, className, + title, }: { icon: React.ReactNode; label: string; className?: string; + title?: string; }) { return ( - + {icon} {label} ); } +/** + * Worker liveness indicator. When liveness cannot be determined (no readable + * worker lock in any known location — the worker may run from a different + * directory), it says so explicitly instead of claiming "stopped". + */ +export function WorkerStatusIndicator({ worker }: { worker: WorkerStatus }) { + if (worker.status === "running") { + return ( + } + label={`worker running (pid ${worker.pid ?? "?"})`} + className="text-foreground" + /> + ); + } + if (worker.status === "stopped") { + return } label="worker stopped" />; + } + return ( + } + label="worker status unknown" + title="No worker lock file was found in this project or the workspace home, so liveness cannot be determined. The worker may be running from a different directory, or it has never run here." + /> + ); +} + /** Header strip: worker liveness, queue counts, and open agent PRs. */ export function StatusStrip() { const { data } = usePoll("/api/worker"); @@ -28,7 +57,6 @@ export function StatusStrip() { return null; } - const running = data.worker?.running ?? false; const queueLabel = data.queue.failed > 0 ? `${data.queue.pending} queued, ${data.queue.failed} failed` @@ -36,22 +64,14 @@ export function StatusStrip() { return (
- - ) : ( - - ) - } - label={running ? `worker running (pid ${data.worker?.pid})` : "worker stopped"} - className={running ? "text-foreground" : undefined} - /> + } label={queueLabel} /> - } - label={`${data.agentPrs.open} agent PR${data.agentPrs.open === 1 ? "" : "s"} open`} - /> + + } + label={`${data.agentPrs.open} agent PR${data.agentPrs.open === 1 ? "" : "s"} open`} + /> + {data.dbMissing ? ( } diff --git a/packages/dashboard-ui/src/lib/api.ts b/packages/dashboard-ui/src/lib/api.ts index 452b391..70610fe 100644 --- a/packages/dashboard-ui/src/lib/api.ts +++ b/packages/dashboard-ui/src/lib/api.ts @@ -130,8 +130,18 @@ export interface StatsResponse { } | null; } +export type WorkerLiveness = "running" | "stopped" | "unknown"; + +export interface WorkerStatus { + status: WorkerLiveness; + pid?: number; + startedAt?: string; + /** Lock file the status was read from; absent when it could not be determined. */ + lockFile?: string; +} + export interface WorkerResponse { - worker: { running: boolean; pid?: number; startedAt?: string } | null; + worker: WorkerStatus; queue: { pending: number; processing: number; failed: number }; agentPrs: { open: number; closed: number }; cursors: { source: string; cursorValue: string; updatedAt: number }[]; @@ -139,6 +149,22 @@ export interface WorkerResponse { dbMissing: boolean; } +/** One open agent-created PR (registry rows reconciled with GitHub by the worker). */ +export interface AgentPrRecord { + repo: string; + prNumber: number; + prUrl: string; + branch?: string; + taskKey?: string; + ticketUrl?: string; + createdAt: number; + updatedAt: number; +} + +export interface AgentPrsResponse { + prs: AgentPrRecord[]; +} + export type WorkerLogLevel = "info" | "warn" | "error"; export type LogStream = "out" | "err"; diff --git a/packages/dashboard-ui/src/lib/format-age.test.ts b/packages/dashboard-ui/src/lib/format-age.test.ts new file mode 100644 index 0000000..e1c2185 --- /dev/null +++ b/packages/dashboard-ui/src/lib/format-age.test.ts @@ -0,0 +1,28 @@ +import { expect, test } from "bun:test"; + +import { formatAge } from "@/lib/utils"; + +const NOW = 1_800_000_000_000; +const MINUTE = 60_000; +const HOUR = 60 * MINUTE; +const DAY = 24 * HOUR; + +test("ages under a minute show seconds", () => { + expect(formatAge(NOW - 45_000, NOW)).toBe("45s"); +}); + +test("ages up to an hour show minutes", () => { + expect(formatAge(NOW - 5 * MINUTE, NOW)).toBe("5m"); +}); + +test("ages up to a day show hours", () => { + expect(formatAge(NOW - 3 * HOUR, NOW)).toBe("3h"); +}); + +test("older ages show days", () => { + expect(formatAge(NOW - 2 * DAY, NOW)).toBe("2d"); +}); + +test("a future timestamp clamps to zero", () => { + expect(formatAge(NOW + HOUR, NOW)).toBe("0s"); +}); diff --git a/packages/dashboard-ui/src/lib/utils.ts b/packages/dashboard-ui/src/lib/utils.ts index c938766..dbbe2ab 100644 --- a/packages/dashboard-ui/src/lib/utils.ts +++ b/packages/dashboard-ui/src/lib/utils.ts @@ -35,3 +35,20 @@ export function formatDuration(ms: number): string { export function formatRate(rate: number | null): string { return rate === null ? "–" : `${Math.round(rate * 100)}%`; } + +/** Compact relative age of an epoch-ms timestamp (e.g. "3h" for "3h ago"). */ +export function formatAge(epochMs: number, now: number = Date.now()): string { + const seconds = Math.max(0, Math.round((now - epochMs) / 1000)); + if (seconds < 60) { + return `${seconds}s`; + } + const minutes = Math.floor(seconds / 60); + if (minutes < 60) { + return `${minutes}m`; + } + const hours = Math.floor(minutes / 60); + if (hours < 24) { + return `${hours}h`; + } + return `${Math.floor(hours / 24)}d`; +} diff --git a/packages/dashboard-ui/src/views/AgentPrsView.test.tsx b/packages/dashboard-ui/src/views/AgentPrsView.test.tsx new file mode 100644 index 0000000..c49a2a8 --- /dev/null +++ b/packages/dashboard-ui/src/views/AgentPrsView.test.tsx @@ -0,0 +1,59 @@ +import { expect, test } from "bun:test"; +import { createElement } from "react"; +import { renderToStaticMarkup } from "react-dom/server"; + +import { AgentPrsTable } from "@/views/AgentPrsView"; +import type { AgentPrRecord } from "@/lib/api"; + +const NOW = 1_800_000_000_000; +const HOUR = 60 * 60 * 1000; + +function pr(overrides: Partial = {}): AgentPrRecord { + return { + repo: "acme/widgets", + prNumber: 42, + prUrl: "https://github.com/acme/widgets/pull/42", + branch: "feature/dev-1", + taskKey: "DEV-1", + ticketUrl: "https://acme.atlassian.net/browse/DEV-1", + createdAt: NOW - 3 * HOUR, + updatedAt: NOW - HOUR, + ...overrides, + }; +} + +test("each open PR links to GitHub with branch, ticket, and age", () => { + const html = renderToStaticMarkup(createElement(AgentPrsTable, { prs: [pr()], now: NOW })); + + expect(html).toContain("acme/widgets#42"); + expect(html).toContain('href="https://github.com/acme/widgets/pull/42"'); + expect(html).toContain("feature/dev-1"); + expect(html).toContain("DEV-1"); + expect(html).toContain('href="https://acme.atlassian.net/browse/DEV-1"'); + expect(html).toContain("3h ago"); +}); + +test("missing branch or ticket metadata degrades to a dash", () => { + const html = renderToStaticMarkup( + createElement(AgentPrsTable, { + prs: [pr({ branch: undefined, taskKey: undefined, ticketUrl: undefined })], + now: NOW, + }), + ); + + expect(html).toContain("acme/widgets#42"); + expect(html).not.toContain("feature/dev-1"); + expect(html).not.toContain("atlassian.net"); +}); + +test("multiple PRs render one row each", () => { + const html = renderToStaticMarkup( + createElement(AgentPrsTable, { + prs: [pr(), pr({ prNumber: 43, prUrl: "https://github.com/acme/widgets/pull/43" })], + now: NOW, + }), + ); + + expect(html).toContain("acme/widgets#42"); + expect(html).toContain("acme/widgets#43"); +}); diff --git a/packages/dashboard-ui/src/views/AgentPrsView.tsx b/packages/dashboard-ui/src/views/AgentPrsView.tsx new file mode 100644 index 0000000..e1e73e1 --- /dev/null +++ b/packages/dashboard-ui/src/views/AgentPrsView.tsx @@ -0,0 +1,84 @@ +import { EmptyState } from "@/components/shared"; +import { TicketKey } from "@/components/TicketKey"; +import { Card } from "@/components/ui/card"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { usePoll } from "@/lib/api"; +import type { AgentPrRecord, AgentPrsResponse } from "@/lib/api"; +import { formatAge, formatTime } from "@/lib/utils"; + +/** + * The open agent PRs, most useful first. Each row links straight to the PR + * on GitHub so a finished run is one click from review. Registry rows are + * reconciled with GitHub by the worker's review polling, so merged or closed + * PRs drop out within one poll cycle. + */ +export function AgentPrsTable({ prs, now = Date.now() }: { prs: AgentPrRecord[]; now?: number }) { + return ( + + + + + Pull request + Branch + Ticket + Age + + + + {prs.map((pr) => ( + + + + {pr.repo}#{pr.prNumber} + + + + {pr.branch ? ( + {pr.branch} + ) : ( + + )} + + + + + + {formatAge(pr.createdAt, now)} ago + + + ))} + +
+
+ ); +} + +/** List of open agent PRs with direct GitHub links. */ +export function AgentPrsView() { + const { data, error } = usePoll("/api/agent-prs"); + + return ( +
+ {error ? : null} + + {data && data.prs.length === 0 ? ( + + ) : null} + + {data && data.prs.length > 0 ? : null} +
+ ); +} diff --git a/packages/dashboard-ui/src/views/RunsView.tsx b/packages/dashboard-ui/src/views/RunsView.tsx index 5802c27..b4d9257 100644 --- a/packages/dashboard-ui/src/views/RunsView.tsx +++ b/packages/dashboard-ui/src/views/RunsView.tsx @@ -98,6 +98,7 @@ export function RunsView({ onOpenRun }: { onOpenRun: (id: number) => void }) { Work Origin Harness + Branch Result Duration Started @@ -125,6 +126,13 @@ export function RunsView({ onOpenRun }: { onOpenRun: (id: number) => void }) { {run.harness ?? "–"} + + {run.branch ? ( + {run.branch} + ) : ( + + )} +