mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-23 00:53:38 +02:00
Merge origin/master into master
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
commit
0c170712e1
4 files changed
+265
-67
No files matched your search
@@ -511,7 +511,9 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
|
||||
runId,
|
||||
claimedAt: now,
|
||||
finishedAt: new Date("2026-03-19T00:05:00.000Z"),
|
||||
error: input.runStatus === "succeeded" ? null : (input.runError ?? "run failed before issue advanced"),
|
||||
error: input.runStatus === "succeeded"
|
||||
? null
|
||||
: ("runError" in input ? input.runError : "run failed before issue advanced"),
|
||||
});
|
||||
|
||||
await db.insert(heartbeatRuns).values({
|
||||
@@ -533,8 +535,12 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
|
||||
startedAt: now,
|
||||
finishedAt: new Date("2026-03-19T00:05:00.000Z"),
|
||||
updatedAt: new Date("2026-03-19T00:05:00.000Z"),
|
||||
errorCode: input.runStatus === "succeeded" ? null : (input.runErrorCode ?? "process_lost"),
|
||||
error: input.runStatus === "succeeded" ? null : (input.runError ?? "run failed before issue advanced"),
|
||||
errorCode: input.runStatus === "succeeded"
|
||||
? null
|
||||
: ("runErrorCode" in input ? input.runErrorCode : "process_lost"),
|
||||
error: input.runStatus === "succeeded"
|
||||
? null
|
||||
: ("runError" in input ? input.runError : "run failed before issue advanced"),
|
||||
});
|
||||
|
||||
await db.insert(issues).values([
|
||||
@@ -1544,6 +1550,61 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
|
||||
expect(comments[0]?.body).toContain(`Recovery issue: [${recovery.identifier}]`);
|
||||
});
|
||||
|
||||
it("redacts error-code-only stranded recovery failures in issue copy", async () => {
|
||||
const { companyId, agentId, issueId, runId } = await seedStrandedIssueFixture({
|
||||
status: "in_progress",
|
||||
runStatus: "failed",
|
||||
retryReason: "issue_continuation_needed",
|
||||
runErrorCode: "adapter_exit_code",
|
||||
runError: null,
|
||||
});
|
||||
const heartbeat = heartbeatService(db);
|
||||
|
||||
const result = await heartbeat.reconcileStrandedAssignedIssues();
|
||||
expect(result.escalated).toBe(1);
|
||||
|
||||
const recovery = await expectStrandedRecoveryArtifacts({
|
||||
companyId,
|
||||
agentId,
|
||||
issueId,
|
||||
runId,
|
||||
previousStatus: "in_progress",
|
||||
retryReason: "issue_continuation_needed",
|
||||
});
|
||||
expect(recovery.description).toContain("Latest retry failure details were withheld from the issue thread");
|
||||
expect(recovery.description).not.toContain("- Failure: none recorded");
|
||||
|
||||
const comments = await db.select().from(issueComments).where(eq(issueComments.issueId, issueId));
|
||||
expect(comments).toHaveLength(1);
|
||||
expect(comments[0]?.body).toContain("Latest retry failure details were withheld from the issue thread");
|
||||
expect(comments[0]?.body).not.toContain("- Failure: none recorded");
|
||||
});
|
||||
|
||||
it("reuses the raced stranded recovery issue when duplicate active recovery creation conflicts", async () => {
|
||||
const { companyId, issueId } = await seedStrandedIssueFixture({
|
||||
status: "in_progress",
|
||||
runStatus: "failed",
|
||||
retryReason: "issue_continuation_needed",
|
||||
});
|
||||
const heartbeat = heartbeatService(db);
|
||||
|
||||
const results = await Promise.allSettled(
|
||||
Array.from({ length: 8 }, () => heartbeat.reconcileStrandedAssignedIssues()),
|
||||
);
|
||||
expect(results.every((result) => result.status === "fulfilled")).toBe(true);
|
||||
|
||||
const recoveries = await db
|
||||
.select()
|
||||
.from(issues)
|
||||
.where(and(
|
||||
eq(issues.companyId, companyId),
|
||||
eq(issues.originKind, "stranded_issue_recovery"),
|
||||
eq(issues.originId, issueId),
|
||||
));
|
||||
expect(recoveries).toHaveLength(1);
|
||||
await expect(sourceBlockerIssueIds(companyId, issueId)).resolves.toEqual([recoveries[0]?.id]);
|
||||
});
|
||||
|
||||
it("blocks stranded recovery issues in place instead of creating nested recovery issues", async () => {
|
||||
const sourceIssueId = randomUUID();
|
||||
const { companyId, agentId, issueId, runId } = await seedStrandedIssueFixture({
|
||||
|
||||
@@ -102,7 +102,7 @@ function readNonEmptyString(value: unknown): string | null {
|
||||
function summarizeRunFailureForIssueComment(run: LatestIssueRun) {
|
||||
if (!run) return null;
|
||||
|
||||
if (readNonEmptyString(run.error)) {
|
||||
if (readNonEmptyString(run.error) || readNonEmptyString(run.errorCode)) {
|
||||
return " Latest retry failure details were withheld from the issue thread; inspect the linked run for evidence.";
|
||||
}
|
||||
return null;
|
||||
@@ -814,6 +814,16 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup })
|
||||
);
|
||||
}
|
||||
|
||||
function isUniqueStrandedIssueRecoveryConflict(error: unknown) {
|
||||
if (!error || typeof error !== "object") return false;
|
||||
const maybe = error as { code?: string; constraint?: string; message?: string };
|
||||
return maybe.code === "23505" &&
|
||||
(
|
||||
maybe.constraint === "issues_active_stranded_issue_recovery_uq" ||
|
||||
typeof maybe.message === "string" && maybe.message.includes("issues_active_stranded_issue_recovery_uq")
|
||||
);
|
||||
}
|
||||
|
||||
async function ensureSourceIssueBlockedByStaleEvaluation(input: {
|
||||
sourceIssue: typeof issues.$inferSelect | null;
|
||||
evaluationIssue: { id: string; identifier: string | null };
|
||||
@@ -1267,32 +1277,40 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup })
|
||||
if (!ownerAgentId) return null;
|
||||
|
||||
const prefix = await getCompanyIssuePrefix(input.issue.companyId);
|
||||
const recovery = await issuesSvc.create(input.issue.companyId, {
|
||||
title: `Recover stalled issue ${input.issue.identifier ?? input.issue.title}`,
|
||||
description: buildStrandedIssueRecoveryDescription({
|
||||
issue: input.issue,
|
||||
latestRun: input.latestRun,
|
||||
previousStatus: input.previousStatus,
|
||||
prefix,
|
||||
}),
|
||||
status: "todo",
|
||||
priority: input.issue.priority,
|
||||
parentId: input.issue.id,
|
||||
projectId: input.issue.projectId,
|
||||
goalId: input.issue.goalId,
|
||||
assigneeAgentId: ownerAgentId,
|
||||
originKind: STRANDED_ISSUE_RECOVERY_ORIGIN_KIND,
|
||||
originId: input.issue.id,
|
||||
originRunId: input.latestRun?.id ?? null,
|
||||
originFingerprint: [
|
||||
STRANDED_ISSUE_RECOVERY_ORIGIN_KIND,
|
||||
input.issue.companyId,
|
||||
input.issue.id,
|
||||
input.latestRun?.id ?? "no-run",
|
||||
].join(":"),
|
||||
billingCode: input.issue.billingCode,
|
||||
inheritExecutionWorkspaceFromIssueId: input.issue.id,
|
||||
});
|
||||
let recovery: Awaited<ReturnType<typeof issuesSvc.create>>;
|
||||
try {
|
||||
recovery = await issuesSvc.create(input.issue.companyId, {
|
||||
title: `Recover stalled issue ${input.issue.identifier ?? input.issue.title}`,
|
||||
description: buildStrandedIssueRecoveryDescription({
|
||||
issue: input.issue,
|
||||
latestRun: input.latestRun,
|
||||
previousStatus: input.previousStatus,
|
||||
prefix,
|
||||
}),
|
||||
status: "todo",
|
||||
priority: input.issue.priority,
|
||||
parentId: input.issue.id,
|
||||
projectId: input.issue.projectId,
|
||||
goalId: input.issue.goalId,
|
||||
assigneeAgentId: ownerAgentId,
|
||||
originKind: STRANDED_ISSUE_RECOVERY_ORIGIN_KIND,
|
||||
originId: input.issue.id,
|
||||
originRunId: input.latestRun?.id ?? null,
|
||||
originFingerprint: [
|
||||
STRANDED_ISSUE_RECOVERY_ORIGIN_KIND,
|
||||
input.issue.companyId,
|
||||
input.issue.id,
|
||||
input.latestRun?.id ?? "no-run",
|
||||
].join(":"),
|
||||
billingCode: input.issue.billingCode,
|
||||
inheritExecutionWorkspaceFromIssueId: input.issue.id,
|
||||
});
|
||||
} catch (error) {
|
||||
if (!isUniqueStrandedIssueRecoveryConflict(error)) throw error;
|
||||
const raced = await findOpenStrandedIssueRecoveryIssue(input.issue.companyId, input.issue.id);
|
||||
if (!raced) throw error;
|
||||
return raced;
|
||||
}
|
||||
|
||||
await deps.enqueueWakeup(ownerAgentId, {
|
||||
source: "assignment",
|
||||
|
||||
@@ -4,7 +4,13 @@ import { act, useEffect } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { CompanyProvider, useCompany } from "./CompanyContext";
|
||||
import type { Company } from "@paperclipai/shared";
|
||||
import {
|
||||
CompanyProvider,
|
||||
resolveBootstrapCompanySelection,
|
||||
shouldClearStoredCompanySelection,
|
||||
useCompany,
|
||||
} from "./CompanyContext";
|
||||
|
||||
const mockCompaniesApi = vi.hoisted(() => ({
|
||||
list: vi.fn(),
|
||||
@@ -15,6 +21,33 @@ vi.mock("../api/companies", () => ({
|
||||
companiesApi: mockCompaniesApi,
|
||||
}));
|
||||
|
||||
const activeCompany = { id: "company-1" };
|
||||
const secondActiveCompany = { id: "company-2" };
|
||||
const archivedCompany = { id: "archived-company" };
|
||||
|
||||
function makeCompany(id: string): Company {
|
||||
return {
|
||||
id,
|
||||
name: "Paperclip",
|
||||
description: null,
|
||||
status: "active",
|
||||
issuePrefix: "PAP",
|
||||
issueCounter: 1,
|
||||
budgetMonthlyCents: 0,
|
||||
spentMonthlyCents: 0,
|
||||
requireBoardApprovalForNewAgents: false,
|
||||
feedbackDataSharingEnabled: false,
|
||||
feedbackDataSharingConsentAt: null,
|
||||
feedbackDataSharingConsentByUserId: null,
|
||||
feedbackDataSharingTermsVersion: null,
|
||||
brandColor: null,
|
||||
logoAssetId: null,
|
||||
logoUrl: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
}
|
||||
|
||||
function Probe({ onSelectedCompanyId }: { onSelectedCompanyId: (companyId: string | null) => void }) {
|
||||
const { selectedCompanyId } = useCompany();
|
||||
useEffect(() => {
|
||||
@@ -23,6 +56,71 @@ function Probe({ onSelectedCompanyId }: { onSelectedCompanyId: (companyId: strin
|
||||
return <div data-selected-company-id={selectedCompanyId ?? ""} />;
|
||||
}
|
||||
|
||||
describe("resolveBootstrapCompanySelection", () => {
|
||||
it("does not expose a stale stored company id before companies load", () => {
|
||||
expect(resolveBootstrapCompanySelection({
|
||||
companies: [],
|
||||
sidebarCompanies: [],
|
||||
selectedCompanyId: null,
|
||||
storedCompanyId: "stale-company",
|
||||
})).toBeNull();
|
||||
});
|
||||
|
||||
it("replaces a stale stored company id with the first loaded company", () => {
|
||||
expect(resolveBootstrapCompanySelection({
|
||||
companies: [activeCompany],
|
||||
sidebarCompanies: [activeCompany],
|
||||
selectedCompanyId: null,
|
||||
storedCompanyId: "stale-company",
|
||||
})).toBe("company-1");
|
||||
});
|
||||
|
||||
it("keeps a valid selected company ahead of stored bootstrap state", () => {
|
||||
expect(resolveBootstrapCompanySelection({
|
||||
companies: [activeCompany],
|
||||
sidebarCompanies: [activeCompany],
|
||||
selectedCompanyId: "company-1",
|
||||
storedCompanyId: "stale-company",
|
||||
})).toBe("company-1");
|
||||
});
|
||||
|
||||
it("keeps a valid stored company id instead of falling back to the first company", () => {
|
||||
expect(resolveBootstrapCompanySelection({
|
||||
companies: [activeCompany, secondActiveCompany],
|
||||
sidebarCompanies: [activeCompany, secondActiveCompany],
|
||||
selectedCompanyId: null,
|
||||
storedCompanyId: "company-2",
|
||||
})).toBe("company-2");
|
||||
});
|
||||
|
||||
it("uses selectable sidebar companies before archived companies", () => {
|
||||
expect(resolveBootstrapCompanySelection({
|
||||
companies: [archivedCompany, activeCompany],
|
||||
sidebarCompanies: [activeCompany],
|
||||
selectedCompanyId: null,
|
||||
storedCompanyId: "archived-company",
|
||||
})).toBe("company-1");
|
||||
});
|
||||
});
|
||||
|
||||
describe("shouldClearStoredCompanySelection", () => {
|
||||
it("does not clear the stored company selection during an unauthorized company list response", () => {
|
||||
expect(shouldClearStoredCompanySelection({
|
||||
companies: [],
|
||||
isLoading: false,
|
||||
unauthorized: true,
|
||||
})).toBe(false);
|
||||
});
|
||||
|
||||
it("clears the stored company selection when an authorized company list is empty", () => {
|
||||
expect(shouldClearStoredCompanySelection({
|
||||
companies: [],
|
||||
isLoading: false,
|
||||
unauthorized: false,
|
||||
})).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("CompanyProvider", () => {
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
@@ -70,28 +168,11 @@ describe("CompanyProvider", () => {
|
||||
|
||||
it("replaces a stale stored company id with the first loaded company", async () => {
|
||||
localStorage.setItem("paperclip.selectedCompanyId", "stale-company");
|
||||
mockCompaniesApi.list.mockResolvedValue([
|
||||
{
|
||||
id: "company-1",
|
||||
name: "Paperclip",
|
||||
description: null,
|
||||
status: "active",
|
||||
issuePrefix: "PAP",
|
||||
issueCounter: 1,
|
||||
budgetMonthlyCents: 0,
|
||||
spentMonthlyCents: 0,
|
||||
requireBoardApprovalForNewAgents: false,
|
||||
feedbackDataSharingEnabled: false,
|
||||
feedbackDataSharingConsentAt: null,
|
||||
feedbackDataSharingConsentByUserId: null,
|
||||
feedbackDataSharingTermsVersion: null,
|
||||
brandColor: null,
|
||||
logoAssetId: null,
|
||||
logoUrl: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
]);
|
||||
let resolveCompanies!: (companies: Company[]) => void;
|
||||
const companiesPromise = new Promise<Company[]>((resolve) => {
|
||||
resolveCompanies = resolve;
|
||||
});
|
||||
mockCompaniesApi.list.mockReturnValue(companiesPromise);
|
||||
const seen: Array<string | null> = [];
|
||||
|
||||
await act(async () => {
|
||||
@@ -104,6 +185,11 @@ describe("CompanyProvider", () => {
|
||||
);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
resolveCompanies([makeCompany("company-1")]);
|
||||
await companiesPromise;
|
||||
});
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(seen).toEqual([null, "company-1"]);
|
||||
expect(localStorage.getItem("paperclip.selectedCompanyId")).toBe("company-1");
|
||||
|
||||
@@ -14,6 +14,7 @@ import { ApiError } from "../api/client";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
import type { CompanySelectionSource } from "../lib/company-selection";
|
||||
type CompanySelectionOptions = { source?: CompanySelectionSource };
|
||||
type CompanyListResult = { companies: Company[]; unauthorized: boolean };
|
||||
|
||||
interface CompanyContextValue {
|
||||
companies: Company[];
|
||||
@@ -35,25 +36,55 @@ const STORAGE_KEY = "paperclip.selectedCompanyId";
|
||||
|
||||
const CompanyContext = createContext<CompanyContextValue | null>(null);
|
||||
|
||||
export function resolveBootstrapCompanySelection(input: {
|
||||
companies: Array<Pick<Company, "id">>;
|
||||
sidebarCompanies: Array<Pick<Company, "id">>;
|
||||
selectedCompanyId: string | null;
|
||||
storedCompanyId: string | null;
|
||||
}) {
|
||||
if (input.companies.length === 0) return null;
|
||||
|
||||
const selectableCompanies = input.sidebarCompanies.length > 0
|
||||
? input.sidebarCompanies
|
||||
: input.companies;
|
||||
if (input.selectedCompanyId && selectableCompanies.some((company) => company.id === input.selectedCompanyId)) {
|
||||
return input.selectedCompanyId;
|
||||
}
|
||||
if (input.storedCompanyId && selectableCompanies.some((company) => company.id === input.storedCompanyId)) {
|
||||
return input.storedCompanyId;
|
||||
}
|
||||
return selectableCompanies[0]?.id ?? null;
|
||||
}
|
||||
|
||||
export function shouldClearStoredCompanySelection(input: {
|
||||
companies: Array<Pick<Company, "id">>;
|
||||
isLoading: boolean;
|
||||
unauthorized: boolean;
|
||||
}) {
|
||||
return !input.isLoading && !input.unauthorized && input.companies.length === 0;
|
||||
}
|
||||
|
||||
export function CompanyProvider({ children }: { children: ReactNode }) {
|
||||
const queryClient = useQueryClient();
|
||||
const [selectionSource, setSelectionSource] = useState<CompanySelectionSource>("bootstrap");
|
||||
const [selectedCompanyId, setSelectedCompanyIdState] = useState<string | null>(null);
|
||||
|
||||
const { data: companies = [], isLoading, error } = useQuery({
|
||||
const { data: companiesResult = { companies: [], unauthorized: false }, isLoading, error } = useQuery<CompanyListResult>({
|
||||
queryKey: queryKeys.companies.all,
|
||||
queryFn: async () => {
|
||||
try {
|
||||
return await companiesApi.list();
|
||||
return { companies: await companiesApi.list(), unauthorized: false };
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError && err.status === 401) {
|
||||
return [];
|
||||
return { companies: [], unauthorized: true };
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
retry: false,
|
||||
});
|
||||
const companies = companiesResult.companies;
|
||||
const companyListUnauthorized = companiesResult.unauthorized;
|
||||
const sidebarCompanies = useMemo(
|
||||
() => companies.filter((company) => company.status !== "archived"),
|
||||
[companies],
|
||||
@@ -63,24 +94,26 @@ export function CompanyProvider({ children }: { children: ReactNode }) {
|
||||
useEffect(() => {
|
||||
if (isLoading) return;
|
||||
if (companies.length === 0) {
|
||||
if (selectedCompanyId !== null) {
|
||||
setSelectedCompanyIdState(null);
|
||||
if (shouldClearStoredCompanySelection({ companies, isLoading: false, unauthorized: companyListUnauthorized })) {
|
||||
if (selectedCompanyId !== null) {
|
||||
setSelectedCompanyIdState(null);
|
||||
}
|
||||
localStorage.removeItem(STORAGE_KEY);
|
||||
}
|
||||
localStorage.removeItem(STORAGE_KEY);
|
||||
return;
|
||||
}
|
||||
|
||||
const selectableCompanies = sidebarCompanies.length > 0 ? sidebarCompanies : companies;
|
||||
if (selectedCompanyId && selectableCompanies.some((c) => c.id === selectedCompanyId)) return;
|
||||
|
||||
const stored = localStorage.getItem(STORAGE_KEY);
|
||||
const next = stored && selectableCompanies.some((c) => c.id === stored)
|
||||
? stored
|
||||
: selectableCompanies[0]!.id;
|
||||
const next = resolveBootstrapCompanySelection({
|
||||
companies,
|
||||
sidebarCompanies,
|
||||
selectedCompanyId,
|
||||
storedCompanyId: localStorage.getItem(STORAGE_KEY),
|
||||
});
|
||||
if (next === null || next === selectedCompanyId) return;
|
||||
setSelectedCompanyIdState(next);
|
||||
setSelectionSource("bootstrap");
|
||||
localStorage.setItem(STORAGE_KEY, next);
|
||||
}, [companies, isLoading, selectedCompanyId, sidebarCompanies]);
|
||||
}, [companies, companyListUnauthorized, isLoading, selectedCompanyId, sidebarCompanies]);
|
||||
|
||||
const setSelectedCompanyId = useCallback((companyId: string, options?: CompanySelectionOptions) => {
|
||||
setSelectedCompanyIdState(companyId);
|
||||
|
||||
Reference in new issue
Block a user