diff --git a/AGENTS.md b/AGENTS.md index 9597b02..398ba34 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -45,7 +45,9 @@ could be removed without the product collapsing, the design is wrong. `lib/`. Agent definitions only wire pieces together. No kitchen-sink files. - Database changes go through the tooling, never by hand: edit `lib/db/schema.ts`, then `pnpm db:generate --name `, then `pnpm db:migrate`. Migration SQL is - generated output; handwriting or editing it is a defect. + generated output; handwriting or editing it is a defect. The one sanctioned + exception is a data backfill, which rides `pnpm db:generate --custom`, drizzle's + own mechanism for exactly that. - `scripts/` holds only load-bearing, public-grade harnesses. ## UI standards diff --git a/app/api/doordash/approvals/[id]/cancel/route.ts b/app/api/doordash/approvals/[id]/cancel/route.ts index 456028f..9fac88a 100644 --- a/app/api/doordash/approvals/[id]/cancel/route.ts +++ b/app/api/doordash/approvals/[id]/cancel/route.ts @@ -1,14 +1,21 @@ import { eq } from "drizzle-orm"; +import { z } from "zod"; import { getDb } from "@/lib/db/index"; import { ddApprovals } from "@/lib/db/schema"; -import { voidApproval } from "@/lib/rendi/doordash-approval"; +import { + verifyApprovalToken, + voidApproval, +} from "@/lib/rendi/doordash-approval"; import { setCartStatus } from "@/lib/rendi/doordash-db"; // The card's cancel button: voids an unconsumed approval and reopens // the cart for editing. A consumed approval already became an order; // cancelling here must never rewind that cart. + +const bodySchema = z.object({ token: z.string().min(1).max(64) }); + export async function POST( - _request: Request, + request: Request, { params }: { params: Promise<{ id: string }> }, ) { const { id } = await params; @@ -16,19 +23,23 @@ export async function POST( if (!Number.isInteger(approvalId)) { return Response.json({ error: "bad approval" }, { status: 400 }); } + const parsed = bodySchema.safeParse(await request.json().catch(() => null)); + if (!parsed.success || !verifyApprovalToken(approvalId, parsed.data.token)) { + return Response.json({ error: "not yours" }, { status: 403 }); + } const [row] = await getDb() - .select({ - cartUuid: ddApprovals.cartUuid, - consumedAt: ddApprovals.consumedAt, - }) + .select({ cartUuid: ddApprovals.cartUuid }) .from(ddApprovals) .where(eq(ddApprovals.id, approvalId)); if (!row) return Response.json({ error: "no such approval" }, { status: 404 }); - if (row.consumedAt) { + // The void itself is the arbiter: if submission consumed the row + // between any read and now, nothing voids and the cart must not + // reopen under an order that is already being placed. + const voided = await voidApproval(approvalId); + if (!voided) { return Response.json({ error: "already used" }, { status: 409 }); } - await voidApproval(approvalId); await setCartStatus(row.cartUuid, "open"); return Response.json({ ok: true }); } diff --git a/app/api/doordash/approvals/[id]/route.ts b/app/api/doordash/approvals/[id]/route.ts index 75cbb80..e94825e 100644 --- a/app/api/doordash/approvals/[id]/route.ts +++ b/app/api/doordash/approvals/[id]/route.ts @@ -1,6 +1,7 @@ import { eq } from "drizzle-orm"; import { getDb } from "@/lib/db/index"; import { ddApprovals } from "@/lib/db/schema"; +import { verifyApprovalToken } from "@/lib/rendi/doordash-approval"; export type ApprovalStatus = | "waiting" @@ -12,7 +13,7 @@ export type ApprovalStatus = // The card re-renders from the transcript on every reload, so it asks // the row where things actually stand before offering a code input. export async function GET( - _request: Request, + request: Request, { params }: { params: Promise<{ id: string }> }, ) { const { id } = await params; @@ -20,6 +21,10 @@ export async function GET( if (!Number.isInteger(approvalId)) { return Response.json({ error: "bad approval" }, { status: 400 }); } + const token = new URL(request.url).searchParams.get("token"); + if (!verifyApprovalToken(approvalId, token)) { + return Response.json({ error: "not yours" }, { status: 403 }); + } const [row] = await getDb() .select({ consumedAt: ddApprovals.consumedAt, diff --git a/app/api/doordash/approvals/[id]/verify/route.ts b/app/api/doordash/approvals/[id]/verify/route.ts index e3bcec4..fe0501f 100644 --- a/app/api/doordash/approvals/[id]/verify/route.ts +++ b/app/api/doordash/approvals/[id]/verify/route.ts @@ -2,14 +2,22 @@ import { eq } from "drizzle-orm"; import { z } from "zod"; import { getDb } from "@/lib/db/index"; import { ddApprovals } from "@/lib/db/schema"; -import { verifyApproval } from "@/lib/rendi/doordash-approval"; +import { + verifyApproval, + verifyApprovalToken, +} from "@/lib/rendi/doordash-approval"; import { sendSessionText } from "@/lib/rendi/nudge"; // The code's only door. Deterministic verification, no model anywhere // in the path; on success the session inbox wakes the agent, the -// three-writers pattern doing what it was born for. +// three-writers pattern doing what it was born for. The capability +// token proves the caller holds this conversation's card, so another +// gate holder cannot burn attempts on someone else's approval. -const bodySchema = z.object({ code: z.string().min(1).max(12) }); +const bodySchema = z.object({ + code: z.string().min(1).max(12), + token: z.string().min(1).max(64), +}); export async function POST( request: Request, @@ -22,7 +30,10 @@ export async function POST( } const parsed = bodySchema.safeParse(await request.json().catch(() => null)); if (!parsed.success) { - return Response.json({ error: "bad code" }, { status: 400 }); + return Response.json({ error: "bad request" }, { status: 400 }); + } + if (!verifyApprovalToken(approvalId, parsed.data.token)) { + return Response.json({ error: "not yours" }, { status: 403 }); } const result = await verifyApproval(approvalId, parsed.data.code); if (!result.ok) { @@ -32,11 +43,27 @@ export async function POST( .select({ conversationId: ddApprovals.conversationId }) .from(ddApprovals) .where(eq(ddApprovals.id, approvalId)); + // The row is already verified; losing the wake must not lose the + // approval, so the bell rings up to three times under ONE message id + // (a commit-then-lost-response send must not wake the agent twice), + // and the card is told honestly when nobody answered. + let woke = false; if (row) { - await sendSessionText( - row.conversationId, - `[order approved, approval ${approvalId}] The owner entered the code. Place the order with doordash-submit.`, - ); + const wakeId = crypto.randomUUID(); + for (let attempt = 0; attempt < 3 && !woke; attempt++) { + try { + await sendSessionText( + row.conversationId, + `[order approved, approval ${approvalId}] The owner entered the code. Place the order with doordash-submit.`, + wakeId, + ); + woke = true; + } catch { + await new Promise((resolve) => + setTimeout(resolve, 300 * (attempt + 1)), + ); + } + } } - return Response.json({ ok: true }); + return Response.json({ ok: true, woke }); } diff --git a/app/api/doordash/carts/[uuid]/ops/route.ts b/app/api/doordash/carts/[uuid]/ops/route.ts index 12652f3..4147037 100644 --- a/app/api/doordash/carts/[uuid]/ops/route.ts +++ b/app/api/doordash/carts/[uuid]/ops/route.ts @@ -68,5 +68,15 @@ export async function POST( } await new Promise((resolve) => setTimeout(resolve, POLL_MS)); } + // A timeout answer must also be true: cancel the run so a queued edit + // cannot mutate the cart after the card was told it failed, then look + // once more, because the run may have finished regardless of us. + await runs.cancel(handle.id).catch(() => {}); + await new Promise((resolve) => setTimeout(resolve, 1500)); + const last = await runs.retrieve(handle.id).catch(() => null); + if (last?.status === "COMPLETED") { + const fresh = await getCartSnapshot(uuid); + return Response.json({ ok: true, cart: fresh ?? null }); + } return Response.json({ error: "edit timed out" }, { status: 504 }); } diff --git a/app/api/doordash/carts/[uuid]/route.ts b/app/api/doordash/carts/[uuid]/route.ts new file mode 100644 index 0000000..429dcdf --- /dev/null +++ b/app/api/doordash/carts/[uuid]/route.ts @@ -0,0 +1,25 @@ +import { getCartSnapshot } from "@/lib/rendi/doordash-db"; + +// The durable snapshot, for cards to hydrate against: transcripts +// freeze tool output forever, but the cart kept living. The uuid is +// its own capability; there is nothing guessable here. +export async function GET( + _request: Request, + { params }: { params: Promise<{ uuid: string }> }, +) { + const { uuid } = await params; + const snapshot = await getCartSnapshot(uuid); + if (!snapshot) { + return Response.json({ error: "no such cart" }, { status: 404 }); + } + return Response.json({ + cartUuid: snapshot.cartUuid, + storeName: snapshot.storeName, + storeImageUrl: snapshot.storeImageUrl, + items: snapshot.items, + quote: snapshot.quote, + tipCents: snapshot.tipCents, + fulfillment: snapshot.fulfillment, + status: snapshot.status, + }); +} diff --git a/components/doordash/approval-card.stories.tsx b/components/doordash/approval-card.stories.tsx index c42aeda..a62f1cb 100644 --- a/components/doordash/approval-card.stories.tsx +++ b/components/doordash/approval-card.stories.tsx @@ -4,6 +4,7 @@ import { ApprovalCard } from "./approval-card"; const awaiting = { approvalId: 41, + token: "test-token", expiresAt: new Date(Date.now() + 14 * 60 * 1000).toISOString(), totalCents: 3317, tipCents: 440, @@ -19,9 +20,11 @@ const meta = { args: { state: "output-available", output: awaiting, - verify: fn(async (_id: number, _code: string) => ({ ok: true })), - cancel: fn(async (_id: number) => {}), - fetchStatus: fn(async (_id: number) => "waiting"), + verify: fn(async (_id: number, _code: string, _token: string) => ({ + ok: true, + })), + cancel: fn(async (_id: number, _token: string) => {}), + fetchStatus: fn(async (_id: number, _token: string) => "waiting"), }, decorators: [ (Story) => ( @@ -43,7 +46,9 @@ export const CodeEntry: Story = { await expect(approve).toBeDisabled(); await userEvent.type(input, "482913"); await userEvent.click(approve); - await waitFor(() => expect(args.verify).toHaveBeenCalledWith(41, "482913")); + await waitFor(() => + expect(args.verify).toHaveBeenCalledWith(41, "482913", "test-token"), + ); await expect( canvas.getByText("approved; rendi is placing the order"), ).toBeVisible(); @@ -52,7 +57,7 @@ export const CodeEntry: Story = { export const WrongCode: Story = { args: { - verify: fn(async (_id: number, _code: string) => ({ + verify: fn(async (_id: number, _code: string, _token: string) => ({ ok: false, reason: "wrong", attemptsLeft: 4, @@ -75,7 +80,9 @@ export const Cancelled: Story = { play: async ({ canvasElement, args }) => { const canvas = within(canvasElement); await userEvent.click(canvas.getByRole("button", { name: "Cancel" })); - await waitFor(() => expect(args.cancel).toHaveBeenCalledWith(41)); + await waitFor(() => + expect(args.cancel).toHaveBeenCalledWith(41, "test-token"), + ); await expect( canvas.getByText("approval cancelled; the cart is open again"), ).toBeVisible(); @@ -83,7 +90,7 @@ export const Cancelled: Story = { }; export const AlreadyApproved: Story = { - args: { fetchStatus: fn(async (_id: number) => "consumed") }, + args: { fetchStatus: fn(async (_id: number, _token: string) => "consumed") }, play: async ({ canvasElement }) => { const canvas = within(canvasElement); // A reload after the code was entered never re-offers the input. diff --git a/components/doordash/approval-card.tsx b/components/doordash/approval-card.tsx index 01e7a80..3047dbc 100644 --- a/components/doordash/approval-card.tsx +++ b/components/doordash/approval-card.tsx @@ -10,6 +10,7 @@ import { Input } from "@/components/ui/input"; type ApprovalOutput = { approvalId?: number; + token?: string; expiresAt?: string; totalCents?: number; storeName?: string; @@ -19,6 +20,7 @@ type ApprovalOutput = { type VerifyResponse = { ok?: boolean; + woke?: boolean; reason?: string; attemptsLeft?: number; }; @@ -26,23 +28,28 @@ type VerifyResponse = { async function postCode( approvalId: number, code: string, + token: string, ): Promise { const response = await fetch(`/api/doordash/approvals/${approvalId}/verify`, { method: "POST", headers: { "content-type": "application/json" }, - body: JSON.stringify({ code }), + body: JSON.stringify({ code, token }), }); return response.json(); } -async function postCancel(approvalId: number): Promise { +async function postCancel(approvalId: number, token: string): Promise { await fetch(`/api/doordash/approvals/${approvalId}/cancel`, { method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ token }), }); } -async function getStatus(approvalId: number): Promise { - const response = await fetch(`/api/doordash/approvals/${approvalId}`); +async function getStatus(approvalId: number, token: string): Promise { + const response = await fetch( + `/api/doordash/approvals/${approvalId}?token=${encodeURIComponent(token)}`, + ); if (!response.ok) return "voided"; const body = (await response.json()) as { status?: string }; return body.status ?? "waiting"; @@ -90,12 +97,13 @@ export function ApprovalCard({ const [seconds, setSeconds] = useState(null); const approvalId = output?.approvalId; + const token = output?.token ?? ""; const expiresAt = output?.expiresAt; useEffect(() => { if (!approvalId) return; let alive = true; - fetchStatus(approvalId).then((status) => { + fetchStatus(approvalId, token).then((status) => { if (!alive) return; if (status === "verified" || status === "consumed") { setPhase("approved"); @@ -108,7 +116,7 @@ export function ApprovalCard({ return () => { alive = false; }; - }, [approvalId, fetchStatus]); + }, [approvalId, token, fetchStatus]); useEffect(() => { if (phase !== "waiting" || !expiresAt) return; @@ -125,9 +133,12 @@ export function ApprovalCard({ if (!approvalId || code.trim().length < 6) return; setPhase("checking"); setNote(null); - const result = await verify(approvalId, code.trim()); + const result = await verify(approvalId, code.trim(), token); if (result.ok) { setPhase("approved"); + if (result.woke === false) { + setNote("rendi did not hear the bell; say anything in the chat"); + } return; } setCode(""); @@ -173,9 +184,16 @@ export function ApprovalCard({

) : approvalId ? ( phase === "approved" ? ( -

- approved; rendi is placing the order -

+
+

+ approved; rendi is placing the order +

+ {note ? ( +

+ {note} +

+ ) : null} +
) : phase === "cancelled" ? (

approval cancelled; the cart is open again @@ -218,7 +236,7 @@ export function ApprovalCard({ variant="ghost" disabled={phase === "checking"} onClick={async () => { - await cancel(approvalId); + await cancel(approvalId, token); setPhase("cancelled"); }} > diff --git a/components/doordash/cart-card.stories.tsx b/components/doordash/cart-card.stories.tsx index daedfac..c018615 100644 --- a/components/doordash/cart-card.stories.tsx +++ b/components/doordash/cart-card.stories.tsx @@ -3,7 +3,7 @@ import { expect, fn, userEvent, waitFor, within } from "storybook/test"; import { cartShowFixture, previewFixture } from "@/lib/rendi/doordash.fixtures"; import { normalizeCartLines, normalizeQuote } from "@/lib/rendi/doordash-cart"; import { ddCartEnvelope, ddPreviewResult } from "@/lib/rendi/doordash-schemas"; -import { CartCard, type CartCardData } from "./cart-card"; +import { CartCard, type CartCardData, type DurableCart } from "./cart-card"; const lines = normalizeCartLines(ddCartEnvelope.parse(cartShowFixture).cart); const quote = normalizeQuote(ddPreviewResult.parse(previewFixture)); @@ -25,6 +25,7 @@ const meta = { args: { data: building, exec: fn(async () => ({ ok: true, cart: null })), + hydrate: fn(async (): Promise => null), }, decorators: [ (Story) => ( @@ -97,3 +98,66 @@ export const Empty: Story = { await expect(canvas.getByText("The cart is empty.")).toBeVisible(); }, }; + +export const Sealed: Story = { + args: { + hydrate: fn(async () => ({ status: "placing" })), + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + // The durable snapshot says the order is being placed: every + // control freezes and the header says why. + await expect(await canvas.findByText("placing the order")).toBeVisible(); + await waitFor(() => + expect( + canvas.getByRole("button", { name: "One more Pad See Ew" }), + ).toBeDisabled(), + ); + await expect(canvas.getByRole("button", { name: "10%" })).toBeDisabled(); + }, +}; + +export const HydratesFromDurableTruth: Story = { + args: { + hydrate: fn(async () => ({ status: "open", tipCents: 880 })), + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + // The transcript froze a 10 percent tip; the durable cart moved on + // to 20 percent, and the card adopts the truth on mount. + await waitFor(async () => + expect(canvas.getByRole("button", { name: "20%" })).toHaveAttribute( + "aria-pressed", + "true", + ), + ); + }, +}; + +export const FailedEditKeepsDurableTruth: Story = { + args: { + hydrate: fn(async () => ({ status: "open", tipCents: 880 })), + exec: fn(async () => { + throw new Error("edit failed"); + }), + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await waitFor(async () => + expect(canvas.getByRole("button", { name: "20%" })).toHaveAttribute( + "aria-pressed", + "true", + ), + ); + await userEvent.click(canvas.getByRole("button", { name: "15%" })); + await expect( + await canvas.findByText("that edit did not go through; prices unchanged"), + ).toBeVisible(); + // The rollback lands on the hydrated truth, never the frozen + // transcript numbers. + await expect(canvas.getByRole("button", { name: "20%" })).toHaveAttribute( + "aria-pressed", + "true", + ); + }, +}; diff --git a/components/doordash/cart-card.tsx b/components/doordash/cart-card.tsx index 12177fd..0ee9526 100644 --- a/components/doordash/cart-card.tsx +++ b/components/doordash/cart-card.tsx @@ -1,7 +1,7 @@ "use client"; import { ShoppingBag } from "lucide-react"; -import { useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { Shimmer } from "@/components/ai-elements/shimmer"; import { CartLine } from "@/components/doordash/cart-line"; import { ZoomableImage } from "@/components/doordash/zoomable-image"; @@ -43,6 +43,14 @@ async function execOp( return response.json(); } +export type DurableCart = Partial & { status?: string }; + +async function fetchSnapshot(cartUuid: string): Promise { + const response = await fetch(`/api/doordash/carts/${cartUuid}`); + if (!response.ok) return null; + return response.json(); +} + const TIP_PRESETS = [10, 15, 20]; function dollars(cents: number): string { @@ -51,18 +59,53 @@ function dollars(cents: number): string { // The cart is an instrument: live truth rendered as a card, steered by // touch without the model, and every change lands where the agent reads -// it back. exec is injectable so stories run on fixtures. +// it back. exec and hydrate are injectable so stories run on fixtures. export function CartCard({ data, exec = execOp, + hydrate = fetchSnapshot, }: { data: CartCardData; exec?: typeof execOp; + hydrate?: typeof fetchSnapshot; }) { const [cart, setCart] = useState(data); + const [status, setStatus] = useState("open"); const [busy, setBusy] = useState(false); const [failed, setFailed] = useState(null); + // The freshest settled truth: hydration or a completed edit. A failed + // edit rolls back here, never to the frozen transcript prop. + const lastGood = useRef(data); + // The transcript froze this card's numbers at tool time; the cart + // kept living. Adopt the durable snapshot so a reload, or an older + // copy of the same cart, converges on the truth. + useEffect(() => { + let alive = true; + hydrate(data.cartUuid).then((durable) => { + if (!alive || !durable) return; + setStatus(durable.status ?? "open"); + setCart((current) => { + const next = { + ...current, + ...(durable.items ? { items: durable.items } : {}), + ...(durable.quote !== undefined ? { quote: durable.quote } : {}), + ...(durable.tipCents !== undefined + ? { tipCents: durable.tipCents } + : {}), + ...(durable.fulfillment ? { fulfillment: durable.fulfillment } : {}), + }; + lastGood.current = next; + return next; + }); + }); + return () => { + alive = false; + }; + }, [data.cartUuid, hydrate]); + + const sealed = status === "placing" || status === "placed"; + const frozen = busy || sealed; const subtotal = cart.quote?.ladder.find( (line) => line.chargeId === "SUBTOTAL", )?.cents; @@ -74,17 +117,21 @@ export function CartCard({ try { const result = await exec(cart.cartUuid, op); if (result.cart) { - setCart((current) => ({ - ...current, - ...(result.cart as Partial), - storeName: - (result.cart as Partial).storeName ?? - current.storeName, - })); + setCart((current) => { + const next = { + ...current, + ...(result.cart as Partial), + storeName: + (result.cart as Partial).storeName ?? + current.storeName, + }; + lastGood.current = next; + return next; + }); } } catch { setFailed("that edit did not go through; prices unchanged"); - setCart(data); + setCart(lastGood.current); } finally { setBusy(false); } @@ -116,9 +163,13 @@ export function CartCard({ {cart.storeName}

- {cart.quote?.asapAvailable - ? (cart.quote?.etaRange ?? "") - : "closed right now"} + {sealed + ? status === "placed" + ? "ordered" + : "placing the order" + : cart.quote?.asapAvailable + ? (cart.quote?.etaRange ?? "") + : "closed right now"}

@@ -126,7 +177,7 @@ export function CartCard({