fix: preserve recovery and optimistic task status transitions

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-11-02 11:00:14 -06:00
1 parent 7ddd8205ad
commit fa9afcc466
13 files changed
+62 -10

No files matched your search

@@ -2,11 +2,12 @@ import { randomUUID } from "node:crypto";
import { eq, sql } from "drizzle-orm";
import { expect, it } from "vitest";
import request from "supertest";
import { agents, heartbeatRuns, issueRecoveryActions, issues } from "@paperclipai/db";
import { agents, heartbeatRuns, issueRecoveryActions, issues, statusCards } from "@paperclipai/db";
import { companySearchQuerySchema, createIssueSchema, PROVIDER_QUOTA_MONITOR_SERVICE_NAME } from "@paperclipai/shared";
import { issueDisplayStatusSql } from "../services/issue-display-status.js";
import { issueService } from "../services/issues.js";
import { companySearchService } from "../services/company-search.js";
import { statusCardService } from "../services/status-cards.js";
import { issueRoutes } from "../routes/issues.js";
import {
describeEmbeddedPostgres, routeApp,
@@ -51,6 +52,8 @@ describeEmbeddedPostgres("Monitoring task presentation", () => {
expect(result.results[0]?.issue).toMatchObject({ id: f.issueId, status: "in_progress", displayStatus: "monitoring" });
expect(result.filterOptionCounts.displayStatus?.monitoring).toBe(1);
expect(result.filterOptionCounts.status.in_progress).toBe(1);
const [card] = await ctx.db.insert(statusCards).values({ companyId: f.companyId, interestPrompt: "Watch deployment", mentionedIssueIds: [f.issueId], refreshPolicy: { mode: "manual" } }).returning();
expect(await statusCardService(ctx.db).listMentionedIssues(card!)).toEqual([expect.objectContaining({ id: f.issueId, status: "in_progress", displayStatus: "monitoring" })]);
// Existing agent/API lifecycle filters keep their original meaning.
expect(await svc.count(f.companyId, { status: "in_progress" })).toBe(1);
expect(createIssueSchema.safeParse({ title: "Manual monitoring", status: "monitoring" }).success).toBe(false);
@@ -103,6 +106,8 @@ describeEmbeddedPostgres("Monitoring task presentation", () => {
const f = await fixture();
await ctx.db.insert(issueRecoveryActions).values({ companyId: f.companyId, sourceIssueId: f.issueId, kind: "source", ownerType: "board", cause: "process_lost", fingerprint: randomUUID(), nextAction: "Inspect stopped run" });
expect((await displayStatus(f.issueId)).displayStatus).toBe("in_progress");
await ctx.db.execute(sql`UPDATE issue_recovery_actions SET status = 'escalated' WHERE source_issue_id = ${f.issueId}`);
expect((await displayStatus(f.issueId)).displayStatus).toBe("in_progress");
await ctx.db.execute(sql`UPDATE issue_recovery_actions SET status = 'resolved' WHERE source_issue_id = ${f.issueId}`);
expect((await displayStatus(f.issueId)).displayStatus).toBe("monitoring");
await ctx.db.update(issues).set({ executionState: { currentParticipant: { type: "user", userId: "reviewer" } } }).where(eq(issues.id, f.issueId));
@@ -50,7 +50,7 @@ describeEmbeddedPostgres("bounded issue summaries", () => {
expect(res.headers["cache-control"]).toBe("private, no-store");
expect(res.body.map((row: { id: string }) => row.id)).toEqual([f.rows[1]!.id, f.rows[0]!.id]);
expect(Object.keys(res.body[0]).sort()).toEqual([
"companyId", "externalConversationState", "id", "identifier", "status", "title", "updatedAt",
"companyId", "displayStatus", "externalConversationState", "id", "identifier", "status", "title", "updatedAt",
]);
expect(res.body[0].externalConversationState).toBeNull();
expect(forbiddenRead).not.toHaveBeenCalled();
+1 -1
View File
@@ -31,7 +31,7 @@ export function issueDisplayStatusSql(): SQL<IssueDisplayStatus> {
SELECT 1 FROM issue_recovery_actions display_recovery
WHERE display_recovery.company_id = ${companyId}
AND display_recovery.source_issue_id = ${issueId}
AND display_recovery.status = 'active'
AND display_recovery.status IN ('active', 'escalated')
)
THEN 'monitoring' ELSE ${issues.status} END`;
}
+2
View File
@@ -1,4 +1,5 @@
import { notifyStatusCardEvaluation } from "./status-card-notifications.js";
import { issueDisplayStatusSql } from "./issue-display-status.js";
import { createHash } from "node:crypto";
import { and, desc, eq, gt, gte, inArray, isNotNull, isNull, lte, ne, or, sql, type SQL } from "drizzle-orm";
import {
@@ -552,6 +553,7 @@ export function statusCardService(
identifier: issues.identifier,
title: issues.title,
status: issues.status,
displayStatus: issueDisplayStatusSql(),
priority: issues.priority,
assigneeAgentId: issues.assigneeAgentId,
assigneeUserId: issues.assigneeUserId,
+14
View File
@@ -0,0 +1,14 @@
import { expect, it, vi } from "vitest";
import { parseSearchQuery } from "../lib/search-query-parser";
import { searchApi } from "./search";
import { api } from "./client";
vi.mock("./client", () => ({ api: { get: vi.fn() } }));
it("keeps scheduled monitor waits in is:open search requests", async () => {
const parsed = parseSearchQuery("is:open deployment", {});
await searchApi.search("company-1", { q: parsed.query, ...parsed.filters });
const url = new URL(vi.mocked(api.get).mock.calls[0]![0], "http://localhost");
expect(url.searchParams.getAll("displayStatus")).toEqual(["backlog", "todo", "in_progress", "monitoring", "in_review", "blocked"]);
expect(url.searchParams.get("q")).toBe("deployment");
});
+2 -2
View File
@@ -1714,7 +1714,7 @@ function StreamlinedIssuesList({
const defaults: Record<string, unknown> = { ...(baseCreateIssueDefaults ?? {}) };
if (projectId && defaults.projectId === undefined) defaults.projectId = projectId;
if (groupKey) {
if (viewState.groupBy === "status") defaults.status = groupKey === "monitoring" ? "in_progress" : groupKey;
if (viewState.groupBy === "status") defaults.status = groupKey === "monitoring" ? "todo" : groupKey;
else if (viewState.groupBy === "priority") defaults.priority = groupKey;
else if (viewState.groupBy === "assignee" && groupKey !== "__unassigned") {
if (groupKey.startsWith("__user:")) defaults.assigneeUserId = groupKey.slice("__user:".length);
@@ -2149,7 +2149,7 @@ function StreamlinedIssuesList({
: [...viewState.collapsedGroups, group.key],
});
}}
trailing={(
trailing={group.key === "monitoring" ? undefined : (
<Button
variant="ghost"
size="icon-xs"
+10
View File
@@ -5,6 +5,7 @@ import { flushSync } from "react-dom";
import type { Issue, IssueStatus } from "@paperclipai/shared";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { getKanbanColumnTone, KanbanBoard, resolveKanbanTargetStatus } from "./KanbanBoard";
import { applyOptimisticIssueFieldUpdate } from "../lib/optimistic-issue-comments";
vi.mock("@/lib/router", () => ({
Link: ({
@@ -232,6 +233,15 @@ describe("KanbanBoard", () => {
expect(resolveKanbanTargetStatus("issue-blocked-2", issues)).toBe("blocked");
expect(resolveKanbanTargetStatus("missing", issues)).toBeNull();
});
it("moves a cached card into its new column before the server responds", () => {
const issue = { ...createIssue(1, "todo"), displayStatus: "todo" as const };
const { container, render } = renderBoard({ issues: [issue] });
const column = () => container.querySelector('a[href="/issues/PAP-1"]')?.closest('[data-issue-row-details]')?.parentElement?.parentElement;
expect(column()?.textContent).toContain("Todo");
render({ issues: [applyOptimisticIssueFieldUpdate(issue, { status: "done" })!] });
expect(column()?.textContent).toContain("Done");
});
});
+2 -2
View File
@@ -1602,7 +1602,7 @@ export function IssuesList({
const defaults: Record<string, unknown> = { ...(baseCreateIssueDefaults ?? {}) };
if (projectId && defaults.projectId === undefined) defaults.projectId = projectId;
if (groupKey) {
if (viewState.groupBy === "status") defaults.status = groupKey === "monitoring" ? "in_progress" : groupKey;
if (viewState.groupBy === "status") defaults.status = groupKey === "monitoring" ? "todo" : groupKey;
else if (viewState.groupBy === "priority") defaults.priority = groupKey;
else if (viewState.groupBy === "assignee" && groupKey !== "__unassigned") {
if (groupKey.startsWith("__user:")) defaults.assigneeUserId = groupKey.slice("__user:".length);
@@ -2006,7 +2006,7 @@ export function IssuesList({
: [...viewState.collapsedGroups, group.key],
});
}}
trailing={(
trailing={group.key === "monitoring" ? undefined : (
<Button
variant="ghost"
size="icon-xs"
@@ -20,6 +20,12 @@ import {
} from "./optimistic-issue-comments";
describe("optimistic issue comments", () => {
it("immediately replaces a derived status when a task moves or reopens", () => {
const monitoring = { status: "in_progress", displayStatus: "monitoring" } as Issue;
expect(applyOptimisticIssueFieldUpdate(monitoring, { status: "done" })).toMatchObject({ status: "done", displayStatus: "done" });
expect(applyOptimisticIssueFieldUpdate(monitoring, { priority: "high" })?.displayStatus).toBe("monitoring");
expect(applyOptimisticIssueCommentUpdate({ ...monitoring, status: "done", displayStatus: "done" }, { reopen: true })).toMatchObject({ status: "todo", displayStatus: "todo" });
});
it("keeps a workspace-held task blocked when a follow-up is sent", () => {
const issue = { status: "blocked", executionBlocker: { workspaceRepairRequired: true } } as Issue;
expect(applyOptimisticIssueCommentUpdate(issue, { reopen: true })?.status).toBe("blocked");
+2
View File
@@ -275,6 +275,7 @@ export function applyOptimisticIssueCommentUpdate(
if (params.reopen === true && !issue.executionBlocker && (issue.status === "done" || issue.status === "cancelled" || issue.status === "blocked")) {
nextIssue.status = "todo";
nextIssue.displayStatus = "todo";
}
if (params.reassignment) {
@@ -303,6 +304,7 @@ export function applyOptimisticIssueFieldUpdate(
};
assign("status");
if (hasOwn("status")) nextIssue.displayStatus = nextIssue.status;
assign("priority");
assign("assigneeAgentId");
assign("assigneeUserId");
+1 -1
View File
@@ -70,7 +70,7 @@ describe("parseSearchQuery", () => {
it("parses is:open quick filters", () => {
expect(parseSearchQuery("is:open", context).filters).toEqual({
status: ["backlog", "todo", "in_progress", "in_review", "blocked"],
status: ["backlog", "todo", "in_progress", "monitoring", "in_review", "blocked"],
});
});
+1 -1
View File
@@ -20,7 +20,7 @@ const SEARCH_FILTER_PARAM_KEYS = [
"updatedAfter",
] as const;
const OPEN_STATUSES: IssueDisplayStatus[] = ["backlog", "todo", "in_progress", "in_review", "blocked"];
const OPEN_STATUSES: IssueDisplayStatus[] = ["backlog", "todo", "in_progress", "monitoring", "in_review", "blocked"];
const CLOSED_STATUSES: IssueDisplayStatus[] = ["done", "cancelled"];
export type SearchOperatorKey = "status" | "assignee" | "project" | "label" | "priority" | "updated" | "is";
@@ -7,6 +7,9 @@ import { IssueDetail } from "@/pages/IssueDetail";
import { Issues } from "@/pages/Issues";
import { Search } from "@/pages/Search";
import { Dashboard } from "@/pages/Dashboard";
import { Inbox } from "@/pages/Inbox";
import { UserProfile } from "@/pages/UserProfile";
import { StatusCardDetailDrawer } from "@/pages/StatusCards/StatusCardDetailDrawer";
import { PluginLauncherProvider } from "@/plugins/launchers";
import { StatusIcon } from "@/components/StatusIcon";
import { IssueStatusBadge } from "@/components/StatusBadge";
@@ -14,6 +17,7 @@ import { IssueReferencePill } from "@/components/IssueReferencePill";
import { IssueSiblingNavigation } from "@/components/IssueSiblingNavigation";
import { AgentTaskCard } from "@/components/chat/AgentWorkPanels";
import { installFixtureApi } from "../../support/capability-gaps/api";
import { makeStatusCard } from "../../support/capability-gaps/status-cards";
import { storybookIssues, storybookAgents } from "../../fixtures/paperclipData";
import { instanceExperimentalSettingsSchema, type Issue } from "@paperclipai/shared";
@@ -35,7 +39,7 @@ const rows = [monitored, working, ...storybookIssues.slice(2)];
function fixtures() {
return installFixtureApi([
{ path: "/api/workspace-preferences/me", read: () => ({ userId: "user-storybook", coordinatorWorkspaceEnabled: false, updatedAt: null }) },
{ path: "/api/instance/settings/experimental", read: () => instanceExperimentalSettingsSchema.parse({ enableStreamlinedUi: true }) },
{ path: "/api/instance/settings/experimental", read: () => instanceExperimentalSettingsSchema.parse({ enableStreamlinedUi: true, enableCombinedInboxTasks: true }) },
{ path: "/api/cli-auth/me", read: () => ({ source: "local_implicit", isInstanceAdmin: true, companyIds: [companyId], memberships: [] }) },
{ path: /^\/api\/issues\/[^/]+\/(feedback-votes|browsers|live-runs|attachments|accepted-plan-decompositions)$/, read: () => [] },
@@ -43,6 +47,9 @@ function fixtures() {
{ path: /^\/api\/companies\/[^/]+\/email\/tasks\/[^/]+$/, read: () => null },
{ path: `/api/companies/${companyId}/live-runs`, read: () => [] },
{ path: `/api/companies/${companyId}/users/riley/profile`, read: () => ({ user: { id: "user-storybook", slug: "riley", name: "Riley Board", email: "riley@example.com", image: null, membershipRole: "owner", membershipStatus: "active", joinedAt: monitored.createdAt }, stats: [], daily: [], recentIssues: [monitored, working], recentActivity: [], topAgents: [], topProviders: [] }) },
{ path: "/api/status-cards/card-gap/updates", read: () => [] },
{ path: "/api/status-cards/card-gap/dry-run", read: () => ({ cardId: "card-gap", queryVersion: 1, queries: [], mentionedIssues: [monitored] }) },
{ path: `/api/companies/${companyId}/issues`, read: ({ url }) => rows.filter(row => !url.searchParams.has("displayStatus") || url.searchParams.get("displayStatus")!.split(",").includes(row.displayStatus ?? row.status)) },
{ path: `/api/companies/${companyId}/issues/summaries`, read: ({ url }) => rows.filter(row => url.searchParams.get("ids")?.includes(row.id)).map(row => ({ ...row, externalConversationState: null })) },
{ path: `/api/companies/${companyId}/search`, read: () => ({ query: "deployment", normalizedQuery: "deployment", scope: "issues", sort: "relevance", limit: 20, offset: 0, hasMore: false, zeroResults: null,
@@ -68,6 +75,8 @@ function MonitoringPage({ path }: { path: string }) {
<Route path="issues/:issueId" element={<IssueDetail />} />
<Route path="search" element={<Search />} />
<Route path="dashboard" element={<Dashboard />} />
<Route path="inbox" element={<Inbox tab="recent" />} />
<Route path="users/:userSlug" element={<UserProfile />} />
</Route></Routes></PluginLauncherProvider>;
}
@@ -84,7 +93,11 @@ export const TaskDetail: Story = { render: () => <MonitoringPage path="/PAP/issu
export const TaskList: Story = { render: () => <MonitoringPage path="/PAP/issues?view=all" /> };
export const SearchResults: Story = { render: () => <MonitoringPage path="/PAP/search?q=deployment" /> };
export const CompanyDashboard: Story = { render: () => <MonitoringPage path="/PAP/dashboard" /> };
export const InboxRecent: Story = { render: () => <MonitoringPage path="/PAP/inbox" /> };
export const ProfileRecentTasks: Story = { render: () => <MonitoringPage path="/PAP/users/riley" /> };
export const StatusCardWatchedTasks: Story = { render: () => <StatusCardDetailDrawer card={makeStatusCard({ mentionedIssueIds: [monitored.id] })} companyId={companyId} open onOpenChange={() => undefined} initialTab="watched" /> };
export const TaskDetailMobile: Story = { ...TaskDetail, globals: { viewport: { value: "mobile", isRotated: false } }, parameters: { waitForViewport: true } };
export const TaskDetailLight: Story = { ...TaskDetail, globals: { theme: "light" } };
export const TaskDetailDark: Story = { ...TaskDetail, globals: { theme: "dark" } };
export const SharedIndicators: Story = {
render: () => <div className="mx-auto max-w-3xl space-y-6 p-8">