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
17 changes: 17 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -50,3 +50,20 @@ CLICKSTACK_OTLP_URL=
# secret behind the /gate page. Leave both unset to run without a gate.
ACCESS_CODES=
ACCESS_TOKEN_SECRET=

# DoorDash ordering (optional). Requires the official dd-cli on the worker
# and a personal account; docs/DEPLOYMENT.md covers the whole ritual.
# Approval codes are emailed here and nowhere else; whoever owns this inbox
# owns every order.
RENDI_OWNER_EMAIL=
# Local dev signs in with `dd-cli login` (keychain). Deployed workers are
# headless and need an exported token instead: `dd-cli export-token`.
DD_CLI_ACCESS_TOKEN=
# Spend caps, enforced below the agent. Defaults: $100 per order, 3 orders
# per day, 15 approval requests per day.
DD_MAX_ORDER_CENTS=
DD_MAX_ORDERS_PER_DAY=
DD_MAX_APPROVALS_PER_DAY=
# Set to 1 when strangers hold gate codes: hides saved addresses, payment
# methods, and order history from the agent entirely.
DD_HIDE_PERSONAL=
11 changes: 11 additions & 0 deletions .env.trigger.example
Original file line number Diff line number Diff line change
Expand Up @@ -22,3 +22,14 @@ RENDI_MODEL=claude-opus-4-8
RENDI_APP_URL=

GITHUB_TOKEN=

# DoorDash ordering (optional; leave unset to run without it). Deployed
# workers are headless: mint the token with `dd-cli export-token` on a
# machine where you are signed in. Approval codes are emailed to the owner
# address and nowhere else.
DD_CLI_ACCESS_TOKEN=
RENDI_OWNER_EMAIL=
DD_MAX_ORDER_CENTS=
DD_MAX_ORDERS_PER_DAY=
DD_MAX_APPROVALS_PER_DAY=
DD_HIDE_PERSONAL=
7 changes: 5 additions & 2 deletions app/(app)/c/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ export default async function ConversationPage({
const [transcript, canvas] = conversation
? await Promise.all([getTranscript(id), loadCanvas(id)])
: [[], undefined];
const archived = conversation ? isArchived(conversation.triggerEnv) : false;
return (
<>
<h1 className="sr-only">{conversation?.title ?? "New conversation"}</h1>
Expand All @@ -31,9 +32,11 @@ export default async function ConversationPage({
chatId={id}
initialMessages={transcript}
initialCanvas={canvas ?? null}
archived={conversation ? isArchived(conversation.createdAt) : false}
archived={archived}
session={
conversation?.publicAccessToken
// A gallery never attaches to its session: the other
// environment's token cannot resume here, only 401.
conversation?.publicAccessToken && !archived
? {
publicAccessToken: conversation.publicAccessToken,
lastEventId: conversation.lastEventId ?? undefined,
Expand Down
34 changes: 34 additions & 0 deletions app/api/doordash/approvals/[id]/cancel/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { eq } from "drizzle-orm";
import { getDb } from "@/lib/db/index";
import { ddApprovals } from "@/lib/db/schema";
import { 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.
export async function POST(
_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 [row] = await getDb()
.select({
cartUuid: ddApprovals.cartUuid,
consumedAt: ddApprovals.consumedAt,
})
.from(ddApprovals)
.where(eq(ddApprovals.id, approvalId));
Comment on lines +24 to +25

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 Protect cancellation with an unguessable capability

In the documented shared-gate deployment, every visitor has the same middleware authorization, while approval IDs are sequential integers and this route cancels solely by that ID. Any gate holder can enumerate IDs and void another conversation's live approval without possessing its code or cart UUID. Put an unguessable cancellation capability in the card output and require it here, or bind cancellation to an equivalent per-conversation authorization.

Useful? React with 👍 / 👎.

if (!row)
return Response.json({ error: "no such approval" }, { status: 404 });
if (row.consumedAt) {
return Response.json({ error: "already used" }, { status: 409 });
}
await voidApproval(approvalId);
await setCartStatus(row.cartUuid, "open");
Comment on lines +31 to +32

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 Make cancellation and cart reopening atomic

If submission consumes the approval after this initial read but before voidApproval, the conditional void becomes a no-op, yet the route still reports success and reopens the cart. That can expose an editable “open” cart while the approved order is already being validated or placed. Make the conditional void return whether it updated a row and reopen only on success, within the same transaction.

Useful? React with 👍 / 👎.

return Response.json({ ok: true });
}
44 changes: 44 additions & 0 deletions app/api/doordash/approvals/[id]/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { eq } from "drizzle-orm";
import { getDb } from "@/lib/db/index";
import { ddApprovals } from "@/lib/db/schema";

export type ApprovalStatus =
| "waiting"
| "verified"
| "consumed"
| "voided"
| "expired";

// 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,
{ 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 [row] = await getDb()
.select({
consumedAt: ddApprovals.consumedAt,
voidedAt: ddApprovals.voidedAt,
verifiedAt: ddApprovals.verifiedAt,
expiresAt: ddApprovals.expiresAt,
})
.from(ddApprovals)
.where(eq(ddApprovals.id, approvalId));
if (!row)
return Response.json({ error: "no such approval" }, { status: 404 });
const status: ApprovalStatus = row.consumedAt
? "consumed"
: row.voidedAt
? "voided"
: row.verifiedAt
? "verified"
: row.expiresAt.getTime() < Date.now()
? "expired"
: "waiting";
return Response.json({ status, expiresAt: row.expiresAt.toISOString() });
}
42 changes: 42 additions & 0 deletions app/api/doordash/approvals/[id]/verify/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
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 { 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.

const bodySchema = z.object({ code: z.string().min(1).max(12) });

export async function POST(
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) {
return Response.json({ error: "bad code" }, { status: 400 });
}
const result = await verifyApproval(approvalId, parsed.data.code);
if (!result.ok) {
return Response.json(result, { status: 400 });
}
const [row] = await getDb()
.select({ conversationId: ddApprovals.conversationId })
.from(ddApprovals)
.where(eq(ddApprovals.id, approvalId));
if (row) {
await sendSessionText(
row.conversationId,
`[order approved, approval ${approvalId}] The owner entered the code. Place the order with doordash-submit.`,
);
Comment on lines +36 to +39

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Make approval wake-up delivery durable

The code is marked verified before this external session send. If Trigger is temporarily unavailable, the route returns an error but the durable row remains verified; on reload the card sees verified and removes the input while claiming the order is being placed, and no outbox or retry ever wakes the agent. Persist a pending wake-up and retry it, or otherwise couple verification to durable delivery.

AGENTS.md reference: AGENTS.md:L17-L18

Useful? React with 👍 / 👎.

}
return Response.json({ ok: true });
}
72 changes: 72 additions & 0 deletions app/api/doordash/carts/[uuid]/ops/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import { runs, tasks } from "@trigger.dev/sdk";
import { z } from "zod";
import { getCartSnapshot } from "@/lib/rendi/doordash-db";
import type { ddCartExec } from "@/trigger/dd-cart-exec";

// The card's touch edits. The dd-cli binary lives in the worker, so the
// route triggers the exec task and waits the run out; the fresh snapshot
// comes back for the card to adopt. Actor is the user by construction:
// this route only ever runs from the browser's hand.

const opSchema = z.discriminatedUnion("kind", [
z.object({
kind: z.literal("set-quantity"),
lineId: z.string(),
quantity: z.number().int().min(0).max(50),
}),
z.object({ kind: z.literal("remove-line"), lineId: z.string() }),
z.object({
kind: z.literal("set-tip"),
tipCents: z.number().int().min(0).max(50_000),
}),
z.object({
kind: z.literal("set-fulfillment"),
fulfillment: z.enum(["delivery", "pickup"]),
}),
z.object({ kind: z.literal("preview") }),
]);

const POLL_MS = 800;
const DEADLINE_MS = 60_000;

export async function POST(
request: Request,
{ params }: { params: Promise<{ uuid: string }> },
) {
const { uuid } = await params;
const snapshot = await getCartSnapshot(uuid);
if (!snapshot) {
return Response.json({ error: "unknown cart" }, { status: 404 });
}
const parsed = opSchema.safeParse(await request.json());
if (!parsed.success) {
return Response.json({ error: "malformed op" }, { status: 400 });
}
const handle = await tasks.trigger<typeof ddCartExec>("rendi-dd-cart-exec", {
cartUuid: uuid,
conversationId: snapshot.conversationId,
op: parsed.data,
});
const startedAt = Date.now();
while (Date.now() - startedAt < DEADLINE_MS) {
const run = await runs.retrieve(handle.id);
if (run.status === "COMPLETED") {
const fresh = await getCartSnapshot(uuid);
return Response.json({ ok: true, cart: fresh ?? null });
Comment on lines +53 to +55

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Deliver browser cart edits to the next agent turn

After a browser edit completes, the fresh cart is returned only to that HTTP caller. The task writes no append-only operation or unseen marker, while trigger/chat.ts injects only instrument and canvas readback, so the next agent turn still sees the historical cart tool output and can discuss or approve a stale quantity, tip, or fulfillment choice. Persist cart edits and inject their live state on the next turn.

AGENTS.md reference: AGENTS.md:L17-L18

Useful? React with 👍 / 👎.

}
if (
run.status === "FAILED" ||
run.status === "CRASHED" ||
run.status === "CANCELED" ||
run.status === "SYSTEM_FAILURE" ||
run.status === "TIMED_OUT"
) {
return Response.json(
{ error: "the edit did not go through" },
{ status: 502 },
);
}
await new Promise((resolve) => setTimeout(resolve, POLL_MS));
}
return Response.json({ error: "edit timed out" }, { status: 504 });

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Stop timed-out cart tasks from mutating later

When this 60-second deadline expires, the route returns a definitive failure but leaves the Trigger task running for up to 120 seconds, including while it waits in the global single-concurrency queue. The task can therefore mutate DoorDash and persist the cart after the UI has reported that the edit failed, causing an unexpected quantity, tip, or fulfillment change. Cancel the run on timeout or return a durable pending state that the card continues to reconcile.

AGENTS.md reference: AGENTS.md:L17-L18

Useful? React with 👍 / 👎.

}
4 changes: 1 addition & 3 deletions components/chat/archive-notice.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,7 @@ type Story = StoryObj<typeof meta>;
export const Default: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await expect(
canvas.getByText(/archived conversation from Rendi/),
).toBeVisible();
await expect(canvas.getByText(/kept as a live gallery/)).toBeVisible();
await expect(
canvas.getByRole("link", { name: "New conversation" }),
).toHaveAttribute("href", "/");
Expand Down
5 changes: 2 additions & 3 deletions components/chat/archive-notice.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,10 @@ export function ArchiveNotice() {
/>
<div className="min-w-0">
<p className="text-sm">
An archived conversation from Rendi&rsquo;s build days, kept as a
gallery.
This conversation is kept as a live gallery.
</p>
<p className="mt-0.5 text-[13px] text-muted-foreground">
Everything renders live. To talk with Rendi, start a new
Everything still renders. To talk with Rendi, start a new
conversation.
</p>
</div>
Expand Down
87 changes: 87 additions & 0 deletions components/chat/transcript.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,10 @@ import { PulseCard } from "@/components/chat/pulse-card";
import { QueryDataCard } from "@/components/chat/query-data-card";
import { ScreenshotCard } from "@/components/chat/screenshot-card";
import { ShareLinkCard } from "@/components/chat/share-link-card";
import { ApprovalCard } from "@/components/doordash/approval-card";
import { CartToolCard } from "@/components/doordash/cart-tool-card";
import { DoorDashBrowseCard } from "@/components/doordash/doordash-browse-card";
import { OrderCard } from "@/components/doordash/order-card";
import { persistedInstrumentSpec } from "@/lib/rendi/instrument";

export function Transcript({
Expand Down Expand Up @@ -224,6 +228,89 @@ function Parts({
/>
);
}
if (part.type === "tool-doordash-cart") {
return (
<CartToolCard
key={key}
interrupted={interrupted}
state={part.state}
output={
part.state === "output-available"
? (part.output as Parameters<
typeof CartToolCard
>[0]["output"])
: undefined
}
errorText={
part.state === "output-error" ? part.errorText : undefined
}
/>
);
}
if (part.type === "tool-doordash-request-approval") {
return (
<ApprovalCard
key={key}
interrupted={interrupted}
state={part.state}
output={
part.state === "output-available"
? (part.output as Parameters<
typeof ApprovalCard
>[0]["output"])
: undefined
}
errorText={
part.state === "output-error" ? part.errorText : undefined
}
/>
);
}
if (part.type === "tool-doordash-submit") {
return (
<OrderCard
key={key}
interrupted={interrupted}
state={part.state}
output={
part.state === "output-available"
? (part.output as Parameters<typeof OrderCard>[0]["output"])
: undefined
}
errorText={
part.state === "output-error" ? part.errorText : undefined
}
/>
);
}
if (part.type === "tool-doordash-browse") {
return (
<DoorDashBrowseCard
key={key}
interrupted={interrupted}
state={part.state}
input={
part.state === "input-available" ||
part.state === "output-available" ||
part.state === "output-error"
? (part.input as Parameters<
typeof DoorDashBrowseCard
>[0]["input"])
: undefined
}
output={
part.state === "output-available"
? (part.output as Parameters<
typeof DoorDashBrowseCard
>[0]["output"])
: undefined
}
errorText={
part.state === "output-error" ? part.errorText : undefined
}
/>
);
}
if (part.type === "tool-sync-commits") {
return (
<CommitSyncCard
Expand Down
Loading