diff --git a/CHANGELOG.md b/CHANGELOG.md index 6fcf81f7..b4fb7ef0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,22 @@ date, grouped by `Added` / `Changed` / `Fixed` / `Removed`. ## [Unreleased] +## [2026-07-14] — v0.12.0 · Calmer issue operations + +### Added + +- **Every successful agent run now leaves one durable issue reply.** Terminal outcomes are visible in the issue conversation across all engagement modes, while late provider trace events can no longer resurrect completed work as Thinking. +- **Operational alerts now share one lifecycle.** Command Center and the notification drawer reconcile durable active and resolved state, with accessible notification focus behavior and clearer incident grouping, counts, recovery actions, and error feedback. + +### Changed + +- **Issue detail keeps context visible without forcing a full sidebar.** The page has one predictable scroll owner, Activity is the default rail view, dense cards start clean and expand for full content, and patient user waits no longer generate repeated stall replies. +- **GitHub URLs use Forge's native resource relation.** GitHub issues and pull requests submitted through generic link paths retain checks, sync, and PR state instead of becoming opaque attachments. + +### Fixed + +- **Historical pins can be removed and reordered again.** Migration-era `pin_` rows and modern CUID rows are both accepted, and each compact navbar pin now has a direct unpin action. + ## [2026-07-13] — v0.11.0 · Live agent operations ### Added diff --git a/DEVLOG.md b/DEVLOG.md index 72220d25..0ba1055f 100644 --- a/DEVLOG.md +++ b/DEVLOG.md @@ -2,6 +2,32 @@ > Append-only session log. Read at session start. Update at session end. +## 2026-07-13 — AXI-102 patient-wait spam + expandable Command Center cards + +Closed the AXI-102 stall-comment loop introduced by approval-expiry recovery. +The runs dispatcher was polling every provider-backed `WAITING` row even though +ordinary `runs.setWaiting` turns are expected to finish provider-side after the +agent parks for an operator reply. That normal completion was consequently +reclassified as a missing `runs.complete` contract, marked STALLED, and surfaced +as a synthetic `[dispatch · run stalled]` issue comment. Polling now covers +ACTIVE runs plus only the WAITING rows that hold a real pending runtime +approval; patient waits remain parked for the existing reply-based resume path. +The generic recovery queue also excludes WAITING work so Command Center does not +duplicate a legitimate question as a stalled run with an incorrect Abandon +action. + +Added a shared compact expandable-text treatment to Command Center asks, +runtime approvals, review prompts and summaries, recoverable-run details, and +agent-attention items. Cards remain a consistent two-line scan by default and +offer keyboard-accessible Show full / Show less controls only when their content +actually overflows. + +Verification: focused dispatcher and operator-attention coverage passed +(**13/13**); lint passed with existing repository warnings only; typecheck +passed; the full Vitest suite passed (**1,211 passed; 1 skipped**); a fresh +production build and the responsive Command Center Playwright contract passed +(**1/1** across desktop, tablet, and mobile); and `git diff --check` passed. + ## 2026-07-13 — v0.11.0 live agent operations release candidate Integrated the issue Workstream and durable realtime stream with the global @@ -12577,3 +12603,69 @@ passed, 1 skipped), and a fresh production-build Playwright run (37 passed). The standard parallel Vitest run exposed the known cross-file stale-work database race; that suite passed both independently (9 tests) and within the serial full run. + +--- + +## 2026-07-13 — Issue detail rail UX pass + +Reworked the issue-detail right rail from a nested, viewport-sized scroller +into a compact part of the page's single content scroll. Removed the runtime +height calculator, sticky inner rail, contained overscroll, and independent +vertical overflow. Empty GitHub state, property labels, and agent-queue help +now consume substantially less height while preserving the existing controls. + +Made Activity the bare-URL default, kept Attachments and Relations deep-linkable, +and updated attachment jumps to use `?tab=attachments`. The tab pattern now has +larger targets, roving focus, Left/Right/Home/End navigation, and explicit +tab/tabpanel relationships. + +Added focused Playwright coverage for Activity-first routing, rail overflow, +tab semantics, keyboard navigation, and URL state. Screenshot verification at +1440×900 and 1280×720 measured the rail at 458px with equal client/scroll +heights and visible overflow, eliminating the previous second scroll owner. + +Verification: lint passed with existing repository warnings; typecheck passed; +the focused issue-flow Playwright test passed after a fresh production build; +and the full Playwright suite passed (37 tests). The parallel Vitest suite +passed 1,208 tests with 1 skip and hit the known unrelated stale-work database +race once; that failing test passed immediately in isolation. + +--- + +## 2026-07-14 — Run closure and shared operational-alert lifecycle + +Made successful agent-run closure visible and durable across every engagement +mode. The shared `finishRun` path now creates one agent-authored final issue +comment (or adopts the agent's explicitly supplied final BODY comment), stores +its ID in completion metadata, and keeps mode-specific `runs.complete` +validation intact. Terminal transitions clear live-only step/approval state, +and buffered provider trace events can no longer revive a completed card as +“thinking” or “live”; explicit operator reconciliation events remain durable. + +GitHub issue and pull-request URLs submitted through generic link attachment +paths now route into Forge's native GitHub resource relation, preserving sync, +checks, and PR state instead of creating an opaque attachment. + +Restored unpin compatibility for migration-0023 rows such as AXI-28. Those +historical pins deliberately use opaque `pin_` IDs rather than CUIDs, so +the remove/reorder API now accepts both persisted formats. Cross-workspace +navbar chips also expose a direct per-item unpin action for issues, projects, +initiatives, sprints, views, and agents. + +Unified operational alerts and notifications around `NotificationState`. +Active stale/no-ack/plan alerts reconcile to Resolved when durable product state +proves recovery, while a human follow-up alone does not falsely clear an agent +stall. The notification drawer now exposes clearer “I’m handling”, Hide, and +Resolve actions with per-item feedback, retryable error states, larger targets, +modal focus containment, and focus restoration. Command Center groups repeated +active-stale symptoms by agent, shows exact run/approval counts, confirms +destructive recovery, and no longer reports fetch failures as empty state. +Agent attention/runroom and Inbox empty/roster language now distinguish +verified clear states from unavailable or offline conditions. + +Verification: lint passed with existing repository warnings; typecheck passed; +focused database suites passed (156 tests). The parallel full Vitest run passed +1,212 tests with 1 skip and hit the known cross-suite stale-work race once; its +9-test file passed immediately in isolation. A fresh production build passed +the Command Center and issue-flow Playwright specs, and the notification drawer +focus-containment/restore regression also passed (3 focused browser tests). diff --git a/package.json b/package.json index 6efefa8b..6b7412a0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "forge", - "version": "0.11.0", + "version": "0.12.0", "private": true, "description": "Forge — a fast, minimalist, keyboard-driven project management platform with pluggable agents.", "license": "MIT", diff --git a/src/app/(app)/w/[slug]/command-center/page.tsx b/src/app/(app)/w/[slug]/command-center/page.tsx index 38d2c6e8..51ba12e4 100644 --- a/src/app/(app)/w/[slug]/command-center/page.tsx +++ b/src/app/(app)/w/[slug]/command-center/page.tsx @@ -4,6 +4,7 @@ import Link from "next/link"; import { toast } from "sonner"; import { ArrowRight, + AlertTriangle, Bot, CalendarClock, Check, @@ -23,7 +24,8 @@ import { import type { Role } from "@prisma/client"; import { Topbar } from "@/components/topbar"; import { Button } from "@/components/ui/button"; -import { Picker } from "@/components/ui/modal"; +import { ExpandableText } from "@/components/ui/expandable-text"; +import { Picker, useConfirm } from "@/components/ui/modal"; import { EmptyState, SkeletonList } from "@/components/ui"; import { AgentAttentionPanel } from "@/components/agent-attention-panel"; import { RunApprovalCard } from "@/components/agents/run-approval-card"; @@ -61,7 +63,8 @@ export default function CommandCenterPage() { const utils = trpc.useUtils(); const canRecoverRuns = ws.role === ("OWNER" as Role) || ws.role === ("ADMIN" as Role); const summaryInput = { dueWindowDays: 7, limit: 20 } as const; - const { data, isLoading } = trpc.commandCenter.summary.useQuery(summaryInput); + const summaryQuery = trpc.commandCenter.summary.useQuery(summaryInput); + const { data, isLoading } = summaryQuery; // Realtime fan-out. The summary stitches together action requests, // review gates, goals, and agent runs — invalidate on the events that @@ -153,10 +156,10 @@ export default function CommandCenterPage() { }, }); const attentionCount = data - ? data.actionRequests.length + - data.runtimeApprovals.length + - data.reviewGates.length + - data.stalledRuns.length + ? data.counts.actionRequests + + data.counts.runtimeApprovals + + data.counts.reviewGates + + data.counts.stalledRuns : 0; const activeAttentionGroups = data ? Number(data.actionRequests.length > 0) + @@ -164,6 +167,7 @@ export default function CommandCenterPage() { Number(data.stalledRuns.length > 0) + Number(data.reviewGates.length > 0) : 0; + const runRecoveryGroups = data ? groupRunRecoveries(data.stalledRuns) : []; return ( <> @@ -181,13 +185,19 @@ export default function CommandCenterPage() {
{isLoading ? ( - ) : !data ? ( + ) : summaryQuery.isError ? ( } + title="Command Center is unavailable" + description={summaryQuery.error.message} + action={ + + } /> - ) : ( + ) : !data ? null : (
0 ? ( {data.actionRequests.map((row) => ( @@ -226,7 +236,7 @@ export default function CommandCenterPage() { {data.runtimeApprovals.length > 0 ? ( {data.runtimeApprovals.map((run) => ( @@ -242,7 +252,7 @@ export default function CommandCenterPage() { {data.stalledRuns.length > 0 ? ( - {data.stalledRuns.map((row) => ( - recoverRuns.mutate({ action, runIds: [row.id] })} - /> - ))} + {runRecoveryGroups.map((runs) => + runs.length === 1 ? ( + + recoverRuns.mutate({ action, runIds: [runs[0]!.id] }) + } + /> + ) : ( + recoverRuns.mutate({ action, runIds })} + /> + ), + )} ) : null} {data.reviewGates.length > 0 ? ( {data.reviewGates.map((row) => ( @@ -368,7 +391,7 @@ export default function CommandCenterPage() { icon={} title="Active runs" empty="No agents running." - count={data.activeRuns.length} + count={data.counts.activeRuns} action={} > {data.activeRuns.slice(0, COMMAND_MODULE_LIMIT).map((row) => ( @@ -543,19 +566,20 @@ function RuntimeApprovalDecisionCard({ const href = `/w/${run.issue.workspace.slug || slug}/i/${run.issue.workspace.key}-${run.issue.number}`; return (
- +
- + {run.issue.workspace.key}-{run.issue.number} · {run.issue.title} - + @{run.agent.profileKey}
{run.currentStep ? ( -

- {run.currentStep} -

+ ) : null} - +
{request.body ? ( -

{request.body}

+ ) : null} {request.issue ? ( @@ -932,23 +956,39 @@ function ReviewGateDecisionCard({ return (
- {href ? ( + {step && href ? ( - {step - ? `${step.title} · step ${step.position + 1} of ${step.plan._count.steps}` - : gate.prompt.slice(0, 80)} + {step.title} · step {step.position + 1} of {step.plan._count.steps} - ) : ( - {gate.prompt.slice(0, 80)} - )} - - {step - ? `${step.plan.title}${step.issue ? ` · ${step.issue.title}` : ""}` - : gate.targetType.replace(/-/g, " ")} - {href ? "" : ` · ${gate.targetId.slice(0, 12)}…`} - + ) : step ? ( + + {step.title} · step {step.position + 1} of {step.plan._count.steps} + + ) : null} + +
+ + {step + ? `${step.plan.title}${step.issue ? ` · ${step.issue.title}` : ""}` + : gate.targetType.replace(/-/g, " ")} + {href ? "" : ` · ${gate.targetId.slice(0, 12)}…`} + + {!step && href ? ( + + Open target + + ) : null} +
{step?.runs[0]?.summary ? ( -

{step.runs[0].summary}

+ ) : null}
{!canResolve ? ( @@ -1183,6 +1223,23 @@ type CCRunFailure = { }; }; +function groupRunRecoveries(runs: CCRunFailure[]): CCRunFailure[][] { + const groups = new Map(); + for (const run of runs) { + // Quiet active rows usually share one upstream runtime/agent condition; + // collapse those symptoms into a root incident. Terminal/protocol + // failures remain issue-specific because their recovery evidence differs. + const key = + run.recoveryReason === "active-stale" + ? `${run.agent.profileKey}:${run.recoveryReason}` + : run.id; + const group = groups.get(key) ?? []; + group.push(run); + groups.set(key, group); + } + return [...groups.values()]; +} + function recoveryActionLabel(action: CCRunFailure["recommendedAction"]): string { if (action === "ABANDON") return "Abandon"; if (action === "RECONCILE") return "Reconcile"; @@ -1204,30 +1261,52 @@ function RunRecoveryBulkActions({ pending: boolean; onRecover: (action: CCRunFailure["recommendedAction"], runIds: string[]) => void; }) { + const { confirm, confirmElement } = useConfirm(); return ( -
- {(["ABANDON", "RECONCILE", "CLEAR"] as const).map((action) => { - const runIds = runs - .filter((run) => run.availableActions.includes(action)) - .map((run) => run.id); - if (runIds.length === 0) return null; - const Icon = recoveryActionIcon(action); - return ( - - ); - })} -
+ <> +
+ {(["ABANDON", "RECONCILE", "CLEAR"] as const).map((action) => { + const runIds = runs + .filter((run) => run.availableActions.includes(action)) + .map((run) => run.id); + if (runIds.length === 0) return null; + const Icon = recoveryActionIcon(action); + return ( + + ); + })} +
+ {confirmElement} + ); } @@ -1244,42 +1323,119 @@ function RunFailureCard({ pending: boolean; onRecover: (action: CCRunFailure["recommendedAction"]) => void; }) { + const { confirm, confirmElement } = useConfirm(); const ts = run.finishedAt ?? run.lastEventAt; const excerpt = run.recoveryDetail ?? run.summary ?? run.currentStep ?? run.issue.title; const ActionIcon = recoveryActionIcon(run.recommendedAction); return ( -
- -
- - {run.issue.workspace.key}-{run.issue.number} - - - {run.recoveryReason.replace("-", " ")} + <> +
+
+
+ + {run.issue.workspace.key}-{run.issue.number} + + + {run.recoveryReason.replace("-", " ")} + +
+ + {run.recoveryTitle} +
- {run.recoveryTitle} - - @{run.agent.profileKey} · {new Date(ts).toLocaleString()} · {excerpt} - - - {canRecover ? ( - - ) : null} + {canRecover ? ( + + ) : null} +
+ {confirmElement} + + ); +} + +function RunRecoveryIncidentCard({ + runs, + slug, + canRecover, + pending, + onRecover, +}: { + runs: CCRunFailure[]; + slug: string; + canRecover: boolean; + pending: boolean; + onRecover: (action: CCRunFailure["recommendedAction"], runIds: string[]) => void; +}) { + const first = runs[0]!; + return ( +
+
+
+
+ @{first.agent.profileKey} · {runs.length} affected runs +
+ +
+ {canRecover ? ( + + ) : null} +
+
+ + View {runs.length} affected issues + +
    + {runs.map((run) => ( +
  • + + + {run.issue.workspace.key}-{run.issue.number} + + {run.issue.title} + +
  • + ))} +
+
); } diff --git a/src/app/(app)/w/[slug]/inbox/page.tsx b/src/app/(app)/w/[slug]/inbox/page.tsx index 4591e471..d39f0518 100644 --- a/src/app/(app)/w/[slug]/inbox/page.tsx +++ b/src/app/(app)/w/[slug]/inbox/page.tsx @@ -443,8 +443,8 @@ export default function InboxPage() { } - title="Inbox zero — nothing's waiting" - description="When someone @mentions you, assigns work, or replies to a thread you're following, it lands here." + title="Your inbox is clear" + description="Nothing is assigned to you, mentioning you, or waiting in a thread you follow. Workspace operations may still need attention in Command Center." action={ agent.status !== "OFFLINE").length; return (
- Agents online - {agents.length} + Agent roster + + {reachable}/{agents.length} reachable + } - hint="Every active agent in this workspace and what it can pick up." + hint="Every active agent binding in this workspace, including offline agents." > {agents.length === 0 ? ( diff --git a/src/app/(app)/w/[slug]/issues/[id]/page.tsx b/src/app/(app)/w/[slug]/issues/[id]/page.tsx index 097cedd2..65802b00 100644 --- a/src/app/(app)/w/[slug]/issues/[id]/page.tsx +++ b/src/app/(app)/w/[slug]/issues/[id]/page.tsx @@ -1,5 +1,5 @@ "use client"; -import { use, useEffect, useRef, useState } from "react"; +import { use, useEffect, useState } from "react"; import { useRouter, useSearchParams } from "next/navigation"; import { toast } from "sonner"; import type { AgentStatus, WorkItemKind } from "@prisma/client"; @@ -83,8 +83,6 @@ export default function IssueDetailPage({ params }: { params: Promise<{ id: stri const workspace = useWorkspace(); const slug = workspace.slug; const isAdmin = workspace.role === "OWNER" || workspace.role === "ADMIN"; - const railCardRef = useRef(null); - const [railCardHeight, setRailCardHeight] = useState(null); const { data: ws } = trpc.workspace.current.useQuery(); // 15s refetch keeps the topbar's RunActivityChip (and other live // surfaces on this page) honest without a manual reload while an @@ -255,37 +253,6 @@ export default function IssueDetailPage({ params }: { params: Promise<{ id: stri [siblings?.next?.id, slug], ); - useEffect(() => { - let frame = 0; - - const updateRailHeight = () => { - cancelAnimationFrame(frame); - frame = requestAnimationFrame(() => { - if (!window.matchMedia("(min-width: 768px)").matches) { - setRailCardHeight(null); - return; - } - - const el = railCardRef.current; - if (!el) return; - - const top = Math.max(16, el.getBoundingClientRect().top); - const available = Math.floor(window.innerHeight - top - 16); - const next = Math.max(240, available); - setRailCardHeight((prev) => (prev !== null && Math.abs(prev - next) < 2 ? prev : next)); - }); - }; - - updateRailHeight(); - window.addEventListener("resize", updateRailHeight); - window.addEventListener("scroll", updateRailHeight, true); - return () => { - cancelAnimationFrame(frame); - window.removeEventListener("resize", updateRailHeight); - window.removeEventListener("scroll", updateRailHeight, true); - }; - }, [issue?.id]); - if (error) return (
@@ -419,11 +386,10 @@ export default function IssueDetailPage({ params }: { params: Promise<{ id: stri ); diff --git a/src/components/activity-drawer.tsx b/src/components/activity-drawer.tsx index e3e9943e..abf487be 100644 --- a/src/components/activity-drawer.tsx +++ b/src/components/activity-drawer.tsx @@ -24,9 +24,11 @@ import { Inbox, MessageCircle, CircleCheck, + RefreshCw, UserCheck, X, } from "lucide-react"; +import { toast } from "sonner"; import type { inferRouterOutputs } from "@trpc/server"; import { trpc } from "@/lib/trpc"; import type { AppRouter } from "@/server/routers/_app"; @@ -40,7 +42,7 @@ import { mapActivityEventToNotification, type EventNotificationMetadata, } from "@/lib/notifications/event-notification"; -import { EmptyState, MOTION, SkeletonList } from "@/components/ui"; +import { Button, EmptyState, MOTION, SkeletonList } from "@/components/ui"; type Kind = | "ISSUE_CREATED" @@ -1060,7 +1062,7 @@ function AlertActivityRow({ disabled={isMutating || !onResolve} title="Resolve alert" aria-label="Resolve alert" - className="focus-ring inline-flex h-6 items-center gap-1 rounded-sm px-1.5 text-[0.6875rem] text-muted-foreground hover:bg-subtle hover:text-foreground disabled:opacity-50" + className="focus-ring inline-flex min-h-8 items-center gap-1 rounded-sm px-2 text-[0.6875rem] text-muted-foreground hover:bg-subtle hover:text-foreground disabled:opacity-50" > Resolve @@ -1070,24 +1072,24 @@ function AlertActivityRow({ type="button" onClick={() => onAcknowledge?.(stateId)} disabled={isMutating || !onAcknowledge} - title="Acknowledge alert" - aria-label="Acknowledge alert" - className="focus-ring inline-flex h-6 items-center gap-1 rounded-sm px-1.5 text-[0.6875rem] text-muted-foreground hover:bg-subtle hover:text-foreground disabled:opacity-50" + title="Mark as being handled" + aria-label="Mark alert as being handled" + className="focus-ring inline-flex min-h-8 items-center gap-1 rounded-sm px-2 text-[0.6875rem] text-muted-foreground hover:bg-subtle hover:text-foreground disabled:opacity-50" > - Ack + I’m handling )} )} @@ -1114,6 +1116,8 @@ export default function ActivityDrawer() { const [pages, setPages] = useState([]); const utils = trpc.useUtils(); const chatReadSentRef = useRef>({}); + const dialogRef = useRef(null); + const returnFocusRef = useRef(null); const invalidateNotifications = useCallback(() => { void utils.notification.list.invalidate(); @@ -1129,13 +1133,25 @@ export default function ActivityDrawer() { const markChatReadMutate = markChatRead.mutate; const markNotificationReadMutate = markNotificationRead.mutate; const acknowledgeNotification = trpc.notification.acknowledge.useMutation({ - onSuccess: invalidateNotifications, + onSuccess: () => { + invalidateNotifications(); + toast.success("Marked as being handled"); + }, + onError: (error) => toast.error("Could not acknowledge alert", { description: error.message }), }); const dismissNotification = trpc.notification.dismiss.useMutation({ - onSuccess: invalidateNotifications, + onSuccess: () => { + invalidateNotifications(); + toast.success("Alert hidden"); + }, + onError: (error) => toast.error("Could not dismiss alert", { description: error.message }), }); const resolveNotification = trpc.notification.resolve.useMutation({ - onSuccess: invalidateNotifications, + onSuccess: () => { + invalidateNotifications(); + toast.success("Alert resolved"); + }, + onError: (error) => toast.error("Could not resolve alert", { description: error.message }), }); // Closing the drawer also counts as "seeing" the inbox preview, so the // inbox-derived half of the bell badge (now a since-visit count) settles @@ -1144,11 +1160,11 @@ export default function ActivityDrawer() { onSuccess: () => void utils.inbox.badge.invalidate(), }); const inboxVisitMutate = inboxVisit.mutate; - const notificationMutating = - markNotificationRead.isPending || - acknowledgeNotification.isPending || - dismissNotification.isPending || - resolveNotification.isPending; + const notificationMutatingId = + (acknowledgeNotification.isPending ? acknowledgeNotification.variables?.id : null) ?? + (dismissNotification.isPending ? dismissNotification.variables?.id : null) ?? + (resolveNotification.isPending ? resolveNotification.variables?.id : null) ?? + null; useEffect(() => { if (open) setHasOpenedOnce(true); @@ -1166,11 +1182,43 @@ export default function ActivityDrawer() { useEffect(() => { if (!open) return; + returnFocusRef.current = document.activeElement as HTMLElement | null; + window.requestAnimationFrame(() => dialogRef.current?.focus()); const onKey = (e: KeyboardEvent) => { - if (e.key === "Escape") close(); + if (e.key === "Escape") { + close(); + return; + } + if (e.key !== "Tab" || !dialogRef.current) return; + const focusable = Array.from( + dialogRef.current.querySelectorAll( + 'a[href], button:not([disabled]), input:not([disabled]), [tabindex]:not([tabindex="-1"])', + ), + ).filter((element) => !element.hasAttribute("hidden")); + if (focusable.length === 0) { + e.preventDefault(); + dialogRef.current.focus(); + return; + } + const first = focusable[0]!; + const last = focusable[focusable.length - 1]!; + const active = document.activeElement; + if (active === dialogRef.current || !dialogRef.current.contains(active)) { + e.preventDefault(); + (e.shiftKey ? last : first).focus(); + } else if (e.shiftKey && active === first) { + e.preventDefault(); + last.focus(); + } else if (!e.shiftKey && active === last) { + e.preventDefault(); + first.focus(); + } }; window.addEventListener("keydown", onKey); - return () => window.removeEventListener("keydown", onKey); + return () => { + window.removeEventListener("keydown", onKey); + returnFocusRef.current?.focus(); + }; }, [open, close]); // Mark "seen" when the drawer CLOSES, not on a timer while it's open. @@ -1200,7 +1248,7 @@ export default function ActivityDrawer() { // Mine tab: pulls the same payload that powers /inbox so the badge // count, the drawer preview, and the full page all share a source. - const { data: mineData, isLoading: mineLoading } = trpc.inbox.get.useQuery( + const mineQuery = trpc.inbox.get.useQuery( { allWorkspaces: false }, { enabled: Boolean(ws) && (open || hasOpenedOnce) && tab === "mine", @@ -1208,8 +1256,9 @@ export default function ActivityDrawer() { staleTime: 30_000, }, ); + const { data: mineData, isLoading: mineLoading } = mineQuery; - const { data: attentionData, isLoading: attentionLoading } = trpc.notification.list.useQuery( + const attentionQuery = trpc.notification.list.useQuery( { limit: 30 }, { enabled: Boolean(ws) && (open || hasOpenedOnce) && tab === "mine", @@ -1217,6 +1266,7 @@ export default function ActivityDrawer() { staleTime: 30_000, }, ); + const { data: attentionData, isLoading: attentionLoading } = attentionQuery; useEffect(() => { if (!data) return; @@ -1276,9 +1326,11 @@ export default function ActivityDrawer() { onClick={close} >
); } + if (error && !data && alertRows.length === 0) { + return ( +
+ } + title="Notifications are unavailable" + description={error} + action={ + + } + /> +
+ ); + } if (!data && alertRows.length === 0) { return (
@@ -1561,6 +1640,23 @@ function MinePanel({ return (
+ {error ? ( +
+ + Some notification data could not be refreshed. + +
+ ) : null} {alertRows.length > 0 && ( ))} diff --git a/src/components/agent-attention-panel.tsx b/src/components/agent-attention-panel.tsx index 08f2052d..205f3832 100644 --- a/src/components/agent-attention-panel.tsx +++ b/src/components/agent-attention-panel.tsx @@ -8,12 +8,14 @@ import { ChevronRight, CircleHelp, Clock3, + RefreshCw, ShieldCheck, Workflow, } from "lucide-react"; import type { inferRouterOutputs } from "@trpc/server"; import { AgentPresenceDot } from "@/components/agent-presence-dot"; -import { EmptyState, SkeletonList } from "@/components/ui"; +import { Button, EmptyState, SkeletonList } from "@/components/ui"; +import { ExpandableText } from "@/components/ui/expandable-text"; import { trpc } from "@/lib/trpc"; import type { AppRouter } from "@/server/routers/_app"; import { useRealtime } from "@/hooks/use-realtime"; @@ -77,7 +79,7 @@ export function AgentAttentionPanel({ ...row, items: row.items.filter((item) => !excludedItems.has(item.id)), })); - if (!showEmpty && !query.isLoading && rows.length === 0) return null; + if (!showEmpty && !query.isLoading && !query.isError && rows.length === 0) return null; return (
@@ -85,7 +87,11 @@ export function AgentAttentionPanel({ Agent attention - {rows.length > 0 ? `${rows.length} with activity` : "All clear"} + {query.isError + ? "Unavailable" + : rows.length > 0 + ? `${rows.length} with activity` + : "All clear"}
+ ) : query.isError ? ( + } + title="Agent attention is unavailable" + description="Forge could not verify whether any agents need attention." + action={ + + } + /> ) : rows.length === 0 ? ( {row.items.map((item) => (
  • - +
    - - {item.title} - - {item.detail} - - +
    + + {item.title} + + +
    {relativeTime(item.createdAt)} - +
  • ))} diff --git a/src/components/agents/agent-runroom-dashboard.tsx b/src/components/agents/agent-runroom-dashboard.tsx index ad26c1bd..ec39db93 100644 --- a/src/components/agents/agent-runroom-dashboard.tsx +++ b/src/components/agents/agent-runroom-dashboard.tsx @@ -15,13 +15,14 @@ import { GitBranch, Inbox, Radio, + RefreshCw, Server, ShieldAlert, Workflow, XCircle, } from "lucide-react"; import { AgentPresenceDot } from "@/components/agent-presence-dot"; -import { EmptyState, SkeletonList } from "@/components/ui"; +import { Button, EmptyState, SkeletonList } from "@/components/ui"; import { trpc } from "@/lib/trpc"; import { useRealtime } from "@/hooks/use-realtime"; import { useWorkspace } from "@/hooks/use-workspace"; @@ -50,15 +51,18 @@ const FAILED_RUNS_INPUT = { export function AgentRunroomDashboard() { const ws = useWorkspace(); const utils = trpc.useUtils(); - const { data: agents } = trpc.agent.list.useQuery({ includeArchived: false }); - const { data: pipeline } = trpc.agent.pipeline.useQuery({}); + const agentsQuery = trpc.agent.list.useQuery({ includeArchived: false }); + const pipelineQuery = trpc.agent.pipeline.useQuery({}); const { data: dispatch } = trpc.analytics.dispatch.summary.useQuery({}); - const { data: workspace } = trpc.workspace.current.useQuery(); + const workspaceQuery = trpc.workspace.current.useQuery(); const { data: runtimes } = trpc.runtime.list.useQuery({ includeArchived: false }); const { data: globalRuntimes } = trpc.global.runtimes.useQuery(); const { data: activeRuns } = trpc.agentRun.activeAll.useQuery({ limit: 50 }); const { data: failedRuns } = trpc.agentRun.list.useQuery(FAILED_RUNS_INPUT); const { data: timeline } = trpc.agent.timeline.useQuery({ limit: 12 }); + const agents = agentsQuery.data; + const pipeline = pipelineQuery.data; + const workspace = workspaceQuery.data; useRealtime( () => { @@ -90,6 +94,31 @@ export function AgentRunroomDashboard() { }, ); + const blockingError = agentsQuery.error ?? pipelineQuery.error ?? workspaceQuery.error; + if (blockingError) { + return ( + } + title="Agent runroom is unavailable" + description={blockingError.message} + action={ + + } + /> + ); + } + if (!agents || !pipeline || !workspace) { return (
    @@ -127,8 +156,7 @@ export function AgentRunroomDashboard() { const runtimeOnline = (runtimes ?? []).filter( (runtime) => runtime.health.kind === "online", ).length; - const missedWakes = - noAckEvents.length + failedWindow.filter((run) => run.status === "STALLED").length; + const missedWakes = noAckEvents.length; const healthScore = Math.max( 0, Math.round( @@ -458,7 +486,13 @@ function AgentLaneCard({
    - + 0 && isDown ? heldWork : lane.assigned} diff --git a/src/components/attachments/issue-attachments-panel.tsx b/src/components/attachments/issue-attachments-panel.tsx index 8fe6fb5e..123fbddc 100644 --- a/src/components/attachments/issue-attachments-panel.tsx +++ b/src/components/attachments/issue-attachments-panel.tsx @@ -136,6 +136,7 @@ export function IssueAttachmentsPanel({ issueId }: { issueId: string }) { targetType: "issue", targetId: issueId, }); + utils.github.listLinked.invalidate({ issueId }); utils.issue.byId.invalidate({ id: issueId }); }, onError: (e) => toast.error(e.message), diff --git a/src/components/issue-detail/agent-run-strip.tsx b/src/components/issue-detail/agent-run-strip.tsx index 3d1c217f..f80a0d60 100644 --- a/src/components/issue-detail/agent-run-strip.tsx +++ b/src/components/issue-detail/agent-run-strip.tsx @@ -166,11 +166,15 @@ export function AgentRunStrip({ ? formatElapsed(now - new Date(displayRun.startedAt).getTime()) : null; const lastSignalLabel = displayRun ? relativeTime(displayRun.lastEventAt) : null; - const step = + const liveStep = activeRun?.currentStep ?? activeRun?.statusComment?.currentStep ?? relevantLatestRun?.currentStep ?? - stateCopy(state); + null; + // Historical rows may contain a buffered provider step written around the + // completion boundary. Terminal presentation is owned by lifecycle state, + // never by a stale live-step label. + const step = isActiveRun ? (liveStep ?? stateCopy(state)) : stateCopy(state); const statusBody = activeRun?.statusComment?.body?.trim(); const statusTone = stateTone(state); @@ -238,7 +242,7 @@ export function AgentRunStrip({
    - + {isActiveRun ? : null} {activityHref ? ( -
    +
    0 ? "border-b border-border/60" : ""}`} + > GitHub - {links?.length ?? 0} + {isLoading ? "…" : (links?.length ?? 0)}
    -
    - {isLoading ? ( -
    Loading links...
    - ) : links && links.length > 0 ? ( + {links && links.length > 0 ? ( +
    {links.map((link) => { const resource = link.externalResource; const isPr = resource.resourceType === "PULL_REQUEST"; return ( -
    +
    {isPr ? ( @@ -124,17 +121,8 @@ export function GitHubLinksPanel({ issueId }: { issueId: string }) { ); })}
    - ) : ( - - )} -
    +
    + ) : null}
    diff --git a/src/components/issue-detail/issue-rail.tsx b/src/components/issue-detail/issue-rail.tsx index c120eac4..a575df30 100644 --- a/src/components/issue-detail/issue-rail.tsx +++ b/src/components/issue-detail/issue-rail.tsx @@ -14,8 +14,8 @@ import { trpc } from "@/lib/trpc"; * attachments + relations panels and the new activity stream. * * Tab state is mirrored into the `?tab=` search param so individual tabs - * are deep-linkable (and survive reloads). Activity leads the navigation; - * the historical bare URL still resolves to Attachments for compatibility. + * are deep-linkable (and survive reloads). Activity leads the navigation + * and is the bare-URL default so recent work is visible without discovery. * Keys 1/2/3 cycle between tabs * when the page has focus — bound here, scoped to the issue detail route * so we don't pollute global keybindings. @@ -29,6 +29,14 @@ const TABS = [ type TabId = (typeof TABS)[number]["id"]; +function tabButtonId(issueId: string, tab: TabId) { + return `issue-${issueId}-${tab}-tab`; +} + +function tabPanelId(issueId: string) { + return `issue-${issueId}-detail-panel`; +} + function isTabId(v: string | null | undefined): v is TabId { return v === "attachments" || v === "relations" || v === "activity"; } @@ -52,7 +60,7 @@ export function IssueRail({ const pathname = usePathname(); const searchParams = useSearchParams(); const raw = searchParams?.get("tab"); - const active: TabId = isTabId(raw) ? raw : "attachments"; + const active: TabId = isTabId(raw) ? raw : "activity"; const { data: activeRun } = trpc.agentRun.activeForIssue.useQuery( { issueId }, { staleTime: 5_000 }, @@ -61,7 +69,7 @@ export function IssueRail({ const setTab = useCallback( (next: TabId) => { const params = new URLSearchParams(searchParams?.toString() ?? ""); - if (next === "attachments") params.delete("tab"); + if (next === "activity") params.delete("tab"); else params.set("tab", next); const q = params.toString(); router.replace(q ? `${pathname}?${q}` : pathname, { scroll: false }); @@ -96,9 +104,17 @@ export function IssueRail({ return () => window.removeEventListener("keydown", onKey); }, [setTab]); + const focusTab = useCallback( + (next: TabId) => { + setTab(next); + requestAnimationFrame(() => document.getElementById(tabButtonId(issueId, next))?.focus()); + }, + [issueId, setTab], + ); + return (
    - {header &&
    {header}
    } + {header &&
    {header}
    }
    { const Icon = t.icon; const selected = active === t.id; + const index = TABS.indexOf(t); return (
    -
    +
    {/* Keep each tab simple — reuse the battle-tested panels as-is. */}
    diff --git a/src/components/pins/pins-strip.tsx b/src/components/pins/pins-strip.tsx index 96903e99..0bd007af 100644 --- a/src/components/pins/pins-strip.tsx +++ b/src/components/pins/pins-strip.tsx @@ -4,6 +4,7 @@ import { useEffect, useMemo, useRef, useState } from "react"; import { toast } from "sonner"; import { Pin as PinIcon, + X, FolderKanban, Diamond, Filter, @@ -40,6 +41,15 @@ export function PinsStrip() { const utils = trpc.useUtils(); const { data } = trpc.pin.listAll.useQuery({ workspaceId: null }); const pins = (data ?? []) as HydratedPin[]; + const remove = trpc.pin.remove.useMutation({ + onSuccess: () => { + void utils.pin.listAll.invalidate(); + void utils.pin.list.invalidate(); + broadcastCrossTab({ type: "pins:updated" }); + toast.success("Unpinned"); + }, + onError: (error) => toast.error(error.message), + }); // If another tab toggles a pin, refresh our cached list so the strip // stays consistent across windows. Pins are user-scoped (not tied to @@ -62,7 +72,21 @@ export function PinsStrip() { if (!pin || !pin.target) { return ; } - return ; + return ( + remove.mutate({ id: pin.id })} + removing={ + remove.isPending && + Boolean( + remove.variables && + "id" in remove.variables && + remove.variables.id === pin.id, + ) + } + /> + ); })}
    ); @@ -252,7 +276,38 @@ function TargetIcon({ kind }: { kind: HydratedPinTarget["targetType"] }) { * keeps the same outer dimensions (`h-7`) so the strip stays a uniform * height regardless of which mix of pin types the user has. */ -function PinChip({ target }: { target: HydratedPinTarget }) { +function PinChip({ + target, + onRemove, + removing, +}: { + target: HydratedPinTarget; + onRemove: () => void; + removing: boolean; +}) { + const label = labelFor(target); + return ( +
    + + +
    + ); +} + +function PinChipLink({ target }: { target: HydratedPinTarget }) { switch (target.targetType) { case "ISSUE": return ; @@ -270,7 +325,7 @@ function PinChip({ target }: { target: HydratedPinTarget }) { } const STRIP_BASE = cn( - "focus-ring group inline-flex h-7 max-w-[220px] items-center gap-1.5 rounded-md border border-border bg-card/60 pl-1 pr-2 text-left hover:bg-subtle", + "focus-ring group inline-flex h-7 max-w-[220px] items-center gap-1.5 rounded-md border border-border bg-card/60 pl-1 pr-7 text-left hover:bg-subtle", MOTION.base, ); @@ -417,4 +472,3 @@ function AgentStripChip({ ); } - diff --git a/src/components/ui/expandable-text.tsx b/src/components/ui/expandable-text.tsx new file mode 100644 index 00000000..940c44dc --- /dev/null +++ b/src/components/ui/expandable-text.tsx @@ -0,0 +1,62 @@ +"use client"; + +import { useEffect, useId, useRef, useState } from "react"; +import { ChevronDown, ChevronUp } from "lucide-react"; +import { cn } from "@/lib/utils"; + +/** + * Keeps long card copy at a predictable two-line height until the operator + * explicitly asks to read it. The expanded state is intentionally local and + * ephemeral: collapsing the card restores the clean queue scan pattern. + */ +export function ExpandableText({ content, className }: { content: string; className?: string }) { + const contentId = useId(); + const textRef = useRef(null); + const [expanded, setExpanded] = useState(false); + const [canExpand, setCanExpand] = useState(false); + + useEffect(() => { + if (expanded) return; + const node = textRef.current; + if (!node) return; + + const measure = () => setCanExpand(node.scrollHeight > node.clientHeight + 1); + measure(); + + if (typeof ResizeObserver === "undefined") return; + const observer = new ResizeObserver(measure); + observer.observe(node); + return () => observer.disconnect(); + }, [content, expanded]); + + return ( +
    +
    + {content} +
    + {canExpand ? ( + + ) : null} +
    + ); +} diff --git a/src/server/routers/__tests__/notification.test.ts b/src/server/routers/__tests__/notification.test.ts index 2660291d..4eda1bae 100644 --- a/src/server/routers/__tests__/notification.test.ts +++ b/src/server/routers/__tests__/notification.test.ts @@ -66,10 +66,7 @@ async function createStalledEvent(params: Awaited>, cre describe("notificationRouter", () => { it("materializes alertable activity events into persisted notification state", async () => { const setupData = await setup(); - const event = await createStalledEvent( - setupData, - new Date("2026-04-26T12:00:00Z"), - ); + const event = await createStalledEvent(setupData, new Date("2026-04-26T12:00:00Z")); const unread = await setupData.caller.unreadCount(); expect(unread.count).toBe(1); @@ -136,14 +133,8 @@ describe("notificationRouter", () => { it("uses replacementKey to keep only the latest matching alert active", async () => { const setupData = await setup(); - const older = await createStalledEvent( - setupData, - new Date("2026-04-26T14:00:00Z"), - ); - const newer = await createStalledEvent( - setupData, - new Date("2026-04-26T14:05:00Z"), - ); + const older = await createStalledEvent(setupData, new Date("2026-04-26T14:00:00Z")); + const newer = await createStalledEvent(setupData, new Date("2026-04-26T14:05:00Z")); const list = await setupData.caller.list({ limit: 10 }); expect(list.notifications.map((n) => n.event.id)).toEqual([newer.id]); @@ -162,4 +153,51 @@ describe("notificationRouter", () => { ]); expect(states[0].replacementKey).toBe(states[1].replacementKey); }); + + it("auto-resolves a stalled alert after later issue activity proves recovery", async () => { + const setupData = await setup(); + const stalled = await createStalledEvent(setupData, new Date("2026-04-26T15:00:00Z")); + + expect((await setupData.caller.list({ limit: 10 })).notifications).toHaveLength(1); + + await setupData.prisma.activityEvent.create({ + data: { + workspaceId: setupData.fixture.workspace.id, + kind: EventKind.COMMENT_CREATED, + actorId: setupData.fixture.user.id, + subjectType: "issue", + subjectId: setupData.issue.id, + payload: { issueId: setupData.issue.id, body: "Are you still working on this?" }, + createdAt: new Date("2026-04-26T15:00:30Z"), + }, + }); + expect((await setupData.caller.list({ limit: 10 })).notifications).toHaveLength(1); + + await setupData.prisma.activityEvent.create({ + data: { + workspaceId: setupData.fixture.workspace.id, + kind: EventKind.COMMENT_CREATED, + actorAgentId: setupData.agent.id, + subjectType: "issue", + subjectId: setupData.issue.id, + payload: { issueId: setupData.issue.id, body: "Recovered and replied." }, + createdAt: new Date("2026-04-26T15:01:00Z"), + }, + }); + + expect((await setupData.caller.list({ limit: 10 })).notifications).toHaveLength(0); + expect((await setupData.caller.unreadCount()).count).toBe(0); + + const state = await setupData.prisma.notificationState.findUniqueOrThrow({ + where: { + workspaceId_userId_eventId: { + workspaceId: setupData.fixture.workspace.id, + userId: setupData.fixture.user.id, + eventId: stalled.id, + }, + }, + }); + expect(state.status).toBe(NotificationStatus.RESOLVED); + expect(state.resolvedAt).toBeInstanceOf(Date); + }); }); diff --git a/src/server/routers/__tests__/pin.test.ts b/src/server/routers/__tests__/pin.test.ts new file mode 100644 index 00000000..d0e302da --- /dev/null +++ b/src/server/routers/__tests__/pin.test.ts @@ -0,0 +1,60 @@ +import { afterAll, afterEach, describe, expect, it } from "vitest"; +import { pinRouter } from "@/server/routers/pin"; +import { + buildContext, + createIssue, + createWorkspaceFixture, + disconnectPrisma, + getPrisma, + type TestFixture, +} from "./helpers"; + +const fixtures: TestFixture[] = []; + +afterEach(async () => { + while (fixtures.length) await fixtures.pop()!.cleanup(); +}); + +afterAll(async () => { + await disconnectPrisma(); +}); + +describe("pinRouter legacy row ids", () => { + it("reorders and removes migration-0023 pin_ ids", async () => { + const fixture = await createWorkspaceFixture({ keyPrefix: "PIN" }); + fixtures.push(fixture); + const prisma = getPrisma(); + const caller = pinRouter.createCaller(await buildContext(fixture)); + const legacyIssue = await createIssue(fixture, { title: "Legacy pinned issue" }); + const modernIssue = await createIssue(fixture, { title: "Modern pinned issue" }); + const legacyId = "pin_5c6707a7bb57b32f8e6ac1f622b7fe4c"; + await prisma.pin.create({ + data: { + id: legacyId, + userId: fixture.user.id, + workspaceId: null, + targetType: "ISSUE", + targetId: legacyIssue.id, + orderIndex: 0, + }, + }); + const modern = await prisma.pin.create({ + data: { + userId: fixture.user.id, + workspaceId: null, + targetType: "ISSUE", + targetId: modernIssue.id, + orderIndex: 1, + }, + }); + + await expect( + caller.reorder({ workspaceId: null, ids: [modern.id, legacyId] }), + ).resolves.toEqual({ reordered: 2 }); + const ordered = await caller.listAll({ workspaceId: null }); + expect(ordered.map((pin) => pin.id)).toEqual([modern.id, legacyId]); + + await expect(caller.remove({ id: legacyId })).resolves.toEqual({ removed: 1 }); + expect(await prisma.pin.findUnique({ where: { id: legacyId } })).toBeNull(); + }); +}); diff --git a/src/server/routers/attachment.ts b/src/server/routers/attachment.ts index 8374cda4..537a7275 100644 --- a/src/server/routers/attachment.ts +++ b/src/server/routers/attachment.ts @@ -22,6 +22,8 @@ import { presignUploadUrl, workspaceQuotaStats, } from "@/server/services/storage"; +import { parseGitHubIssueOrPrRef } from "@/lib/github-ref"; +import { linkGitHubUrlToIssue } from "@/server/services/github/resource-sync"; /** * Map an exception from the storage layer onto a tRPC error. We use a @@ -171,7 +173,7 @@ export const attachmentRouter = router({ }), /** - * Attach an external link (Google Doc, GitHub PR, Linear ticket, …) + * Attach an external link (Google Doc, Linear ticket, …) * as a first-class Attachment row. No bytes hit MinIO. Mirrors the * MCP `attachments.attachLink` tool — the storage helper enforces * the FILE-shaped column population (filename = title ?? hostname, @@ -183,6 +185,22 @@ export const attachmentRouter = router({ .mutation(async ({ ctx, input }) => { await assertTargetInWorkspace(ctx, input.targetType, input.targetId); const safeUrl = assertSafeExternalUrl(input.url); + if (input.targetType === "issue" && parseGitHubIssueOrPrRef(safeUrl)) { + const native = await linkGitHubUrlToIssue({ + db: ctx.db, + workspaceId: ctx.workspaceId, + issueId: input.targetId, + url: safeUrl, + kind: "RELATES_TO", + actor: { + actorId: ctx.session.user.id, + actorAgentId: ctx.apiKey?.linkedAgentId ?? null, + ip: ctx.ip, + userAgent: ctx.userAgent, + }, + }); + return { id: native.link.id, routedTo: "github.link" as const, ...native }; + } // Caller didn't provide a label → try to scrape off the // target page so the chip is meaningful. Fail soft: any error // leaves resolvedTitle null and createLinkAttachment falls back diff --git a/src/server/routers/command-center.ts b/src/server/routers/command-center.ts index 1acdcf54..eb05ff98 100644 --- a/src/server/routers/command-center.ts +++ b/src/server/routers/command-center.ts @@ -78,8 +78,12 @@ export const commandCenterRouter = router({ const [ actionRequests, + actionRequestCount, reviewGates, + reviewGateCount, activeRuns, + activeRunCount, + runtimeApprovalCount, runRecovery, recentArtifacts, dueIssues, @@ -109,11 +113,17 @@ export const commandCenterRouter = router({ }, }) : Promise.resolve([]), + userId + ? ctx.db.actionRequest.count({ where: decisionAskWhere(ctx.workspaceId, userId) }) + : Promise.resolve(0), listReviewGatesWithContext(ctx.db, { workspaceId: ctx.workspaceId, status: ReviewGateStatus.PENDING, limit: input.limit, }), + ctx.db.reviewGate.count({ + where: { workspaceId: ctx.workspaceId, status: ReviewGateStatus.PENDING }, + }), ctx.db.agentRun.findMany({ where: { workspaceId: ctx.workspaceId, @@ -136,6 +146,20 @@ export const commandCenterRouter = router({ }, }, }), + ctx.db.agentRun.count({ + where: { + workspaceId: ctx.workspaceId, + status: { in: [AgentRunStatus.ACTIVE, AgentRunStatus.WAITING] }, + awaitingApprovalAt: null, + }, + }), + ctx.db.agentRun.count({ + where: { + workspaceId: ctx.workspaceId, + status: { in: [AgentRunStatus.ACTIVE, AgentRunStatus.WAITING] }, + awaitingApprovalAt: { not: null }, + }, + }), listRunRecoveryItems(ctx.db, { workspaceId: ctx.workspaceId, limit: input.limit, @@ -255,10 +279,10 @@ export const commandCenterRouter = router({ runningTimer, liveGoals, counts: { - actionRequests: groupedActionRequests.length, - reviewGates: reviewGates.length, - activeRuns: visibleActiveRuns.length, - runtimeApprovals: runtimeApprovals.length, + actionRequests: actionRequestCount, + reviewGates: reviewGateCount, + activeRuns: activeRunCount, + runtimeApprovals: runtimeApprovalCount, stalledRuns: runRecovery.counts.total, recentArtifacts: recentArtifacts.length, dueIssues: dueIssues.length, diff --git a/src/server/routers/notification.ts b/src/server/routers/notification.ts index 85f735a5..3bb62548 100644 --- a/src/server/routers/notification.ts +++ b/src/server/routers/notification.ts @@ -16,6 +16,7 @@ import { loadEffectivePreferenceMap, materializeRecentNotifications, notificationStateInclude, + reconcileRecoveredNotifications, } from "@/server/services/notifications"; import { getWebPushPublicKey, @@ -98,6 +99,11 @@ export const notificationRouter = router({ userId: ctx.session.user.id, limit: Math.max(input.limit * 3, 100), }); + await reconcileRecoveredNotifications(ctx.db, { + workspaceId: ctx.workspaceId, + userId: ctx.session.user.id, + limit: Math.max(input.limit * 3, 100), + }); const cursorRow = input.cursor ? await ctx.db.notificationState.findFirst({ @@ -143,6 +149,11 @@ export const notificationRouter = router({ userId: ctx.session.user.id, limit: 100, }); + await reconcileRecoveredNotifications(ctx.db, { + workspaceId: ctx.workspaceId, + userId: ctx.session.user.id, + limit: 100, + }); const count = await ctx.db.notificationState.count({ where: { workspaceId: ctx.workspaceId, diff --git a/src/server/routers/pin.ts b/src/server/routers/pin.ts index a35252e9..b92d0e85 100644 --- a/src/server/routers/pin.ts +++ b/src/server/routers/pin.ts @@ -48,6 +48,14 @@ const MAX_LEGACY_PINS = 3; */ const MAX_PINS_PER_SCOPE = 5; +// Migration 0023 backfilled existing pins as `pin_${md5(...)}` while +// Prisma-generated rows use CUIDs. Both are durable public row ids and +// must remain accepted anywhere a pin id is supplied. +const pinRowIdSchema = z.union([ + z.string().cuid(), + z.string().regex(/^pin_[a-f0-9]{32}$/, "Invalid pin id."), +]); + // --------------------------------------------------------------------------- // Hydration helpers // --------------------------------------------------------------------------- @@ -765,7 +773,7 @@ export const pinRouter = router({ remove: protectedProcedure .input( z.union([ - z.object({ id: z.string().cuid() }), + z.object({ id: pinRowIdSchema }), z.object({ targetType: z.nativeEnum(PinTargetType), targetId: z.string(), @@ -865,7 +873,7 @@ export const pinRouter = router({ .input( z.object({ workspaceId: z.string().cuid().nullable().optional(), - ids: z.array(z.string().cuid()).max(200), + ids: z.array(pinRowIdSchema).max(200), }), ) .mutation(async ({ ctx, input }) => { diff --git a/src/server/services/__tests__/agent-run-attention.test.ts b/src/server/services/__tests__/agent-run-attention.test.ts index 82e55482..25ff1234 100644 --- a/src/server/services/__tests__/agent-run-attention.test.ts +++ b/src/server/services/__tests__/agent-run-attention.test.ts @@ -32,6 +32,36 @@ async function createAgent(workspaceId: string, suffix: string) { } describe("agent-run operator attention", () => { + it("keeps a patient WAITING run out of generic stale recovery", async () => { + const fixture = await createWorkspaceFixture({ keyPrefix: "RW" }); + fixtures.push(fixture); + const prisma = getPrisma(); + const issue = await createIssue(fixture, { title: "Waiting on a user answer" }); + const agent = await createAgent(fixture.workspace.id, "patient"); + const run = await prisma.agentRun.create({ + data: { + workspaceId: fixture.workspace.id, + issueId: issue.id, + agentId: agent.id, + status: AgentRunStatus.WAITING, + currentStep: "Waiting for the user to provide the full rollout requirements.", + }, + }); + await prisma.$executeRawUnsafe( + `UPDATE "AgentRun" SET "lastEventAt" = $1 WHERE "id" = $2`, + new Date(Date.now() - 60 * 60_000), + run.id, + ); + + const recovery = await listRunRecoveryItems(prisma, { + workspaceId: fixture.workspace.id, + limit: 20, + }); + + expect(recovery.items.map((item) => item.id)).not.toContain(run.id); + expect(recovery.counts.activeStale).toBe(0); + }); + it("does not misclassify an old approval pause as generic stale recovery", async () => { const fixture = await createWorkspaceFixture({ keyPrefix: "RA" }); fixtures.push(fixture); diff --git a/src/server/services/__tests__/agent-run.test.ts b/src/server/services/__tests__/agent-run.test.ts index 4c6948b6..2769a382 100644 --- a/src/server/services/__tests__/agent-run.test.ts +++ b/src/server/services/__tests__/agent-run.test.ts @@ -98,6 +98,19 @@ describe("agent-run lifecycle", () => { expect(completed.actorId).toBe(fixture.user.id); expect(completed.actorAgentId).toBe(agent.id); + const finalComment = await prisma.comment.findFirstOrThrow({ + where: { + issueId: issue.id, + authoringAgentId: agent.id, + kind: "BODY", + body: "Done.", + }, + }); + const completedRun = await prisma.agentRun.findUniqueOrThrow({ where: { id: run.id } }); + expect(completedRun.completionMeta).toMatchObject({ + completionCommentId: finalComment.id, + }); + const audit = await prisma.auditLog.findMany({ where: { workspaceId: fixture.workspace.id, @@ -110,6 +123,49 @@ describe("agent-run lifecycle", () => { expect(audit.every((row) => row.actorAgentId === agent.id)).toBe(true); }); + it("creates one final comment when completion attempts race", async () => { + const fixture = await createWorkspaceFixture({ keyPrefix: "ARC" }); + fixtures.push(fixture); + const prisma = getPrisma(); + const agent = await createAgent(fixture.workspace.id, "arc-a1"); + const issue = await createIssue(fixture); + const run = await prisma.agentRun.create({ + data: { + workspaceId: fixture.workspace.id, + issueId: issue.id, + agentId: agent.id, + }, + }); + const complete = () => + prisma.$transaction((tx) => + finishRun(tx, { + runId: run.id, + workspaceId: fixture.workspace.id, + issueId: issue.id, + agentId: agent.id, + status: "COMPLETED", + summary: "One durable result.", + actorAgentId: agent.id, + }), + ); + + await Promise.all([complete(), complete()]); + + expect( + await prisma.comment.count({ + where: { issueId: issue.id, authoringAgentId: agent.id, body: "One durable result." }, + }), + ).toBe(1); + expect(await prisma.agentRunEvent.count({ where: { runId: run.id, kind: "COMPLETED" } })).toBe( + 1, + ); + expect( + await prisma.activityEvent.count({ + where: { subjectType: "agent-run", subjectId: run.id, kind: EventKind.AGENT_RUN_COMPLETED }, + }), + ).toBe(1); + }); + it("a fresh assignment touch re-stamps mode + source and resumes WAITING (Phase 2)", async () => { // A genuine AGENT_ASSIGNED (fresh assignmentEventId) is an authoritative // (re)dispatch: the resolved mode the inbox passes in wins. Sticky-mode @@ -222,6 +278,23 @@ describe("agent-run lifecycle", () => { const after = await prisma.agentRun.findUniqueOrThrow({ where: { id: run.id } }); expect(after.status).toBe(AgentRunStatus.ABANDONED); expect(after.finishedAt).not.toBeNull(); + expect(after.currentStep).toBeNull(); + + const eventCount = await prisma.agentRunEvent.count({ where: { runId: run.id } }); + await prisma.$transaction((tx) => + appendRunEvent(tx, { + runId: run.id, + workspaceId: fixture.workspace.id, + issueId: issue.id, + agentId: agent.id, + kind: "STEP", + currentStep: "late thinking", + payload: { thinking: "buffered after completion" }, + }), + ); + const afterLateEvent = await prisma.agentRun.findUniqueOrThrow({ where: { id: run.id } }); + expect(afterLateEvent.currentStep).toBeNull(); + expect(await prisma.agentRunEvent.count({ where: { runId: run.id } })).toBe(eventCount); }); it("records one actionable approval lifecycle across competing producers", async () => { diff --git a/src/server/services/__tests__/mcp.test.ts b/src/server/services/__tests__/mcp.test.ts index 22481a67..92f15fbc 100644 --- a/src/server/services/__tests__/mcp.test.ts +++ b/src/server/services/__tests__/mcp.test.ts @@ -3933,7 +3933,7 @@ describe("mcp runs.complete + completion contract", () => { producedArtifactIds: string[]; verificationResult: unknown; followUps: unknown; - completionMeta: { mode: string; contractVersion: string }; + completionMeta: { mode: string; contractVersion: string; completionCommentId: string }; }; expect(result.summary).toBe("Migration shipped."); expect(result.producedArtifactIds).toEqual([artifact.id]); @@ -3941,6 +3941,17 @@ describe("mcp runs.complete + completion contract", () => { expect(result.followUps).toBeTruthy(); expect(result.completionMeta.mode).toBe("EXECUTE"); expect(result.completionMeta.contractVersion).toMatch(/^2026-/); + expect(result.completionMeta.completionCommentId).toBeTruthy(); + + const completionComment = await prisma.comment.findUniqueOrThrow({ + where: { id: result.completionMeta.completionCommentId }, + }); + expect(completionComment).toMatchObject({ + issueId: issue.id, + authoringAgentId: agent.id, + kind: "BODY", + body: "Migration shipped.", + }); const completed = await prisma.agentRun.findUniqueOrThrow({ where: { id: run.id } }); expect(completed.status).toBe("COMPLETED"); @@ -3969,6 +3980,52 @@ describe("mcp runs.complete + completion contract", () => { expect(audit.actorAgentId).toBe(agent.id); }); + it("runs.complete can adopt an already-posted final comment without duplicating it", async () => { + const fixture = await createWorkspaceFixture({ keyPrefix: "CCC" }); + fixtures.push(fixture); + const prisma = getPrisma(); + const { ctx } = buildMcpCtx(fixture); + const agent = await prisma.agent.create({ + data: { + workspaceId: fixture.workspace.id, + profileKey: `ccc-${Date.now()}`, + name: "Comment closer", + }, + }); + const issue = await createIssue(fixture); + const run = await prisma.agentRun.create({ + data: { + workspaceId: fixture.workspace.id, + issueId: issue.id, + agentId: agent.id, + engagementMode: EngagementMode.DISCUSS, + }, + }); + const scopedCtx = { ...ctx, apiKey: { ...ctx.apiKey!, linkedAgentId: agent.id } }; + const comment = (await call( + "comments.create", + { issueId: issue.id, body: "Yes — the PR is linked natively." }, + scopedCtx, + )) as { id: string }; + + const completed = (await call( + "runs.complete", + { + runId: run.id, + summary: "Yes — the PR is linked natively.", + completionCommentId: comment.id, + }, + scopedCtx, + )) as { completionMeta: { completionCommentId: string } }; + + expect(completed.completionMeta.completionCommentId).toBe(comment.id); + expect( + await prisma.comment.count({ + where: { issueId: issue.id, authoringAgentId: agent.id, kind: "BODY" }, + }), + ).toBe(1); + }); + it("runs.complete atomically advances its execution step to review", async () => { const fixture = await createWorkspaceFixture({ keyPrefix: "CCS" }); fixtures.push(fixture); diff --git a/src/server/services/__tests__/run-dispatcher.test.ts b/src/server/services/__tests__/run-dispatcher.test.ts index 2e873265..23e37131 100644 --- a/src/server/services/__tests__/run-dispatcher.test.ts +++ b/src/server/services/__tests__/run-dispatcher.test.ts @@ -101,6 +101,62 @@ describe("runs dispatcher", () => { expect(after.summary).toMatch(/provider no longer recognizes this run/i); }); + it("leaves a patient WAITING run parked when its provider turn has ended", async () => { + const fixture = await createWorkspaceFixture({ keyPrefix: "RWP" }); + fixtures.push(fixture); + const prisma = getPrisma(); + const runtime = await prisma.runtime.create({ + data: { + workspaceId: fixture.workspace.id, + ownerId: fixture.user.id, + name: "Hermes patient wait", + kind: RuntimeKind.REMOTE_HTTP, + adapterKey: "hermes", + endpoint: "http://hermes.invalid/v1", + providersAvailable: [AgentProvider.HERMES], + }, + }); + const agent = await prisma.agent.create({ + data: { + workspaceId: fixture.workspace.id, + name: "patient runner", + profileKey: "patient-runner", + provider: AgentProvider.HERMES, + runEngine: RunEngine.RUNS, + runtimeId: runtime.id, + status: "ONLINE", + }, + }); + const issue = await createIssue(fixture, { title: "waiting for an operator reply" }); + const run = await prisma.agentRun.create({ + data: { + workspaceId: fixture.workspace.id, + issueId: issue.id, + agentId: agent.id, + status: AgentRunStatus.WAITING, + externalRunId: "run_patient_wait", + currentStep: "Waiting for the operator to choose a deployment target.", + }, + }); + const fetchMock = vi.fn(async () => + Response.json({ status: "completed", output: "Waiting for user reply." }), + ); + vi.stubGlobal("fetch", fetchMock); + + await ingestRunsDispatch(); + + expect(fetchMock).not.toHaveBeenCalled(); + const after = await prisma.agentRun.findUniqueOrThrow({ where: { id: run.id } }); + expect(after.status).toBe(AgentRunStatus.WAITING); + expect(after.finishedAt).toBeNull(); + expect(after.summary).toBeNull(); + expect( + await prisma.comment.count({ + where: { issueId: issue.id, body: { contains: "[dispatch · run stalled]" } }, + }), + ).toBe(0); + }); + it("starts the provider run when the durable inbox already precreated AgentRun", async () => { const previousE2E = process.env.FORGE_E2E; process.env.FORGE_E2E = "1"; diff --git a/src/server/services/agent-run-recovery.ts b/src/server/services/agent-run-recovery.ts index 35082cca..11a2d725 100644 --- a/src/server/services/agent-run-recovery.ts +++ b/src/server/services/agent-run-recovery.ts @@ -11,10 +11,7 @@ import { type Tx = PrismaClient | Prisma.TransactionClient; -export type RunRecoveryReason = - | "active-stale" - | "terminal-failure" - | "protocol-failed"; +export type RunRecoveryReason = "active-stale" | "terminal-failure" | "protocol-failed"; export type RunRecoveryAction = "ABANDON" | "CLEAR" | "RECONCILE"; @@ -180,23 +177,19 @@ export function classifyRunForRecovery( }; } - if ( - (run.status === "ACTIVE" || run.status === "WAITING") && - !run.awaitingApprovalAt && - idleMs >= STALE_RUN_MS - ) { + // WAITING is a deliberate patient-agent state, not generic stale work. + // Questions and approvals surface through their dedicated attention rows; + // listing a quiet wait again as a "stalled run" duplicates the operator + // card and invites an incorrect abandon action. + if (run.status === "ACTIVE" && !run.awaitingApprovalAt && idleMs >= STALE_RUN_MS) { const detail = warningDiagnostic?.description ?? - (run.status === "WAITING" - ? "The agent is waiting and has not updated the run recently." - : "The run is active but has not emitted a recent AgentRunEvent."); + "The run is active but has not emitted a recent AgentRunEvent."; return { id: run.id, reason: "active-stale", severity: "warning", - title: - warningDiagnostic?.title ?? - (run.status === "WAITING" ? "Waiting run is quiet" : "Active run is stale"), + title: warningDiagnostic?.title ?? "Active run is stale", detail, idleMs, recommendedAction: "ABANDON", @@ -273,10 +266,7 @@ export async function listRunRecoveryItems( async function clearRun( tx: Tx, input: { - run: Pick< - AgentRunRecoveryRow, - "id" | "issueId" | "agentId" | "status" | "clearedAt" - >; + run: Pick<AgentRunRecoveryRow, "id" | "issueId" | "agentId" | "status" | "clearedAt">; workspaceId: string; actorId: string; actorAgentId?: string | null; @@ -401,6 +391,7 @@ export async function recoverAgentRuns( diagnostics: item.diagnostics, }, currentStep: "reconciled by operator", + allowTerminal: true, }); await clearRun(tx, { run, diff --git a/src/server/services/agent-run.ts b/src/server/services/agent-run.ts index 5ce2f764..6b7d59bf 100644 --- a/src/server/services/agent-run.ts +++ b/src/server/services/agent-run.ts @@ -363,9 +363,35 @@ export async function appendRunEvent( kind: string; payload?: Prisma.InputJsonValue; currentStep?: string | null; + /** Internal operator audit events may be appended without reviving terminal UI state. */ + allowTerminal?: boolean; }, ): Promise<void> { const now = new Date(); + // Terminal rows are immutable from the operator's point of view. Provider + // subscriptions can deliver a buffered thinking/tool event after + // `runs.complete` has already closed the run; claim the live row first so + // that late event cannot put a COMPLETED card back into "thinking". + const live = await tx.agentRun.updateMany({ + where: { + id: params.runId, + status: { in: [AgentRunStatus.ACTIVE, AgentRunStatus.WAITING] }, + }, + data: { + lastEventAt: now, + ...(params.currentStep !== undefined ? { currentStep: params.currentStep } : {}), + }, + }); + if (live.count === 0 && !params.allowTerminal) return; + + if (live.count === 0) { + const terminal = await tx.agentRun.findFirst({ + where: { id: params.runId, workspaceId: params.workspaceId }, + select: { id: true }, + }); + if (!terminal) return; + } + const event = await tx.agentRunEvent.create({ data: { workspaceId: params.workspaceId, @@ -374,19 +400,12 @@ export async function appendRunEvent( payload: params.payload ?? Prisma.JsonNull, }, }); - if (OUTPUT_EVENT_KINDS.has(params.kind)) { + if (live.count > 0 && OUTPUT_EVENT_KINDS.has(params.kind)) { await tx.agentRun.updateMany({ where: { id: params.runId, outputStartedAt: null }, data: { outputStartedAt: now }, }); } - await tx.agentRun.update({ - where: { id: params.runId }, - data: { - lastEventAt: now, - ...(params.currentStep !== undefined ? { currentStep: params.currentStep } : {}), - }, - }); // Lightweight SSE-only publish — no AuditLog row, no ActivityEvent // row. The events table is the durable timeline; this is just for the @@ -413,7 +432,8 @@ export async function appendRunEvent( * terminal status (COMPLETED), is manually canceled (ABANDONED), or * the watchdog flags it (STALLED). * - * Idempotent: returns null if the run is already terminal. + * Idempotent: concurrent or repeated finish attempts return the terminal row + * without duplicating comments, terminal events, or audit activity. */ export async function finishRun( tx: Tx, @@ -428,15 +448,31 @@ export async function finishRun( actorAgentId?: string | null; }, ): Promise<AgentRun | null> { - const existing = await tx.agentRun.findUnique({ where: { id: params.runId } }); + const existing = await tx.agentRun.findFirst({ + where: { + id: params.runId, + workspaceId: params.workspaceId, + issueId: params.issueId, + agentId: params.agentId, + }, + }); if (!existing) return null; if (existing.status !== AgentRunStatus.ACTIVE && existing.status !== AgentRunStatus.WAITING) { return existing; } const now = new Date(); - const finished = await tx.agentRun.update({ - where: { id: params.runId }, + // Claim the live row before producing any terminal side effect. Competing + // completion paths can otherwise both observe ACTIVE and create duplicate + // final comments before either writes the terminal status. + const claimed = await tx.agentRun.updateMany({ + where: { + id: params.runId, + workspaceId: params.workspaceId, + issueId: params.issueId, + agentId: params.agentId, + status: { in: [AgentRunStatus.ACTIVE, AgentRunStatus.WAITING] }, + }, data: { status: AgentRunStatus[params.status], finishedAt: now, @@ -445,8 +481,54 @@ export async function finishRun( // not `finishedAt` (which is stamped for any terminal transition). ...(params.status === "COMPLETED" ? { completedAt: now } : {}), ...(params.summary !== undefined ? { summary: params.summary } : {}), + // Live-only presentation state must not survive a terminal transition. + // In particular this prevents COMPLETED · thinking · LIVE cards when a + // provider flushes a buffered trace event around completion. + currentStep: null, + awaitingApprovalAt: null, + pendingApproval: Prisma.DbNull, + controlState: "NONE", + controlRequestedAt: null, + controlRequestedById: null, }, }); + if (claimed.count !== 1) { + return tx.agentRun.findFirst({ + where: { + id: params.runId, + workspaceId: params.workspaceId, + issueId: params.issueId, + agentId: params.agentId, + }, + }); + } + + let finished: AgentRun; + if (params.status === "COMPLETED") { + const completionMeta = + existing.completionMeta && + typeof existing.completionMeta === "object" && + !Array.isArray(existing.completionMeta) + ? { ...(existing.completionMeta as Record<string, unknown>) } + : {}; + const knownCommentId = completionMeta.completionCommentId; + if (typeof knownCommentId !== "string" || !knownCommentId) { + const body = params.summary?.trim() || existing.summary?.trim() || "Run completed."; + const comment = await postAgentRunComment(tx, { + workspaceId: params.workspaceId, + issueId: params.issueId, + agentId: params.agentId, + body, + }); + completionMeta.completionCommentId = comment.id; + } + finished = await tx.agentRun.update({ + where: { id: params.runId }, + data: { completionMeta: completionMeta as Prisma.InputJsonValue }, + }); + } else { + finished = await tx.agentRun.findUniqueOrThrow({ where: { id: params.runId } }); + } await tx.agentRunEvent.create({ data: { @@ -487,13 +569,13 @@ export async function finishRun( * Post an issue comment authored AS the agent, on behalf of a dispatched run. * * The RUNS-engine dispatcher owns the agent loop over the connector socket. - * A provider that lacks Forge MCP — or that fails before calling - * `comments.create` itself — leaves its output orphaned in `AgentRun.summary`, - * visible only in Mission Control's run/recovery overlay. For terminal - * *failures* we surface that output as a real issue comment so the timeline + - * watcher notifications carry it, exactly like a normal agent reply. Authored - * with `authoringAgentId` and a null human author (Comment.authorId is - * nullable since migration 0047) so the UI renders it as the agent speaking. + * A provider that lacks Forge MCP — or that finishes without calling + * `comments.create` itself — can otherwise leave its output orphaned in + * `AgentRun.summary`, visible only in the run overlay. Successful completions + * use this helper as a shared final-comment guarantee; terminal failures use + * it when the dispatcher needs to surface output. Authored with + * `authoringAgentId` and a null human author so the UI renders the agent as + * the speaker. */ export async function postAgentRunComment( tx: Tx, @@ -503,7 +585,7 @@ export async function postAgentRunComment( agentId: string; body: string; }, -): Promise<void> { +): Promise<{ id: string }> { const comment = await tx.comment.create({ data: { workspaceId: params.workspaceId, @@ -530,6 +612,7 @@ export async function postAgentRunComment( preview: params.body.slice(0, 120), }, }); + return { id: comment.id }; } /** diff --git a/src/server/services/dispatch/run-dispatcher.ts b/src/server/services/dispatch/run-dispatcher.ts index 3675685c..e87db98a 100644 --- a/src/server/services/dispatch/run-dispatcher.ts +++ b/src/server/services/dispatch/run-dispatcher.ts @@ -845,10 +845,23 @@ async function applyPolledCostDelta( async function pollActiveRuns(): Promise<number> { const runs = await db.agentRun.findMany({ - // A WAITING approval remains provider-owned work. Keep polling it so a + // A WAITING *approval* remains provider-owned work. Keep polling it so a // gateway restart/TTL sweep cannot leave an immortal approval in Forge. + // + // A normal runs.setWaiting pause is different: the provider turn is + // expected to finish after the agent records that it needs an operator + // reply. Polling that parked turn would reinterpret the provider's clean + // completion as a missing runs.complete contract, flip the patient run to + // STALLED, and post a synthetic failure comment that can wake the agent + // again. Only approval-backed WAITING rows are still provider-owned. where: { - status: { in: [AgentRunStatus.ACTIVE, AgentRunStatus.WAITING] }, + OR: [ + { status: AgentRunStatus.ACTIVE }, + { + status: AgentRunStatus.WAITING, + awaitingApprovalAt: { not: null }, + }, + ], externalRunId: { not: null }, }, orderBy: { lastEventAt: "asc" }, @@ -1032,9 +1045,7 @@ async function pollActiveRuns(): Promise<number> { await tx.agentRun.update({ where: { id: run.id }, data: { - ...(run.status === AgentRunStatus.WAITING - ? { status: AgentRunStatus.ACTIVE } - : {}), + ...(run.status === AgentRunStatus.WAITING ? { status: AgentRunStatus.ACTIVE } : {}), lastEventAt: new Date(), ...(stepChanged ? { currentStep: step } : {}), ...(clearApproval diff --git a/src/server/services/engagement-mode.ts b/src/server/services/engagement-mode.ts index 6ab512b9..4c88b0e9 100644 --- a/src/server/services/engagement-mode.ts +++ b/src/server/services/engagement-mode.ts @@ -13,7 +13,7 @@ import { * unit-testable. */ -export const FORGE_RUN_CONTRACT_VERSION = "2026-07-13.1"; +export const FORGE_RUN_CONTRACT_VERSION = "2026-07-14.1"; export type EngagementSurface = "assignment" | "queue" | "mention" | "chat" | "plan" | "watcher"; @@ -182,7 +182,9 @@ const RUN_PROTOCOL_INSTRUCTIONS = "Include the mode-specific required fields when completing: EXECUTE supplies " + "artifact/checklist evidence when the issue contract requires it, RESEARCH " + "supplies findings plus confidence, REVIEW supplies a verdict, and DISCUSS " + - "supplies a reply only. " + + "supplies a reply only. Every successful run ends with a durable issue comment: " + + "either post the final BODY comment first and pass its id as completionCommentId, " + + "or let runs.complete create the final comment from summary. " + "Non-EXECUTE modes are read/report/review only; Forge rejects issue-state " + "mutations from those runs."; diff --git a/src/server/services/mcp.ts b/src/server/services/mcp.ts index b2bc5f67..28044fee 100644 --- a/src/server/services/mcp.ts +++ b/src/server/services/mcp.ts @@ -124,6 +124,7 @@ import { searchGitHubIssuesAndPulls } from "@/server/services/github/client"; import { listGitHubRepoMappings } from "@/server/services/github/linkability"; import { githubInstallationId } from "@/server/services/github/mapping-policy"; import { parseGitHubUrl } from "@/server/services/github/url"; +import { parseGitHubIssueOrPrRef } from "@/lib/github-ref"; import { EXTERNAL_LINK_KINDS, GITHUB_RESOURCE_TYPES, @@ -4704,11 +4705,13 @@ export const mcpTools = { "attachments.attachLink": { scopes: ["WRITE_ISSUES"] as const, description: [ - "Attach an external URL (Google Doc, GitHub PR, Linear ticket, web page, …)", + "Attach a generic external URL (Google Doc, Linear ticket, web page, …)", "as a first-class Attachment row on a Forge entity. No bytes are uploaded —", "the URL is the entirety of the payload.", "", - "Use this for any link to off-platform content. For uploading bytes (images,", + "GitHub issue/PR URLs targeting an issue are automatically routed through", + "Forge's native GitHub relation so state, checks, and sync remain available.", + "For other off-platform content use this link attachment path. For uploading bytes (images,", "PDFs, text/html, JSON, audio, video, …) use attachments.initUpload.", "", `Allowed target types: ${[...ALLOWED_TARGET_TYPES].sort().join(", ")}.`, @@ -4761,6 +4764,21 @@ export const mcpTools = { } else if (input.targetType === "chat-message") { await assertMcpChatMessageTarget(ctx, input.targetId); } + if (input.targetType === "issue" && parseGitHubIssueOrPrRef(input.url)) { + const actorId = await resolveActorId(ctx); + const native = await linkGitHubUrlToIssue({ + db, + workspaceId: ctx.workspaceId, + issueId: input.targetId, + url: input.url, + kind: "RELATES_TO", + actor: { + actorId, + actorAgentId: ctx.apiKey?.linkedAgentId ?? null, + }, + }); + return { routedTo: "github.link" as const, ...native }; + } // If the caller didn't supply a label, try to scrape <title> from // the target page so the chip is meaningful out of the box. Fail // soft — createLinkAttachment falls back to hostname when title is @@ -6636,6 +6654,13 @@ export const mcpTools = { input: z.object({ runId: z.string().min(1).max(40), summary: z.string().max(50_000).optional(), + completionCommentId: z + .string() + .cuid() + .optional() + .describe( + "Optional BODY comment already posted as this run's final response. When omitted, Forge creates an agent-authored final comment from summary so every completed run closes visibly in the issue thread.", + ), producedArtifactIds: z.array(z.string().cuid()).max(50).optional(), verificationResult: z .array( @@ -6666,6 +6691,7 @@ export const mcpTools = { input: { runId: string; summary?: string; + completionCommentId?: string; producedArtifactIds?: string[]; verificationResult?: Array<{ id?: string; @@ -6691,6 +6717,7 @@ export const mcpTools = { agentId: true, issueId: true, status: true, + startedAt: true, engagementMode: true, executionStepId: true, issue: { @@ -6711,6 +6738,26 @@ export const mcpTools = { } await assertKeyScope(scopeCtx(ctx), { entity: "issue", id: run.issueId }); + if (input.completionCommentId) { + const completionComment = await db.comment.findFirst({ + where: { + id: input.completionCommentId, + workspaceId: ctx.workspaceId, + issueId: run.issueId, + authoringAgentId: linkedAgentId, + kind: CommentKind.BODY, + deletedAt: null, + createdAt: { gte: run.startedAt }, + }, + select: { id: true }, + }); + if (!completionComment) { + throw new Error( + "completionCommentId must be a live BODY comment posted by this run's agent on the same issue after the run started.", + ); + } + } + let issueLinkedArtifactIds = new Set<string>(); // Validate every producedArtifactId belongs to this workspace. if (input.producedArtifactIds?.length) { @@ -6746,7 +6793,10 @@ export const mcpTools = { ); } - const completionMeta = runCompletionMeta(run.engagementMode, input); + const completionMeta = { + ...runCompletionMeta(run.engagementMode, input), + ...(input.completionCommentId ? { completionCommentId: input.completionCommentId } : {}), + }; const data: Prisma.AgentRunUpdateInput = {}; if (input.summary !== undefined) data.summary = input.summary; if (input.producedArtifactIds !== undefined) { diff --git a/src/server/services/notifications.ts b/src/server/services/notifications.ts index 2e2f65f2..5ad07c2d 100644 --- a/src/server/services/notifications.ts +++ b/src/server/services/notifications.ts @@ -1,8 +1,8 @@ import "server-only"; import { + EventKind, NotificationStatus, - type EventKind, type NotificationDelivery, type NotificationSeverity as PrismaNotificationSeverity, type NotificationState, @@ -32,6 +32,214 @@ export const ACTIVE_NOTIFICATION_STATUSES = [ NotificationStatus.ACKNOWLEDGED, ] as const; +function payloadString(payload: Prisma.JsonValue, key: string): string | null { + if (!payload || typeof payload !== "object" || Array.isArray(payload)) return null; + const value = (payload as Prisma.JsonObject)[key]; + return typeof value === "string" ? value : null; +} + +/** + * Keep the mutable alert lifecycle aligned with the subject that produced it. + * NotificationState is the shared alert/notification record; surfaces should + * not each invent their own stale-open interpretation. This reconciler closes + * alerts when durable product state proves the condition recovered. + */ +export async function reconcileRecoveredNotifications( + db: DbClient, + params: { workspaceId: string; userId: string; limit?: number }, +): Promise<number> { + const rows = await db.notificationState.findMany({ + where: { + workspaceId: params.workspaceId, + userId: params.userId, + status: { in: [...ACTIVE_NOTIFICATION_STATUSES] }, + }, + orderBy: { createdAt: "desc" }, + take: Math.min(Math.max(params.limit ?? 100, 1), 250), + select: { + id: true, + event: { + select: { + kind: true, + subjectType: true, + subjectId: true, + payload: true, + createdAt: true, + }, + }, + }, + }); + + const issueAlerts = rows.filter( + (row) => + row.event.kind === EventKind.ISSUE_STALLED || row.event.kind === EventKind.ISSUE_SLA_BREACH, + ); + const issueIds = [ + ...new Set( + issueAlerts.map((row) => payloadString(row.event.payload, "issueId") ?? row.event.subjectId), + ), + ]; + const runIds = [ + ...new Set( + rows.flatMap((row) => { + if (row.event.kind === EventKind.AGENT_NOACK) { + const runId = payloadString(row.event.payload, "runId"); + return runId ? [runId] : []; + } + if (row.event.kind === EventKind.AGENT_RUN_STALLED) { + return [payloadString(row.event.payload, "runId") ?? row.event.subjectId]; + } + return []; + }), + ), + ]; + const planIds = [ + ...new Set( + rows + .filter( + (row) => + row.event.kind === EventKind.PLAN_BUDGET_EXCEEDED || + row.event.kind === EventKind.PLAN_STALLED, + ) + .map((row) => row.event.subjectId), + ), + ]; + const stepIds = [ + ...new Set( + rows + .filter((row) => row.event.kind === EventKind.EXECUTION_STEP_JUDGED) + .map((row) => row.event.subjectId), + ), + ]; + const earliestIssueAlert = issueAlerts.reduce<Date | null>( + (earliest, row) => + !earliest || row.event.createdAt < earliest ? row.event.createdAt : earliest, + null, + ); + + // Batch recovery evidence by entity class. This runs whenever the bell + // count refreshes, so an N+1 query per active alert would become costly for + // noisy workspaces. + const [issues, issueMovements, runs, plans, steps] = await Promise.all([ + db.issue.findMany({ + where: { id: { in: issueIds }, workspaceId: params.workspaceId }, + select: { id: true, deletedAt: true, status: { select: { category: true } } }, + }), + db.activityEvent.findMany({ + where: { + workspaceId: params.workspaceId, + subjectType: "issue", + subjectId: { in: issueIds }, + ...(earliestIssueAlert ? { createdAt: { gt: earliestIssueAlert } } : {}), + OR: [ + { + kind: { + in: [ + EventKind.ISSUE_STATUS_CHANGED, + EventKind.ISSUE_ASSIGNED, + EventKind.AGENT_ASSIGNED, + ], + }, + }, + { kind: EventKind.COMMENT_CREATED, actorAgentId: { not: null } }, + ], + }, + select: { subjectId: true, kind: true, actorAgentId: true, createdAt: true }, + }), + db.agentRun.findMany({ + where: { id: { in: runIds }, workspaceId: params.workspaceId }, + select: { + id: true, + status: true, + acknowledgedAt: true, + outputStartedAt: true, + clearedAt: true, + supersededByRunId: true, + }, + }), + db.executionPlan.findMany({ + where: { id: { in: planIds }, workspaceId: params.workspaceId }, + select: { id: true, status: true, updatedAt: true, archivedAt: true }, + }), + db.executionStep.findMany({ + where: { id: { in: stepIds }, workspaceId: params.workspaceId }, + select: { id: true, status: true }, + }), + ]); + const issueById = new Map(issues.map((issue) => [issue.id, issue])); + const runById = new Map(runs.map((run) => [run.id, run])); + const planById = new Map(plans.map((plan) => [plan.id, plan])); + const stepById = new Map(steps.map((step) => [step.id, step])); + + const recovered = rows.flatMap((row) => { + const event = row.event; + if (event.kind === EventKind.ISSUE_STALLED || event.kind === EventKind.ISSUE_SLA_BREACH) { + const issueId = payloadString(event.payload, "issueId") ?? event.subjectId; + const issue = issueById.get(issueId); + if (!issue || issue.deletedAt) return [row.id]; + if (["IN_REVIEW", "DONE", "CANCELED"].includes(issue.status.category)) return [row.id]; + const assignedAgentId = payloadString(event.payload, "assignedAgentId"); + const moved = issueMovements.some( + (movement) => + movement.subjectId === issueId && + movement.createdAt > event.createdAt && + (movement.kind !== EventKind.COMMENT_CREATED || + (movement.actorAgentId && + (!assignedAgentId || movement.actorAgentId === assignedAgentId))), + ); + return moved ? [row.id] : []; + } + + if (event.kind === EventKind.AGENT_NOACK) { + const runId = payloadString(event.payload, "runId"); + if (!runId) return []; + const run = runById.get(runId); + return !run || run.acknowledgedAt || run.outputStartedAt || run.status !== "ACTIVE" + ? [row.id] + : []; + } + + if (event.kind === EventKind.AGENT_RUN_STALLED) { + const runId = payloadString(event.payload, "runId") ?? event.subjectId; + const run = runById.get(runId); + return !run || run.clearedAt || run.supersededByRunId || run.status === "COMPLETED" + ? [row.id] + : []; + } + + if (event.kind === EventKind.PLAN_BUDGET_EXCEEDED || event.kind === EventKind.PLAN_STALLED) { + const plan = planById.get(event.subjectId); + if (!plan || plan.archivedAt) return [row.id]; + if (event.kind === EventKind.PLAN_BUDGET_EXCEEDED) { + return plan.status !== "BLOCKED" ? [row.id] : []; + } + return plan.updatedAt > event.createdAt && !["RUNNING", "BLOCKED"].includes(plan.status) + ? [row.id] + : []; + } + + if (event.kind === EventKind.EXECUTION_STEP_JUDGED) { + const step = stepById.get(event.subjectId); + return !step || step.status !== "BLOCKED" ? [row.id] : []; + } + + return []; + }); + + if (recovered.length === 0) return 0; + const now = new Date(); + const result = await db.notificationState.updateMany({ + where: { + id: { in: recovered }, + workspaceId: params.workspaceId, + userId: params.userId, + status: { in: [...ACTIVE_NOTIFICATION_STATUSES] }, + }, + data: { status: NotificationStatus.RESOLVED, readAt: now, resolvedAt: now }, + }); + return result.count; +} + const notificationEventSelect = { id: true, workspaceId: true, diff --git a/src/server/services/run-completion-contract.ts b/src/server/services/run-completion-contract.ts index 34b18aa9..560865c9 100644 --- a/src/server/services/run-completion-contract.ts +++ b/src/server/services/run-completion-contract.ts @@ -18,6 +18,7 @@ export type RunVerificationResultItem = { export type RunCompletionInput = { summary?: string; + completionCommentId?: string; producedArtifactIds?: string[]; verificationResult?: RunVerificationResultItem[]; followUps?: Array<{ title: string; body?: string; kind?: string }>; @@ -134,9 +135,7 @@ export function validateRunCompletion(input: { const checklist = checklistItems(issue.verificationChecklist); if (checklist.length > 0) { const done = new Set( - (completion.verificationResult ?? []) - .filter((item) => item.done) - .map(verificationKey), + (completion.verificationResult ?? []).filter((item) => item.done).map(verificationKey), ); for (const item of checklist) { if (!done.has(verificationKey(item))) { @@ -147,4 +146,3 @@ export function validateRunCompletion(input: { return errors; } - diff --git a/tests/e2e/issue-flow.spec.ts b/tests/e2e/issue-flow.spec.ts index 10d92ee6..a8b56cdb 100644 --- a/tests/e2e/issue-flow.spec.ts +++ b/tests/e2e/issue-flow.spec.ts @@ -28,6 +28,33 @@ test.describe("Issue flow", () => { await expect(page).toHaveURL(/\/w\/forge\/issues\//, { timeout: 20_000 }); await expect(page.getByText(title).first()).toBeVisible(); + // The bare issue URL opens recent Activity, and the rail itself does not + // become a second vertical scroll container. + const activityTab = page.getByRole("tab", { name: /Activity/ }); + const attachmentsTab = page.getByRole("tab", { name: "Attachments" }); + const tabPanel = page.getByRole("tabpanel"); + await expect(activityTab).toHaveAttribute("aria-selected", "true"); + await expect(activityTab).toHaveAttribute("tabindex", "0"); + await expect(tabPanel).toHaveAttribute("aria-labelledby", /activity-tab$/); + const railSurface = page.getByLabel("Issue detail rail").locator(":scope > div"); + await expect(railSurface).toBeVisible(); + const railMetrics = await railSurface.evaluate((el) => ({ + clientHeight: (el as HTMLElement).clientHeight, + scrollHeight: (el as HTMLElement).scrollHeight, + })); + expect(railMetrics.scrollHeight - railMetrics.clientHeight).toBeLessThanOrEqual(1); + + // Standard tab arrow navigation changes both focus and the deep-link URL. + await activityTab.focus(); + await activityTab.press("ArrowRight"); + await expect(attachmentsTab).toHaveAttribute("aria-selected", "true"); + await expect(attachmentsTab).toBeFocused(); + await expect(page).toHaveURL(/\?tab=attachments$/); + + // Returning to the bare path restores Activity as the default. + await page.goto(new URL(page.url()).pathname); + await expect(activityTab).toHaveAttribute("aria-selected", "true"); + // Move status on the detail page and confirm it sticks. const status = page.getByRole("combobox", { name: "Status" }); await status.click(); diff --git a/tests/e2e/notification-drawer.spec.ts b/tests/e2e/notification-drawer.spec.ts new file mode 100644 index 00000000..a711fbe9 --- /dev/null +++ b/tests/e2e/notification-drawer.spec.ts @@ -0,0 +1,22 @@ +import { expect, test } from "@playwright/test"; + +test("notification drawer traps focus and restores it to the bell", async ({ page }) => { + await page.goto("/w/forge/dashboard"); + + const bell = page.getByRole("button", { name: /(?:unread|no unread) item/i }); + await expect(bell).toBeVisible(); + await bell.click(); + + const dialog = page.getByRole("dialog", { name: "Activity" }); + await expect(dialog).toBeVisible(); + await expect(dialog).toBeFocused(); + + await page.keyboard.press("Shift+Tab"); + await expect + .poll(() => page.evaluate(() => document.activeElement?.closest('[role="dialog"]') != null)) + .toBe(true); + + await page.keyboard.press("Escape"); + await expect(dialog).toBeHidden(); + await expect(bell).toBeFocused(); +});