Repository navigation
Harden the approval machine #2
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
bfe256a
bd6e047
380fec5
b5ad606
9f535bb
b3a9bda
414845c
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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 }); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
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 👍 / 👎. |
||
| } | ||
| 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, | ||
| }); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -10,6 +10,7 @@ import { Input } from "@/components/ui/input"; | |
|
|
||
| type ApprovalOutput = { | ||
| approvalId?: number; | ||
| token?: string; | ||
| expiresAt?: string; | ||
| totalCents?: number; | ||
| storeName?: string; | ||
|
|
@@ -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"; | ||
|
|
@@ -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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Persisted tool outputs created before this commit contain an 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"); | ||
|
|
@@ -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({ | |
| </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 | ||
|
|
@@ -218,7 +236,7 @@ export function ApprovalCard({ | |
| variant="ghost" | ||
| disabled={phase === "checking"} | ||
| onClick={async () => { | ||
| await cancel(approvalId); | ||
| await cancel(approvalId, token); | ||
| setPhase("cancelled"); | ||
| }} | ||
| > | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If a session send commits but its response is lost, the call throws and this loop invokes
sendSessionTextagain. That wrapper creates a freshcrypto.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 👍 / 👎.