Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <change>`, 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
Expand Down
27 changes: 19 additions & 8 deletions app/api/doordash/approvals/[id]/cancel/route.ts
Original file line number Diff line number Diff line change
@@ -1,34 +1,45 @@
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;
const approvalId = Number(id);
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 });
}
7 changes: 6 additions & 1 deletion app/api/doordash/approvals/[id]/route.ts
Original file line number Diff line number Diff line change
@@ -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"
Expand All @@ -12,14 +13,18 @@ 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;
const approvalId = Number(id);
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,
Expand Down
45 changes: 36 additions & 9 deletions app/api/doordash/approvals/[id]/verify/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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) {
Expand All @@ -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 {
Comment on lines +53 to +61

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reuse one wake identity across retries

If a session send commits but its response is lost, the call throws and this loop invokes sendSessionText again. That wrapper creates a fresh crypto.randomUUID() for every invocation, so the retries are distinct durable messages rather than idempotent attempts and can wake multiple agent turns for one verification, producing duplicate submission outcomes and transcript entries even though approval consumption prevents a second charge. Reuse a single message identity across all three attempts.

Useful? React with 👍 / 👎.

await new Promise((resolve) =>
setTimeout(resolve, 300 * (attempt + 1)),
);
}
}
}
return Response.json({ ok: true });
return Response.json({ ok: true, woke });
}
10 changes: 10 additions & 0 deletions app/api/doordash/carts/[uuid]/ops/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
Comment on lines +74 to 81

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Do not report failure when cancellation fails

When the Trigger cancellation request rejects, this catch discards the only evidence that cancellation did not happen and still returns a definitive 504. The worker may remain queued or running and apply the edit after the card has rolled back and told the user it failed, leaving the displayed cart inconsistent with durable state. Propagate an uncertain result or keep polling and verify the final snapshot before reporting failure.

AGENTS.md reference: AGENTS.md:L15-L18

Useful? React with 👍 / 👎.

}
25 changes: 25 additions & 0 deletions app/api/doordash/carts/[uuid]/route.ts
Original file line number Diff line number Diff line change
@@ -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,
});
}
21 changes: 14 additions & 7 deletions components/doordash/approval-card.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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) => (
Expand All @@ -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();
Expand All @@ -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,
Expand All @@ -75,15 +80,17 @@ 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();
},
};

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.
Expand Down
40 changes: 29 additions & 11 deletions components/doordash/approval-card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { Input } from "@/components/ui/input";

type ApprovalOutput = {
approvalId?: number;
token?: string;
expiresAt?: string;
totalCents?: number;
storeName?: string;
Expand All @@ -19,30 +20,36 @@ type ApprovalOutput = {

type VerifyResponse = {
ok?: boolean;
woke?: boolean;
reason?: string;
attemptsLeft?: number;
};

async function postCode(
approvalId: number,
code: string,
token: string,
): Promise<VerifyResponse> {
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<void> {
async function postCancel(approvalId: number, token: string): Promise<void> {
await fetch(`/api/doordash/approvals/${approvalId}/cancel`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ token }),
});
}

async function getStatus(approvalId: number): Promise<string> {
const response = await fetch(`/api/doordash/approvals/${approvalId}`);
async function getStatus(approvalId: number, token: string): Promise<string> {
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";
Expand Down Expand Up @@ -90,12 +97,13 @@ export function ApprovalCard({
const [seconds, setSeconds] = useState<number | null>(null);

const approvalId = output?.approvalId;
const token = output?.token ?? "";
Comment on lines 99 to +100

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve approval cards created before deployment

Persisted tool outputs created before this commit contain an approvalId but no token, so this fallback sends an empty capability to the newly mandatory status, verify, and cancel routes. The status client maps the resulting 403 to voided, which prevents still-live pre-deployment codes from being entered or cancelled and makes historical verified or consumed cards claim they are no longer live. Add versioned handling or an authenticated capability-upgrade path for legacy outputs.

AGENTS.md reference: AGENTS.md:L15-L18

Useful? React with 👍 / 👎.

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");
Expand All @@ -108,7 +116,7 @@ export function ApprovalCard({
return () => {
alive = false;
};
}, [approvalId, fetchStatus]);
}, [approvalId, token, fetchStatus]);

useEffect(() => {
if (phase !== "waiting" || !expiresAt) return;
Expand All @@ -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("");
Expand Down Expand Up @@ -173,9 +184,16 @@ export function ApprovalCard({
</p>
) : approvalId ? (
phase === "approved" ? (
<p className="font-mono text-xs text-accent-text">
approved; rendi is placing the order
</p>
<div className="space-y-1">
<p className="font-mono text-xs text-accent-text">
approved; rendi is placing the order
</p>
{note ? (
<p className="font-mono text-xs text-muted-foreground">
{note}
</p>
) : null}
</div>
) : phase === "cancelled" ? (
<p className="font-mono text-xs text-muted-foreground">
approval cancelled; the cart is open again
Expand Down Expand Up @@ -218,7 +236,7 @@ export function ApprovalCard({
variant="ghost"
disabled={phase === "checking"}
onClick={async () => {
await cancel(approvalId);
await cancel(approvalId, token);
setPhase("cancelled");
}}
>
Expand Down
Loading