Merge reviewed fixture cleanup and accounting UI fixes

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Devin FoleyandPaperclip committed 2026-10-04 00:41:11 -07:00
commit 0b0db3241d
7 files changed
+189 -133

No files matched your search

@@ -822,13 +822,12 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
}
it("does not reap active adapter executions started by another heartbeat service instance", async () => {
let releaseAdapter: (() => void) | null = null;
let releaseAdapter!: () => void;
const adapterRelease = new Promise<void>(resolve => { releaseAdapter = resolve; });
const adapterStarted = new Promise<void>((resolve) => {
mockAdapterExecute.mockImplementationOnce(async () => {
resolve();
await new Promise<void>((release) => {
releaseAdapter = release;
});
await adapterRelease;
return {
exitCode: 0,
signal: null,
@@ -852,52 +851,55 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
const executorHeartbeat = heartbeatService(db);
const reaperHeartbeat = heartbeatService(db);
await executorHeartbeat.resumeQueuedRuns();
await Promise.race([
adapterStarted,
new Promise<never>((_, reject) => {
setTimeout(
() =>
reject(
new Error("Timed out waiting for adapter execution to start"),
),
3_000,
);
}),
]);
try {
await executorHeartbeat.resumeQueuedRuns();
await Promise.race([
adapterStarted,
new Promise<never>((_, reject) => {
setTimeout(
() =>
reject(
new Error("Timed out waiting for adapter execution to start"),
),
3_000,
);
}),
]);
await db
.update(heartbeatRuns)
.set({
updatedAt: new Date("2026-03-19T00:00:00.000Z"),
})
.where(eq(heartbeatRuns.id, runId));
await db
.update(heartbeatRuns)
.set({
updatedAt: new Date("2026-03-19T00:00:00.000Z"),
})
.where(eq(heartbeatRuns.id, runId));
const result = await reaperHeartbeat.reapOrphanedRuns({
staleThresholdMs: 1,
});
expect(result).toEqual({ reaped: 0, runIds: [] });
const result = await reaperHeartbeat.reapOrphanedRuns({
staleThresholdMs: 1,
});
expect(result).toEqual({ reaped: 0, runIds: [] });
const activeRun = await reaperHeartbeat.getRun(runId);
expect(activeRun?.status).toBe("running");
expect(activeRun?.errorCode).toBeNull();
const activeRun = await reaperHeartbeat.getRun(runId);
expect(activeRun?.status).toBe("running");
expect(activeRun?.errorCode).toBeNull();
const wakeup = await db
.select()
.from(agentWakeupRequests)
.where(eq(agentWakeupRequests.id, wakeupRequestId))
.then((rows) => rows[0] ?? null);
expect(wakeup?.status).toBe("claimed");
const wakeup = await db
.select()
.from(agentWakeupRequests)
.where(eq(agentWakeupRequests.id, wakeupRequestId))
.then((rows) => rows[0] ?? null);
expect(wakeup?.status).toBe("claimed");
if (!releaseAdapter)
throw new Error("Adapter release handle was not captured");
releaseAdapter();
const settledRun = await waitForRunToSettle(
executorHeartbeat,
runId,
5_000,
);
expect(settledRun?.status).toBe("succeeded");
releaseAdapter();
const settledRun = await waitForRunToSettle(
executorHeartbeat,
runId,
5_000,
);
expect(settledRun?.status).toBe("succeeded");
} finally {
releaseAdapter();
await executorHeartbeat.drainActiveRunExecutions();
}
});
async function seedStrandedIssueFixture(input: {
@@ -3410,7 +3412,8 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
});
it("persists codex_local spawn identity before hot restart and never loses the live run for missing metadata", async () => {
let releaseAdapter: (() => void) | null = null;
let releaseAdapter!: () => void;
const adapterRelease = new Promise<void>(resolve => { releaseAdapter = resolve; });
let spawnedPid: number | null = null;
const adapterStarted = new Promise<void>((resolve) => {
mockAdapterExecute.mockImplementationOnce(async (rawInput?: unknown) => {
@@ -3432,9 +3435,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
startedAt: new Date("2026-07-30T07:00:00.000Z").toISOString(),
});
resolve();
await new Promise<void>((release) => {
releaseAdapter = release;
});
await adapterRelease;
return {
exitCode: 0,
signal: null,
@@ -3459,87 +3460,90 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
includeIssue: false,
});
const heartbeat = heartbeatService(db);
await heartbeat.resumeQueuedRuns();
await Promise.race([
adapterStarted,
new Promise<never>((_, reject) => {
setTimeout(
() =>
reject(
new Error("Timed out waiting for codex_local spawn identity"),
),
3_000,
);
}),
]);
const running = await waitForValue(async () =>
db
.select()
.from(heartbeatRuns)
.where(eq(heartbeatRuns.id, runId))
.then((rows) => {
const row = rows[0] ?? null;
return row?.status === "running" && row.processPid ? row : null;
try {
await heartbeat.resumeQueuedRuns();
await Promise.race([
adapterStarted,
new Promise<never>((_, reject) => {
setTimeout(
() =>
reject(
new Error("Timed out waiting for codex_local spawn identity"),
),
3_000,
);
}),
);
const observedProcessStartedAt = await readProcessStartedAt(spawnedPid!);
expect(observedProcessStartedAt).not.toBeNull();
expect(running).toMatchObject({
id: runId,
status: "running",
processPid: spawnedPid,
processGroupId: null,
processStartedAt: new Date(observedProcessStartedAt!),
});
]);
await withTempPaperclipHome(async (home) => {
await writeHotRestartIntent({
previousServerPid: process.pid,
previousServerVersion: "old-version",
requestedAt: new Date("2026-07-30T07:01:00.000Z"),
});
await heartbeat.prepareHotRestartShutdown(
"SIGTERM",
new Date("2026-07-30T07:02:00.000Z"),
);
const adoption = await heartbeat.reconcileHotRestartAdoption(
new Date("2026-07-30T07:03:00.000Z"),
);
expect(adoption).toMatchObject({
mode: "reported",
adoptedRunIds: [runId],
finalizedWhileDownRunIds: [],
lostRunIds: [],
});
const report = JSON.parse(
await fs.readFile(resolveHotRestartReportPath(home), "utf8"),
) as { runs?: Array<Record<string, unknown>> };
expect(report.runs).toEqual(
expect.arrayContaining([
expect.objectContaining({
runId,
classification: "adopted",
reason: "process_pid_alive",
const running = await waitForValue(async () =>
db
.select()
.from(heartbeatRuns)
.where(eq(heartbeatRuns.id, runId))
.then((rows) => {
const row = rows[0] ?? null;
return row?.status === "running" && row.processPid ? row : null;
}),
]),
);
expect(report.runs).not.toEqual(
expect.arrayContaining([
expect.objectContaining({
runId,
reason: "missing_process_metadata",
}),
]),
);
});
const observedProcessStartedAt = await readProcessStartedAt(spawnedPid!);
expect(observedProcessStartedAt).not.toBeNull();
expect(running).toMatchObject({
id: runId,
status: "running",
processPid: spawnedPid,
processGroupId: null,
processStartedAt: new Date(observedProcessStartedAt!),
});
if (!releaseAdapter)
throw new Error("Adapter release handle was not captured");
releaseAdapter();
const settled = await waitForRunToSettle(heartbeat, runId, 5_000);
expect(settled?.status).toBe("succeeded");
await withTempPaperclipHome(async (home) => {
await writeHotRestartIntent({
previousServerPid: process.pid,
previousServerVersion: "old-version",
requestedAt: new Date("2026-07-30T07:01:00.000Z"),
});
await heartbeat.prepareHotRestartShutdown(
"SIGTERM",
new Date("2026-07-30T07:02:00.000Z"),
);
const adoption = await heartbeat.reconcileHotRestartAdoption(
new Date("2026-07-30T07:03:00.000Z"),
);
expect(adoption).toMatchObject({
mode: "reported",
adoptedRunIds: [runId],
finalizedWhileDownRunIds: [],
lostRunIds: [],
});
const report = JSON.parse(
await fs.readFile(resolveHotRestartReportPath(home), "utf8"),
) as { runs?: Array<Record<string, unknown>> };
expect(report.runs).toEqual(
expect.arrayContaining([
expect.objectContaining({
runId,
classification: "adopted",
reason: "process_pid_alive",
}),
]),
);
expect(report.runs).not.toEqual(
expect.arrayContaining([
expect.objectContaining({
runId,
reason: "missing_process_metadata",
}),
]),
);
});
releaseAdapter();
const settled = await waitForRunToSettle(heartbeat, runId, 5_000);
expect(settled?.status).toBe("succeeded");
} finally {
releaseAdapter();
await heartbeat.drainActiveRunExecutions();
}
});
it("reports adopted hot-restart runs before startup reap can mark them process_lost", async () => {
@@ -29,11 +29,22 @@ describe("exact reservation editing", () => {
const save = Array.from(container.querySelectorAll("button")).find(button => button.textContent === "Update reservation")!;
expect(input.value).toBe("90071992547409.920000001");
expect(save.disabled).toBe(true);
for (const invalid of ["-1", "", "not an amount"]) {
await act(async () => {
Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")!.set!.call(input, invalid);
input.dispatchEvent(new Event("input", { bubbles: true }));
});
expect(save.disabled).toBe(true);
expect(input.getAttribute("aria-invalid")).toBe("true");
expect(container.querySelector('[role="alert"]')?.textContent).toBe("Enter a valid amount of zero or more.");
expect(input.getAttribute("aria-describedby")).toBe(container.querySelector('[role="alert"]')?.id);
}
await act(async () => {
Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")!.set!.call(input, "0.000000001");
input.dispatchEvent(new Event("input", { bubbles: true }));
});
await act(async () => save.click());
expect(onReservationChange).toHaveBeenCalledWith("0.0000001");
expect(container.querySelector('[role="alert"]')).toBeNull();
});
});
+5 -2
View File
@@ -1,5 +1,5 @@
import { centsToUsd, usdToCents } from "@paperclipai/shared";
import { useEffect, useState } from "react";
import { useEffect, useId, useState } from "react";
import type { BudgetPolicySummary } from "@paperclipai/shared";
import { AlertTriangle, PauseCircle, ShieldAlert, Wallet } from "lucide-react";
import { cn, formatCents } from "../lib/utils";
@@ -53,13 +53,16 @@ export function BudgetPolicyCard({
}, [summary.amount]);
const [reservation, setReservation] = useState(centsToUsd(summary.reservationCents ?? 0));
const reservationErrorId = useId();
useEffect(() => setReservation(centsToUsd(summary.reservationCents ?? 0)), [summary.reservationCents]);
let reservationCents: string | null = null;
try { if (!reservation.trim().startsWith("-")) reservationCents = usdToCents(reservation.trim()); } catch { /* Shown as invalid input below. */ }
const reservationSection = onReservationChange ? <div className="space-y-2">
<label className="text-sm">Reserve per run (USD)
<Input aria-label="Reserve per run (USD)" value={reservation} onChange={event => setReservation(event.target.value)} inputMode="decimal" />
<Input aria-label="Reserve per run (USD)" aria-invalid={reservationCents === null} aria-describedby={reservationCents === null ? reservationErrorId : undefined}
value={reservation} onChange={event => setReservation(event.target.value)} inputMode="decimal" />
</label>
{reservationCents === null && <p id={reservationErrorId} role="alert" className="text-xs text-destructive">Enter a valid amount of zero or more.</p>}
<p className="text-xs text-muted-foreground">An estimate held before each run starts. Zero disables the estimate. Actual provider charges may exceed it.</p>
<Button variant="outline" disabled={isSaving || reservationCents === null || reservationCents === (summary.reservationCents ?? "0.0000000")}
onClick={() => { if (reservationCents !== null) onReservationChange(reservationCents); }}>Update reservation</Button>
+12
View File
@@ -34,6 +34,18 @@ describe("Costs account quota polling", () => {
});
afterEach(() => { act(() => root.unmount()); client.clear(); container.remove(); vi.unstubAllGlobals(); });
const flush = async () => { await act(async () => { await new Promise(resolve => setTimeout(resolve, 10)); }); };
it.each(["providers", "billers"] as const)("marks retained %s reports stale when their refresh fails", async (tab) => {
api.byBiller.mockResolvedValue([{ biller: "openai", costCents: 125, eventCount: 1, providerCount: 1, inputTokens: 1, outputTokens: 1, cachedInputTokens: 0 }]);
await act(async () => root.render(<MemoryRouter><SidebarProvider><QueryClientProvider client={client}><Costs initialTab={tab} /></QueryClientProvider></SidebarProvider></MemoryRouter>));
await flush();
expect(container.textContent).not.toContain("Showing the last loaded data");
const report = tab === "providers" ? api.byProvider : api.byBiller;
report.mockRejectedValue(new Error("Private report diagnostic"));
await act(async () => { await client.invalidateQueries(); }); await flush();
expect(container.textContent).toContain("Showing the last loaded data");
expect(container.textContent).toContain("OpenAI");
expect(container.textContent).not.toContain("Private report diagnostic");
});
it("keeps quota readings with a safe notice when the endpoint rejects", async () => {
await act(async () => root.render(<MemoryRouter><SidebarProvider><QueryClientProvider client={client}><Costs /></QueryClientProvider></SidebarProvider></MemoryRouter>));
await flush();
+6 -6
View File
@@ -323,7 +323,7 @@ export function Costs({
return map;
}, [spendData?.byAgentModel]);
const { data: providerData } = useQuery({
const { data: providerData, error: providerError } = useQuery({
queryKey: queryKeys.usageByProvider(companyId, reportQueryFrom, reportQueryTo),
queryFn: () => costsApi.byProvider(companyId, from || undefined, to || undefined),
enabled: !!selectedCompanyId && customReady && (mainTab === "providers" || mainTab === "billers"),
@@ -331,7 +331,7 @@ export function Costs({
staleTime: 10_000,
});
const { data: billerData } = useQuery({
const { data: billerData, error: billerError } = useQuery({
queryKey: queryKeys.usageByBiller(companyId, reportQueryFrom, reportQueryTo),
queryFn: () => costsApi.byBiller(companyId, from || undefined, to || undefined),
enabled: !!selectedCompanyId && customReady && mainTab === "billers",
@@ -339,7 +339,7 @@ export function Costs({
staleTime: 10_000,
});
const { data: weekData } = useQuery({
const { data: weekData, error: weekError } = useQuery({
queryKey: queryKeys.usageByProvider(companyId, weekRange.from, weekRange.to),
queryFn: () => costsApi.byProvider(companyId, weekRange.from, weekRange.to),
enabled: !!selectedCompanyId && (mainTab === "providers" || mainTab === "billers"),
@@ -347,7 +347,7 @@ export function Costs({
staleTime: 10_000,
});
const { data: weekBillerData } = useQuery({
const { data: weekBillerData, error: weekBillerError } = useQuery({
queryKey: queryKeys.usageByBiller(companyId, weekRange.from, weekRange.to),
queryFn: () => costsApi.byBiller(companyId, weekRange.from, weekRange.to),
enabled: !!selectedCompanyId && mainTab === "billers",
@@ -355,7 +355,7 @@ export function Costs({
staleTime: 10_000,
});
const { data: windowData } = useQuery({
const { data: windowData, error: windowError } = useQuery({
queryKey: queryKeys.usageWindowSpend(companyId),
queryFn: () => costsApi.windowSpend(companyId),
enabled: !!selectedCompanyId && mainTab === "providers",
@@ -677,7 +677,7 @@ export function Costs({
</div>
) : null}
{((spendData && spendError) || (financeData && financeError) || (budgetData && budgetError)) ? (
{((spendData && spendError) || (financeData && financeError) || (budgetData && budgetError) || providerError || billerError || weekError || weekBillerError || windowError) ? (
<p role="status" className="text-sm text-muted-foreground">
Showing the last loaded data. Updates will resume automatically.
</p>
@@ -44,6 +44,24 @@ describe("Costs story finance actions", () => {
}
});
it("accepts equivalent invoice retries but rejects changed normalized content without altering charges", async () => {
const fixture = createCostsFinanceFixtures("preview");
const invoice = { biller: "Example", externalId: "immutable-invoice", currency: "USD", lines: [
{ externalId: "fee-b", kind: "fee", amountCents: "50", occurredAt: charge.occurredAt },
{ externalId: "fee-a", kind: "fee", amountCents: "25", occurredAt: charge.occurredAt },
] };
const saved = await (await fixture("accounting/invoices", request("accounting/invoices", invoice)))!.json();
const equivalent = { ...invoice, lines: invoice.lines.slice().reverse().map(line => ({ ...line, amountCents: `${line.amountCents}.0000000` })) };
expect(await (await fixture("accounting/invoices", request("accounting/invoices", equivalent)))!.json()).toEqual(saved);
for (const changed of [
{ ...invoice, currency: "EUR" },
{ ...invoice, lines: [{ ...invoice.lines[0], amountCents: "51" }, invoice.lines[1]] },
{ ...invoice, lines: [{ ...invoice.lines[0], occurredAt: "2000-01-01T00:00:00Z" }, invoice.lines[1]] },
]) expect((await fixture("accounting/invoices", request("accounting/invoices", changed)))!.status).toBe(409);
expect(await (await fixture("costs/finance-summary", request("costs/finance-summary")))!.json()).toMatchObject({ debitCents: 75, eventCount: 2 });
expect(await (await fixture("accounting/invoices", request("accounting/invoices")))!.json()).toHaveLength(1);
});
it("keeps timestamp bounds exact while date-only bounds include the full day", async () => {
const fixture = createCostsFinanceFixtures("preview");
for (const [hour, id] of [["12", "at-boundary"], ["18", "after-boundary"]]) {
+8
View File
@@ -4,6 +4,7 @@ import { normalizeCents, createFinanceEventSchema, importBillingInvoiceSchema, i
export function createCostsFinanceFixtures(companyId: string) {
const events: FinanceEvent[] = [];
const reports: BillingReconciliation[] = [];
const invoiceContents = new Map<string, string>();
const createdAt = new Date();
let sequence = 0;
const id = () => `00000000-0000-4000-8000-${String(++sequence).padStart(12, "0")}`;
@@ -54,11 +55,18 @@ export function createCostsFinanceFixtures(companyId: string) {
if (resource === "accounting/invoices") {
if (request.method === "GET") return Response.json(reports.map(report => report.invoice));
const input = importBillingInvoiceSchema.parse(await request.json());
input.lines.sort((a, b) => a.externalId.localeCompare(b.externalId));
const canonical = (value: unknown): unknown => value instanceof Date ? value.toISOString()
: Array.isArray(value) ? value.map(canonical)
: value && typeof value === "object" ? Object.fromEntries(Object.entries(value).sort(([a], [b]) => a.localeCompare(b)).map(([key, item]) => [key, canonical(item)])) : value;
const contents = JSON.stringify(canonical(input));
let report = reports.find(row => row.invoice.biller === input.biller && row.invoice.externalId === input.externalId);
if (report && invoiceContents.get(report.invoice.id) !== contents) return Response.json({ error: "Invoice identifier already has different contents" }, { status: 409 });
if (!report) {
const invoice = { id: id(), companyId, biller: input.biller, externalId: input.externalId, currency: input.currency, createdAt: createdAt.toISOString() };
report = { invoice, lines: input.lines.map(line => ({ id: id(), externalId: line.externalId, kind: line.kind, amountCents: line.amountCents, status: "unmatched", matchedEventId: null, recordedCents: null, differenceCents: null })) };
reports.push(report);
invoiceContents.set(invoice.id, contents);
for (const line of input.lines) record({ idempotencyKey: `preview-invoice:${invoice.id}:${line.externalId}`, externalInvoiceId: invoice.externalId, biller: input.biller, currency: input.currency, amountCents: line.amountCents, occurredAt: line.occurredAt, direction: line.kind === "credit" ? "credit" : "debit", eventKind: line.kind === "credit" ? "credit_refund" : line.kind === "fee" ? "platform_fee" : "inference_charge", description: `Preview invoice ${input.externalId}` });
}
return Response.json(report.invoice);