mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-03 17:51:58 +01:00
fix: preserve recovery and optimistic task status transitions
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
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();
|
||||
|
||||
@@ -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`;
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
@@ -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"
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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"],
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in new issue
Block a user