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
21 changes: 16 additions & 5 deletions src/app/api/chat/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,8 @@ import {
type UIMessage,
} from "ai";

import { publishedGameKey } from "~/lib/sandbox-paths";
import { isValidThreadId } from "~/lib/thread-id";
import { recordGameVersion } from "~/server/db/games";
import { publicObjectUrl } from "~/server/r2";
import { prepareLessonSandbox } from "~/server/sandbox/prepare";
import { createLessonAgent } from "~/mastra/agents/lesson-agent";
import {
Expand Down Expand Up @@ -76,15 +74,28 @@ export async function POST(req: Request) {
// inside a tool, if the model ever calls one.
const trace = { id: traceId, log };
const sandboxPromise = prepareLessonSandbox({ threadId, userId, trace });
// Handed to the tools for when the container dies between the lifecycle
// check and a command (auto-stop/archive racing the request). A full
// re-prepare rather than a bare restart, because the R2 mount does not
// survive the container; prepareLessonSandbox is idempotent.
const recoverSandbox = () => {
log("sandbox.recover", { threadId });
return prepareLessonSandbox({ threadId, userId, trace });
};

const agent = await createLessonAgent({
threadId,
userId,
model,
sandboxPromise,
// Stable for the life of the thread: every publish overwrites this one key,
// so the teacher's link never changes.
publishedUrl: publicObjectUrl(publishedGameKey(userId, threadId)),
recoverSandbox,
// Stable for the life of the thread: /play proxies current/index.html,
// the one key every publish overwrites, so the teacher's link never
// changes. The app route rather than the bucket URL on purpose — the raw
// key embeds the Clerk user id, and this URL is what the model pastes
// into chat. Origin comes from the request so dev and preview deploys
// hand out links on their own host.
publishedUrl: new URL(`/play/${threadId}`, req.url).toString(),
// Bound to this thread and user here, so the tool cannot record a version
// against anyone else's game — the model never supplies either id.
recordVersion: ({ version, label }) =>
Expand Down
50 changes: 50 additions & 0 deletions src/app/play/[threadId]/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import type { NextRequest } from "next/server";

import { publishedGameKey } from "~/lib/sandbox-paths";
import { isValidThreadId } from "~/lib/thread-id";
import { latestGameVersionByThread } from "~/server/db/games";
import { publicObjectUrl } from "~/server/r2";

/**
* The teacher's share link: `/play/<threadId>`.
*
* The published game physically lives at
* `games/<userId>/<threadId>/current/index.html` in the bucket — a path that
* bakes the owner's Clerk user id into every URL. Handing that URL out leaks
* the id to whole classrooms, so this route is the one the app shares instead:
* it resolves the owner from the publish index and streams the game through,
* keeping the bucket layout (and the user id) server-side.
*
* Public on purpose (see `middleware.ts`): a share link is opened by students
* who hold no session. The thread id is the only capability, same as the
* bucket's unguessable-path model, and it serves nothing but the built game.
*/
export async function GET(
_req: NextRequest,
{ params }: { params: Promise<{ threadId: string }> },
) {
const { threadId } = await params;
if (!isValidThreadId(threadId)) return new Response(null, { status: 404 });

const row = await latestGameVersionByThread(threadId);
if (!row) return new Response(null, { status: 404 });

// `current/index.html`, not the versioned key — the share link must keep
// tracking the newest publish, exactly like the raw current/ URL did.
const url = publicObjectUrl(publishedGameKey(row.userId, threadId));
if (!url) return new Response(null, { status: 503 });

const upstream = await fetch(url, { cache: "no-store" });
if (!upstream.ok || !upstream.body) return new Response(null, { status: 404 });

return new Response(upstream.body, {
headers: {
"Content-Type": "text/html; charset=utf-8",
// A class of thirty opening the link at once should hit the CDN, but a
// republish should show up within a minute — current/ is mutable.
"Cache-Control": "public, max-age=0, s-maxage=60",
// Share links are for people who hold them, not search results.
"X-Robots-Tag": "noindex",
},
});
}
22 changes: 14 additions & 8 deletions src/components/chat/chat-header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,11 @@ import { api } from "~/trpc/react";
/**
* Thread title, plus the teacher's share link once a game exists.
*
* The link is `current/index.html` — the one key every publish overwrites — so
* it keeps working as the game is revised and can be handed to a class once.
* The preview pane deliberately uses the versioned URL instead; see
* The link is `/play/<threadId>` — the app route that proxies
* `current/index.html`, the one key every publish overwrites — so it keeps
* working as the game is revised, can be handed to a class once, and never
* exposes the bucket path (which embeds the owner's Clerk user id). The
* preview pane deliberately uses the versioned bucket URL instead; see
* `GamePreview`.
*/
export function ChatHeader({
Expand All @@ -23,12 +25,16 @@ export function ChatHeader({
}) {
const latest = api.games.latest.useQuery({ threadId });
const [copied, setCopied] = useState(false);
const shareUrl = latest.data?.shareUrl ?? null;
const sharePath = latest.data?.sharePath ?? null;

const copy = async () => {
if (!shareUrl) return;
if (!sharePath) return;
try {
await navigator.clipboard.writeText(shareUrl);
// Absolute at copy time — the clipboard leaves this origin, the href
// below doesn't have to.
await navigator.clipboard.writeText(
new URL(sharePath, window.location.origin).toString(),
);
setCopied(true);
setTimeout(() => setCopied(false), 1500);
} catch {
Expand All @@ -45,7 +51,7 @@ export function ChatHeader({
{title}
</span>
</div>
{shareUrl ? (
{sharePath ? (
<div className="flex shrink-0 items-center gap-1 px-3">
<span className="text-muted-foreground hidden text-[11px] lg:inline">
v{latest.data?.version}
Expand All @@ -54,7 +60,7 @@ export function ChatHeader({
{copied ? "Copied" : "Copy link"}
</Button>
<Button asChild size="sm" variant="ghost">
<a href={shareUrl} rel="noopener noreferrer" target="_blank">
<a href={sharePath} rel="noopener noreferrer" target="_blank">
Open
</a>
</Button>
Expand Down
7 changes: 7 additions & 0 deletions src/mastra/agents/lesson-agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,12 @@ export type CreateLessonAgentOptions = {
* tools and stays a pure planner — it keeps the skill tools.
*/
sandboxPromise?: Promise<Sandbox>;
/**
* Re-runs `prepareLessonSandbox` when the sandbox container dies mid-
* request (auto-stop/archive racing a tool call). Optional, like the
* sandbox itself.
*/
recoverSandbox?: () => Promise<Sandbox>;
/**
* Public URL this thread's published game will live at. Resolved by the
* route because it needs `~/env`; passed down so neither this factory nor
Expand Down Expand Up @@ -109,6 +115,7 @@ export async function createLessonAgent(opts: CreateLessonAgentOptions) {
tools: opts.sandboxPromise
? createSandboxTools({
sandboxPromise: opts.sandboxPromise,
recoverSandbox: opts.recoverSandbox,
publishedUrl: opts.publishedUrl,
recordVersion: opts.recordVersion,
trace: opts.trace,
Expand Down
67 changes: 38 additions & 29 deletions src/mastra/tools/bash-tool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { z } from "zod";

import { ENGINE_ROOT, GAME_ROOT } from "~/lib/sandbox-paths";
import { runCommand } from "~/server/sandbox/exec";
import { withRecoveredSandbox } from "~/server/sandbox/lifecycle";
import type { LessonTrace } from "~/mastra/agents/lesson-shared";

/** Beyond this the output goes to a file and the model gets a pointer. */
Expand All @@ -18,6 +19,8 @@ export type CreateBashToolOptions = {
* still booting/mounting; the first `bash` call pays whatever is left.
*/
sandboxPromise: Promise<Sandbox>;
/** Re-runs the full sandbox preparation if the container died. See lifecycle.ts. */
recoverSandbox?: () => Promise<Sandbox>;
/** Extra env for every command (on top of the sandbox's own envVars). */
env?: Record<string, string>;
trace?: LessonTrace;
Expand All @@ -39,6 +42,7 @@ async function spillToFile(sandbox: Sandbox, output: string): Promise<string> {
*/
export function createBashTool({
sandboxPromise,
recoverSandbox,
env,
trace,
}: CreateBashToolOptions) {
Expand Down Expand Up @@ -92,38 +96,43 @@ export function createBashTool({

try {
// The only await on the sandbox — by now it is usually already warm.
const sandbox = await sandboxPromise;
const res = await runCommand(sandbox, command, {
cwd: GAME_ROOT,
env,
timeoutSeconds,
});
return await withRecoveredSandbox(
sandboxPromise,
recoverSandbox,
async (sandbox) => {
const res = await runCommand(sandbox, command, {
cwd: GAME_ROOT,
env,
timeoutSeconds,
});

const truncated = res.stdout.length > MAX_OUTPUT_CHARS;
const outputPath = truncated
? await spillToFile(sandbox, res.stdout)
: undefined;
const durationMs = Math.round(performance.now() - startedAt);
const truncated = res.stdout.length > MAX_OUTPUT_CHARS;
const outputPath = truncated
? await spillToFile(sandbox, res.stdout)
: undefined;
const durationMs = Math.round(performance.now() - startedAt);

trace?.log("tool.bash.end", {
command,
exitCode: res.exitCode,
outputChars: res.stdout.length,
truncated,
outputPath,
durationMs,
});
trace?.log("tool.bash.end", {
command,
exitCode: res.exitCode,
outputChars: res.stdout.length,
truncated,
outputPath,
durationMs,
});

return {
output: truncated
? `${res.stdout.slice(0, MAX_OUTPUT_CHARS)}\n\n[truncated: ${res.stdout.length} chars total; full output at ${outputPath}]`
: res.stdout,
exitCode: res.exitCode,
durationMs,
truncated,
outputChars: res.stdout.length,
outputPath,
};
return {
output: truncated
? `${res.stdout.slice(0, MAX_OUTPUT_CHARS)}\n\n[truncated: ${res.stdout.length} chars total; full output at ${outputPath}]`
: res.stdout,
exitCode: res.exitCode,
durationMs,
truncated,
outputChars: res.stdout.length,
outputPath,
};
},
);
} catch (err) {
// Sandbox never came up, or the SDK call itself failed. Hand the model
// a readable failure instead of killing the step.
Expand Down
74 changes: 41 additions & 33 deletions src/mastra/tools/edit-file-tool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,12 @@ import { z } from "zod";

import { expandSandboxPath } from "~/lib/sandbox-paths";
import type { LessonTrace } from "~/mastra/agents/lesson-shared";
import { withRecoveredSandbox } from "~/server/sandbox/lifecycle";

export type CreateEditFileToolOptions = {
sandboxPromise: Promise<Sandbox>;
/** Re-runs the full sandbox preparation if the container died. See lifecycle.ts. */
recoverSandbox?: () => Promise<Sandbox>;
trace?: LessonTrace;
};

Expand All @@ -23,6 +26,7 @@ function errorMessage(err: unknown): string {
*/
export function createEditFileTool({
sandboxPromise,
recoverSandbox,
trace,
}: CreateEditFileToolOptions) {
return createTool({
Expand Down Expand Up @@ -59,43 +63,47 @@ export function createEditFileTool({
}

try {
const sandbox = await sandboxPromise;
return await withRecoveredSandbox(
sandboxPromise,
recoverSandbox,
async (sandbox) => {
const details = await sandbox.fs.getFileDetails(path).catch(() => null);
if (!details || details.isDir) {
return {
ok: false,
path,
error: `No file at "${input}". Check the path, or use write to create it.`,
};
}

const details = await sandbox.fs.getFileDetails(path).catch(() => null);
if (!details || details.isDir) {
return {
ok: false,
path,
error: `No file at "${input}". Check the path, or use write to create it.`,
};
}
const content = (await sandbox.fs.downloadFile(path)).toString("utf-8");
const occurrences = content.split(old_string).length - 1;

const content = (await sandbox.fs.downloadFile(path)).toString("utf-8");
const occurrences = content.split(old_string).length - 1;
if (occurrences === 0) {
return {
ok: false,
path,
error: `old_string was not found in "${input}". Read the file and copy the exact text — including indentation — you want to replace.`,
};
}
if (occurrences > 1 && !replace_all) {
return {
ok: false,
path,
error: `old_string matches ${occurrences} times in "${input}", so the edit is ambiguous. Add surrounding context to make it unique, or set replace_all=true.`,
};
}

if (occurrences === 0) {
return {
ok: false,
path,
error: `old_string was not found in "${input}". Read the file and copy the exact text — including indentation — you want to replace.`,
};
}
if (occurrences > 1 && !replace_all) {
return {
ok: false,
path,
error: `old_string matches ${occurrences} times in "${input}", so the edit is ambiguous. Add surrounding context to make it unique, or set replace_all=true.`,
};
}
const updated = replace_all
? content.split(old_string).join(new_string)
: content.replace(old_string, new_string);
await sandbox.fs.uploadFile(Buffer.from(updated, "utf-8"), path);

const updated = replace_all
? content.split(old_string).join(new_string)
: content.replace(old_string, new_string);
await sandbox.fs.uploadFile(Buffer.from(updated, "utf-8"), path);

const replacements = replace_all ? occurrences : 1;
trace?.log("tool.edit", { path, replacements });
return { ok: true, path, replacements };
const replacements = replace_all ? occurrences : 1;
trace?.log("tool.edit", { path, replacements });
return { ok: true, path, replacements };
},
);
} catch (err) {
const message = errorMessage(err);
trace?.log("tool.edit.error", { path, error: message });
Expand Down
6 changes: 6 additions & 0 deletions src/mastra/tools/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,12 @@ import { createWriteFileTool } from "./write-file-tool";
export type SandboxToolOptions = {
/** Un-awaited on purpose — see `~/server/sandbox/prepare`. */
sandboxPromise: Promise<Sandbox>;
/**
* Re-runs the full sandbox preparation (start + R2 mount + hydrate) when
* the container dies under a tool call — auto-stop or archive racing a
* request. Supplied by the route; omitted in tests.
*/
recoverSandbox?: () => Promise<Sandbox>;
/**
* Where this thread's published game will be readable. Computed by the
* caller (it needs env) and handed down, so nothing under `tools/` has to
Expand Down
Loading
Loading