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({