Remove frontend errors and app passthrough (#251)

This commit is contained in:
Nathan Flurry 2026-03-13 21:14:31 -07:00 committed by GitHub
parent d8b8b49f37
commit 8fb19b50da
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
23 changed files with 146 additions and 1119 deletions

View file

@ -50,6 +50,11 @@ Use `pnpm` workspaces and Turborepo.
- `compose.dev.yaml` loads `foundry/.env` (optional) for credentials needed by the backend (GitHub OAuth, Stripe, Daytona, API keys, etc.). - `compose.dev.yaml` loads `foundry/.env` (optional) for credentials needed by the backend (GitHub OAuth, Stripe, Daytona, API keys, etc.).
- The canonical source for these credentials is `~/misc/the-foundry.env`. If `foundry/.env` does not exist, copy it: `cp ~/misc/the-foundry.env foundry/.env` - The canonical source for these credentials is `~/misc/the-foundry.env`. If `foundry/.env` does not exist, copy it: `cp ~/misc/the-foundry.env foundry/.env`
- `foundry/.env` is gitignored and must never be committed. - `foundry/.env` is gitignored and must never be committed.
- If your changes affect the dev server, mock server, frontend runtime, backend runtime, Vite wiring, compose files, or other server-startup/runtime behavior, you must start or restart the relevant stack before finishing the task.
- Use the matching stack for verification:
- real backend + frontend changes: `just foundry-dev` or restart with `just foundry-dev-down && just foundry-dev`
- mock frontend changes: `just foundry-mock` or restart with `just foundry-mock-down && just foundry-mock`
- local frontend-only work outside Docker: restart `pnpm --filter @sandbox-agent/foundry-frontend dev` or `just foundry-dev-mock` as appropriate
- The backend does **not** hot reload. Bun's `--hot` flag causes the server to re-bind on a different port (e.g. 6421 instead of 6420), breaking all client connections while the container still exposes the original port. After backend code changes, restart the backend container: `just foundry-dev-down && just foundry-dev`. - The backend does **not** hot reload. Bun's `--hot` flag causes the server to re-bind on a different port (e.g. 6421 instead of 6420), breaking all client connections while the container still exposes the original port. After backend code changes, restart the backend container: `just foundry-dev-down && just foundry-dev`.
## Railway Logs ## Railway Logs

View file

@ -84,7 +84,6 @@ services:
# Use Linux-native workspace dependencies inside the container instead of host node_modules. # Use Linux-native workspace dependencies inside the container instead of host node_modules.
- "foundry_node_modules:/app/node_modules" - "foundry_node_modules:/app/node_modules"
- "foundry_client_node_modules:/app/foundry/packages/client/node_modules" - "foundry_client_node_modules:/app/foundry/packages/client/node_modules"
- "foundry_frontend_errors_node_modules:/app/foundry/packages/frontend-errors/node_modules"
- "foundry_frontend_node_modules:/app/foundry/packages/frontend/node_modules" - "foundry_frontend_node_modules:/app/foundry/packages/frontend/node_modules"
- "foundry_shared_node_modules:/app/foundry/packages/shared/node_modules" - "foundry_shared_node_modules:/app/foundry/packages/shared/node_modules"
- "foundry_pnpm_store:/tmp/.local/share/pnpm/store" - "foundry_pnpm_store:/tmp/.local/share/pnpm/store"
@ -100,7 +99,6 @@ volumes:
foundry_rivetkit_storage: {} foundry_rivetkit_storage: {}
foundry_node_modules: {} foundry_node_modules: {}
foundry_client_node_modules: {} foundry_client_node_modules: {}
foundry_frontend_errors_node_modules: {}
foundry_frontend_node_modules: {} foundry_frontend_node_modules: {}
foundry_shared_node_modules: {} foundry_shared_node_modules: {}
foundry_pnpm_store: {} foundry_pnpm_store: {}

View file

@ -18,7 +18,6 @@ services:
- "../../../task/rivet-checkout:/task/rivet-checkout:ro" - "../../../task/rivet-checkout:/task/rivet-checkout:ro"
- "mock_node_modules:/app/node_modules" - "mock_node_modules:/app/node_modules"
- "mock_client_node_modules:/app/foundry/packages/client/node_modules" - "mock_client_node_modules:/app/foundry/packages/client/node_modules"
- "mock_frontend_errors_node_modules:/app/foundry/packages/frontend-errors/node_modules"
- "mock_frontend_node_modules:/app/foundry/packages/frontend/node_modules" - "mock_frontend_node_modules:/app/foundry/packages/frontend/node_modules"
- "mock_shared_node_modules:/app/foundry/packages/shared/node_modules" - "mock_shared_node_modules:/app/foundry/packages/shared/node_modules"
- "mock_pnpm_store:/tmp/.local/share/pnpm/store" - "mock_pnpm_store:/tmp/.local/share/pnpm/store"
@ -26,7 +25,6 @@ services:
volumes: volumes:
mock_node_modules: {} mock_node_modules: {}
mock_client_node_modules: {} mock_client_node_modules: {}
mock_frontend_errors_node_modules: {}
mock_frontend_node_modules: {} mock_frontend_node_modules: {}
mock_shared_node_modules: {} mock_shared_node_modules: {}
mock_pnpm_store: {} mock_pnpm_store: {}

View file

@ -15,7 +15,6 @@ RUN pnpm --filter @sandbox-agent/cli-shared build
RUN SKIP_OPENAPI_GEN=1 pnpm --filter sandbox-agent build RUN SKIP_OPENAPI_GEN=1 pnpm --filter sandbox-agent build
RUN pnpm --filter @sandbox-agent/react build RUN pnpm --filter @sandbox-agent/react build
RUN pnpm --filter @sandbox-agent/foundry-client build RUN pnpm --filter @sandbox-agent/foundry-client build
RUN pnpm --filter @sandbox-agent/foundry-frontend-errors build
ENV FOUNDRY_FRONTEND_CLIENT_MODE=remote ENV FOUNDRY_FRONTEND_CLIENT_MODE=remote
RUN pnpm --filter @sandbox-agent/foundry-frontend build RUN pnpm --filter @sandbox-agent/foundry-frontend build

View file

@ -15,7 +15,6 @@ RUN pnpm --filter @sandbox-agent/cli-shared build
RUN SKIP_OPENAPI_GEN=1 pnpm --filter sandbox-agent build RUN SKIP_OPENAPI_GEN=1 pnpm --filter sandbox-agent build
RUN pnpm --filter @sandbox-agent/react build RUN pnpm --filter @sandbox-agent/react build
RUN pnpm --filter @sandbox-agent/foundry-client build RUN pnpm --filter @sandbox-agent/foundry-client build
RUN pnpm --filter @sandbox-agent/foundry-frontend-errors build
ENV FOUNDRY_FRONTEND_CLIENT_MODE=mock ENV FOUNDRY_FRONTEND_CLIENT_MODE=mock
RUN pnpm --filter @sandbox-agent/foundry-frontend build RUN pnpm --filter @sandbox-agent/foundry-frontend build

View file

@ -12,7 +12,6 @@ COPY rivet-checkout /workspace/rivet-checkout
RUN pnpm install --frozen-lockfile RUN pnpm install --frozen-lockfile
RUN pnpm --filter @sandbox-agent/foundry-shared build RUN pnpm --filter @sandbox-agent/foundry-shared build
RUN pnpm --filter @sandbox-agent/foundry-client build RUN pnpm --filter @sandbox-agent/foundry-client build
RUN pnpm --filter @sandbox-agent/foundry-frontend-errors build
RUN pnpm --filter @sandbox-agent/foundry-frontend build RUN pnpm --filter @sandbox-agent/foundry-frontend build
FROM nginx:1.27-alpine FROM nginx:1.27-alpine

View file

@ -9,7 +9,6 @@ import { createBackends, createNotificationService } from "./notifications/index
import { createDefaultDriver } from "./driver.js"; import { createDefaultDriver } from "./driver.js";
import { createProviderRegistry } from "./providers/index.js"; import { createProviderRegistry } from "./providers/index.js";
import { createClient } from "rivetkit/client"; import { createClient } from "rivetkit/client";
import type { FoundryBillingPlanId } from "@sandbox-agent/foundry-shared";
import { initBetterAuthService } from "./services/better-auth.js"; import { initBetterAuthService } from "./services/better-auth.js";
import { createDefaultAppShellServices } from "./services/app-shell-runtime.js"; import { createDefaultAppShellServices } from "./services/app-shell-runtime.js";
import { APP_SHELL_WORKSPACE_ID } from "./actors/workspace/app-shell.js"; import { APP_SHELL_WORKSPACE_ID } from "./actors/workspace/app-shell.js";
@ -205,23 +204,6 @@ export async function startBackend(options: BackendStartOptions = {}): Promise<v
} }
}; };
const appWorkspaceAction = async <T>(action: string, run: (workspace: any) => Promise<T>, context: AppWorkspaceLogContext = {}): Promise<T> => {
try {
return await run(await appWorkspace({ ...context, action }));
} catch (error) {
logger.error(
{
...context,
action,
errorMessage: error instanceof Error ? error.message : String(error),
errorStack: error instanceof Error ? error.stack : undefined,
},
"app_workspace_action_failed",
);
throw error;
}
};
const requestLogContext = (c: any, sessionId?: string): AppWorkspaceLogContext => ({ const requestLogContext = (c: any, sessionId?: string): AppWorkspaceLogContext => ({
...requestHeaderContext(c), ...requestHeaderContext(c),
method: c.req.method, method: c.req.method,
@ -235,30 +217,6 @@ export async function startBackend(options: BackendStartOptions = {}): Promise<v
return session?.session?.id ?? null; return session?.session?.id ?? null;
}; };
app.get("/v1/app/snapshot", async (c) => {
const sessionId = await resolveSessionId(c);
if (!sessionId) {
return c.json({
auth: { status: "signed_out", currentUserId: null },
activeOrganizationId: null,
onboarding: {
starterRepo: {
repoFullName: "rivet-dev/sandbox-agent",
repoUrl: "https://github.com/rivet-dev/sandbox-agent",
status: "pending",
starredAt: null,
skippedAt: null,
},
},
users: [],
organizations: [],
});
}
return c.json(
await appWorkspaceAction("getAppSnapshot", async (workspace) => await workspace.getAppSnapshot({ sessionId }), requestLogContext(c, sessionId)),
);
});
app.all("/v1/auth/*", async (c) => { app.all("/v1/auth/*", async (c) => {
return await betterAuth.auth.handler(c.req.raw); return await betterAuth.auth.handler(c.req.raw);
}); });
@ -289,126 +247,6 @@ export async function startBackend(options: BackendStartOptions = {}): Promise<v
}); });
}); });
app.post("/v1/app/onboarding/starter-repo/skip", async (c) => {
const sessionId = await resolveSessionId(c);
if (!sessionId) {
return c.text("Unauthorized", 401);
}
return c.json(
await appWorkspaceAction("skipAppStarterRepo", async (workspace) => await workspace.skipAppStarterRepo({ sessionId }), requestLogContext(c, sessionId)),
);
});
app.post("/v1/app/organizations/:organizationId/starter-repo/star", async (c) => {
const sessionId = await resolveSessionId(c);
if (!sessionId) {
return c.text("Unauthorized", 401);
}
return c.json(
await appWorkspaceAction(
"starAppStarterRepo",
async (workspace) =>
await workspace.starAppStarterRepo({
sessionId,
organizationId: c.req.param("organizationId"),
}),
requestLogContext(c, sessionId),
),
);
});
app.post("/v1/app/organizations/:organizationId/select", async (c) => {
const sessionId = await resolveSessionId(c);
if (!sessionId) {
return c.text("Unauthorized", 401);
}
return c.json(
await appWorkspaceAction(
"selectAppOrganization",
async (workspace) =>
await workspace.selectAppOrganization({
sessionId,
organizationId: c.req.param("organizationId"),
}),
requestLogContext(c, sessionId),
),
);
});
app.patch("/v1/app/organizations/:organizationId/profile", async (c) => {
const sessionId = await resolveSessionId(c);
if (!sessionId) {
return c.text("Unauthorized", 401);
}
const body = await c.req.json();
return c.json(
await appWorkspaceAction(
"updateAppOrganizationProfile",
async (workspace) =>
await workspace.updateAppOrganizationProfile({
sessionId,
organizationId: c.req.param("organizationId"),
displayName: typeof body?.displayName === "string" ? body.displayName : "",
slug: typeof body?.slug === "string" ? body.slug : "",
primaryDomain: typeof body?.primaryDomain === "string" ? body.primaryDomain : "",
}),
requestLogContext(c, sessionId),
),
);
});
app.post("/v1/app/organizations/:organizationId/import", async (c) => {
const sessionId = await resolveSessionId(c);
if (!sessionId) {
return c.text("Unauthorized", 401);
}
return c.json(
await appWorkspaceAction(
"triggerAppRepoImport",
async (workspace) =>
await workspace.triggerAppRepoImport({
sessionId,
organizationId: c.req.param("organizationId"),
}),
requestLogContext(c, sessionId),
),
);
});
app.post("/v1/app/organizations/:organizationId/reconnect", async (c) => {
const sessionId = await resolveSessionId(c);
if (!sessionId) {
return c.text("Unauthorized", 401);
}
return c.json(
await appWorkspaceAction(
"beginAppGithubInstall",
async (workspace) =>
await workspace.beginAppGithubInstall({
sessionId,
organizationId: c.req.param("organizationId"),
}),
requestLogContext(c, sessionId),
),
);
});
app.post("/v1/app/organizations/:organizationId/billing/checkout", async (c) => {
const sessionId = await resolveSessionId(c);
if (!sessionId) {
return c.text("Unauthorized", 401);
}
const body = await c.req.json().catch(() => ({}));
const planId = body?.planId === "free" || body?.planId === "team" ? (body.planId as FoundryBillingPlanId) : "team";
return c.json(
await (await appWorkspace(requestLogContext(c, sessionId))).createAppCheckoutSession({
sessionId,
organizationId: c.req.param("organizationId"),
planId,
}),
);
});
app.get("/v1/billing/checkout/complete", async (c) => { app.get("/v1/billing/checkout/complete", async (c) => {
const organizationId = c.req.query("organizationId"); const organizationId = c.req.query("organizationId");
const checkoutSessionId = c.req.query("session_id"); const checkoutSessionId = c.req.query("session_id");
@ -427,58 +265,6 @@ export async function startBackend(options: BackendStartOptions = {}): Promise<v
return Response.redirect(result.redirectTo, 302); return Response.redirect(result.redirectTo, 302);
}); });
app.post("/v1/app/organizations/:organizationId/billing/portal", async (c) => {
const sessionId = await resolveSessionId(c);
if (!sessionId) {
return c.text("Unauthorized", 401);
}
return c.json(
await (await appWorkspace(requestLogContext(c, sessionId))).createAppBillingPortalSession({
sessionId,
organizationId: c.req.param("organizationId"),
}),
);
});
app.post("/v1/app/organizations/:organizationId/billing/cancel", async (c) => {
const sessionId = await resolveSessionId(c);
if (!sessionId) {
return c.text("Unauthorized", 401);
}
return c.json(
await (await appWorkspace(requestLogContext(c, sessionId))).cancelAppScheduledRenewal({
sessionId,
organizationId: c.req.param("organizationId"),
}),
);
});
app.post("/v1/app/organizations/:organizationId/billing/resume", async (c) => {
const sessionId = await resolveSessionId(c);
if (!sessionId) {
return c.text("Unauthorized", 401);
}
return c.json(
await (await appWorkspace(requestLogContext(c, sessionId))).resumeAppSubscription({
sessionId,
organizationId: c.req.param("organizationId"),
}),
);
});
app.post("/v1/app/workspaces/:workspaceId/seat-usage", async (c) => {
const sessionId = await resolveSessionId(c);
if (!sessionId) {
return c.text("Unauthorized", 401);
}
return c.json(
await (await appWorkspace(requestLogContext(c, sessionId))).recordAppSeatUsage({
sessionId,
workspaceId: c.req.param("workspaceId"),
}),
);
});
const handleStripeWebhook = async (c: any) => { const handleStripeWebhook = async (c: any) => {
const payload = await c.req.text(); const payload = await c.req.text();
await (await appWorkspace(requestLogContext(c))).handleAppStripeWebhook({ await (await appWorkspace(requestLogContext(c))).handleAppStripeWebhook({

View file

@ -114,6 +114,22 @@ interface WorkspaceHandle {
revertWorkbenchFile(input: TaskWorkbenchDiffInput): Promise<void>; revertWorkbenchFile(input: TaskWorkbenchDiffInput): Promise<void>;
} }
interface AppWorkspaceHandle {
connect(): ActorConn;
getAppSnapshot(input: { sessionId: string }): Promise<FoundryAppSnapshot>;
skipAppStarterRepo(input: { sessionId: string }): Promise<FoundryAppSnapshot>;
starAppStarterRepo(input: { sessionId: string; organizationId: string }): Promise<FoundryAppSnapshot>;
selectAppOrganization(input: { sessionId: string; organizationId: string }): Promise<FoundryAppSnapshot>;
updateAppOrganizationProfile(input: UpdateFoundryOrganizationProfileInput & { sessionId: string }): Promise<FoundryAppSnapshot>;
triggerAppRepoImport(input: { sessionId: string; organizationId: string }): Promise<FoundryAppSnapshot>;
beginAppGithubInstall(input: { sessionId: string; organizationId: string }): Promise<{ url: string }>;
createAppCheckoutSession(input: { sessionId: string; organizationId: string; planId: FoundryBillingPlanId }): Promise<{ url: string }>;
createAppBillingPortalSession(input: { sessionId: string; organizationId: string }): Promise<{ url: string }>;
cancelAppScheduledRenewal(input: { sessionId: string; organizationId: string }): Promise<FoundryAppSnapshot>;
resumeAppSubscription(input: { sessionId: string; organizationId: string }): Promise<FoundryAppSnapshot>;
recordAppSeatUsage(input: { sessionId: string; workspaceId: string }): Promise<FoundryAppSnapshot>;
}
interface TaskHandle { interface TaskHandle {
getTaskSummary(): Promise<WorkbenchTaskSummary>; getTaskSummary(): Promise<WorkbenchTaskSummary>;
getTaskDetail(): Promise<WorkbenchTaskDetail>; getTaskDetail(): Promise<WorkbenchTaskDetail>;
@ -317,6 +333,24 @@ function deriveBackendEndpoints(endpoint: string): { appEndpoint: string; rivetE
}; };
} }
function signedOutAppSnapshot(): FoundryAppSnapshot {
return {
auth: { status: "signed_out", currentUserId: null },
activeOrganizationId: null,
onboarding: {
starterRepo: {
repoFullName: "rivet-dev/sandbox-agent",
repoUrl: "https://github.com/rivet-dev/sandbox-agent",
status: "pending",
starredAt: null,
skippedAt: null,
},
},
users: [],
organizations: [],
};
}
export function createBackendClient(options: BackendClientOptions): BackendClient { export function createBackendClient(options: BackendClientOptions): BackendClient {
if (options.mode === "mock") { if (options.mode === "mock") {
return createMockBackendClient(options.defaultWorkspaceId); return createMockBackendClient(options.defaultWorkspaceId);
@ -362,11 +396,19 @@ export function createBackendClient(options: BackendClientOptions): BackendClien
return (await res.json()) as T; return (await res.json()) as T;
}; };
const redirectTo = async (path: string, init?: RequestInit): Promise<void> => { const getSessionId = async (): Promise<string | null> => {
const response = await appRequest<{ url: string }>(path, init); const res = await fetch(`${appApiEndpoint}/auth/get-session`, {
if (typeof window !== "undefined") { credentials: "include",
window.location.assign(response.url); });
if (res.status === 401) {
return null;
} }
if (!res.ok) {
throw new Error(`auth session request failed: ${res.status} ${res.statusText}`);
}
const data = (await res.json().catch(() => null)) as { session?: { id?: string | null } | null } | null;
const sessionId = data?.session?.id;
return typeof sessionId === "string" && sessionId.length > 0 ? sessionId : null;
}; };
const workspace = async (workspaceId: string): Promise<WorkspaceHandle> => const workspace = async (workspaceId: string): Promise<WorkspaceHandle> =>
@ -374,6 +416,11 @@ export function createBackendClient(options: BackendClientOptions): BackendClien
createWithInput: workspaceId, createWithInput: workspaceId,
}); });
const appWorkspace = async (): Promise<AppWorkspaceHandle> =>
client.workspace.getOrCreate(workspaceKey("app"), {
createWithInput: "app",
}) as unknown as AppWorkspaceHandle;
const task = async (workspaceId: string, repoId: string, taskId: string): Promise<TaskHandle> => client.task.get(taskKey(workspaceId, repoId, taskId)); const task = async (workspaceId: string, repoId: string, taskId: string): Promise<TaskHandle> => client.task.get(taskKey(workspaceId, repoId, taskId));
const sandboxByKey = async (workspaceId: string, providerId: ProviderId, sandboxId: string): Promise<SandboxInstanceHandle> => { const sandboxByKey = async (workspaceId: string, providerId: ProviderId, sandboxId: string): Promise<SandboxInstanceHandle> => {
@ -634,7 +681,7 @@ export function createBackendClient(options: BackendClientOptions): BackendClien
if (!appSubscriptions.disposeConnPromise) { if (!appSubscriptions.disposeConnPromise) {
appSubscriptions.disposeConnPromise = (async () => { appSubscriptions.disposeConnPromise = (async () => {
const handle = await workspace("app"); const handle = await appWorkspace();
const conn = (handle as any).connect(); const conn = (handle as any).connect();
const unsubscribeEvent = conn.on("appUpdated", () => { const unsubscribeEvent = conn.on("appUpdated", () => {
for (const currentListener of [...appSubscriptions.listeners]) { for (const currentListener of [...appSubscriptions.listeners]) {
@ -665,7 +712,11 @@ export function createBackendClient(options: BackendClientOptions): BackendClien
return { return {
async getAppSnapshot(): Promise<FoundryAppSnapshot> { async getAppSnapshot(): Promise<FoundryAppSnapshot> {
return await appRequest<FoundryAppSnapshot>("/app/snapshot"); const sessionId = await getSessionId();
if (!sessionId) {
return signedOutAppSnapshot();
}
return await (await appWorkspace()).getAppSnapshot({ sessionId });
}, },
async connectWorkspace(workspaceId: string): Promise<ActorConn> { async connectWorkspace(workspaceId: string): Promise<ActorConn> {
@ -704,75 +755,106 @@ export function createBackendClient(options: BackendClientOptions): BackendClien
}, },
async skipAppStarterRepo(): Promise<FoundryAppSnapshot> { async skipAppStarterRepo(): Promise<FoundryAppSnapshot> {
return await appRequest<FoundryAppSnapshot>("/app/onboarding/starter-repo/skip", { const sessionId = await getSessionId();
method: "POST", if (!sessionId) {
}); throw new Error("No active auth session");
}
return await (await appWorkspace()).skipAppStarterRepo({ sessionId });
}, },
async starAppStarterRepo(organizationId: string): Promise<FoundryAppSnapshot> { async starAppStarterRepo(organizationId: string): Promise<FoundryAppSnapshot> {
return await appRequest<FoundryAppSnapshot>(`/app/organizations/${organizationId}/starter-repo/star`, { const sessionId = await getSessionId();
method: "POST", if (!sessionId) {
}); throw new Error("No active auth session");
}
return await (await appWorkspace()).starAppStarterRepo({ sessionId, organizationId });
}, },
async selectAppOrganization(organizationId: string): Promise<FoundryAppSnapshot> { async selectAppOrganization(organizationId: string): Promise<FoundryAppSnapshot> {
return await appRequest<FoundryAppSnapshot>(`/app/organizations/${organizationId}/select`, { const sessionId = await getSessionId();
method: "POST", if (!sessionId) {
}); throw new Error("No active auth session");
}
return await (await appWorkspace()).selectAppOrganization({ sessionId, organizationId });
}, },
async updateAppOrganizationProfile(input: UpdateFoundryOrganizationProfileInput): Promise<FoundryAppSnapshot> { async updateAppOrganizationProfile(input: UpdateFoundryOrganizationProfileInput): Promise<FoundryAppSnapshot> {
return await appRequest<FoundryAppSnapshot>(`/app/organizations/${input.organizationId}/profile`, { const sessionId = await getSessionId();
method: "PATCH", if (!sessionId) {
body: JSON.stringify({ throw new Error("No active auth session");
}
return await (await appWorkspace()).updateAppOrganizationProfile({
sessionId,
organizationId: input.organizationId,
displayName: input.displayName, displayName: input.displayName,
slug: input.slug, slug: input.slug,
primaryDomain: input.primaryDomain, primaryDomain: input.primaryDomain,
}),
}); });
}, },
async triggerAppRepoImport(organizationId: string): Promise<FoundryAppSnapshot> { async triggerAppRepoImport(organizationId: string): Promise<FoundryAppSnapshot> {
return await appRequest<FoundryAppSnapshot>(`/app/organizations/${organizationId}/import`, { const sessionId = await getSessionId();
method: "POST", if (!sessionId) {
}); throw new Error("No active auth session");
}
return await (await appWorkspace()).triggerAppRepoImport({ sessionId, organizationId });
}, },
async reconnectAppGithub(organizationId: string): Promise<void> { async reconnectAppGithub(organizationId: string): Promise<void> {
await redirectTo(`/app/organizations/${organizationId}/reconnect`, { const sessionId = await getSessionId();
method: "POST", if (!sessionId) {
}); throw new Error("No active auth session");
}
const response = await (await appWorkspace()).beginAppGithubInstall({ sessionId, organizationId });
if (typeof window !== "undefined") {
window.location.assign(response.url);
}
}, },
async completeAppHostedCheckout(organizationId: string, planId: FoundryBillingPlanId): Promise<void> { async completeAppHostedCheckout(organizationId: string, planId: FoundryBillingPlanId): Promise<void> {
await redirectTo(`/app/organizations/${organizationId}/billing/checkout`, { const sessionId = await getSessionId();
method: "POST", if (!sessionId) {
body: JSON.stringify({ planId }), throw new Error("No active auth session");
}); }
const response = await (await appWorkspace()).createAppCheckoutSession({ sessionId, organizationId, planId });
if (typeof window !== "undefined") {
window.location.assign(response.url);
}
}, },
async openAppBillingPortal(organizationId: string): Promise<void> { async openAppBillingPortal(organizationId: string): Promise<void> {
await redirectTo(`/app/organizations/${organizationId}/billing/portal`, { const sessionId = await getSessionId();
method: "POST", if (!sessionId) {
}); throw new Error("No active auth session");
}
const response = await (await appWorkspace()).createAppBillingPortalSession({ sessionId, organizationId });
if (typeof window !== "undefined") {
window.location.assign(response.url);
}
}, },
async cancelAppScheduledRenewal(organizationId: string): Promise<FoundryAppSnapshot> { async cancelAppScheduledRenewal(organizationId: string): Promise<FoundryAppSnapshot> {
return await appRequest<FoundryAppSnapshot>(`/app/organizations/${organizationId}/billing/cancel`, { const sessionId = await getSessionId();
method: "POST", if (!sessionId) {
}); throw new Error("No active auth session");
}
return await (await appWorkspace()).cancelAppScheduledRenewal({ sessionId, organizationId });
}, },
async resumeAppSubscription(organizationId: string): Promise<FoundryAppSnapshot> { async resumeAppSubscription(organizationId: string): Promise<FoundryAppSnapshot> {
return await appRequest<FoundryAppSnapshot>(`/app/organizations/${organizationId}/billing/resume`, { const sessionId = await getSessionId();
method: "POST", if (!sessionId) {
}); throw new Error("No active auth session");
}
return await (await appWorkspace()).resumeAppSubscription({ sessionId, organizationId });
}, },
async recordAppSeatUsage(workspaceId: string): Promise<FoundryAppSnapshot> { async recordAppSeatUsage(workspaceId: string): Promise<FoundryAppSnapshot> {
return await appRequest<FoundryAppSnapshot>(`/app/workspaces/${workspaceId}/seat-usage`, { const sessionId = await getSessionId();
method: "POST", if (!sessionId) {
}); throw new Error("No active auth session");
}
return await (await appWorkspace()).recordAppSeatUsage({ sessionId, workspaceId });
}, },
async addRepo(workspaceId: string, remoteUrl: string): Promise<RepoRecord> { async addRepo(workspaceId: string, remoteUrl: string): Promise<RepoRecord> {

View file

@ -1,35 +0,0 @@
{
"name": "@sandbox-agent/foundry-frontend-errors",
"version": "0.1.0",
"private": true,
"type": "module",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
},
"./client": {
"types": "./dist/client.d.ts",
"default": "./dist/client.js"
},
"./vite": {
"types": "./dist/vite.d.ts",
"default": "./dist/vite.js"
}
},
"scripts": {
"build": "tsup src/index.ts src/client.ts src/vite.ts --format esm --dts",
"typecheck": "tsc --noEmit",
"test": "vitest run"
},
"dependencies": {
"@hono/node-server": "^1.19.9",
"hono": "^4.11.9"
},
"devDependencies": {
"tsup": "^8.5.0",
"vite": "^7.1.3"
}
}

View file

@ -1,35 +0,0 @@
import type { FrontendErrorContext } from "./types.js";
interface FrontendErrorCollectorGlobal {
setContext: (context: FrontendErrorContext) => void;
}
declare global {
interface Window {
__FOUNDRY_FRONTEND_ERROR_COLLECTOR__?: FrontendErrorCollectorGlobal;
__FOUNDRY_FRONTEND_ERROR_CONTEXT__?: FrontendErrorContext;
}
}
export function setFrontendErrorContext(context: FrontendErrorContext): void {
if (typeof window === "undefined") {
return;
}
const nextContext = sanitizeContext(context);
window.__FOUNDRY_FRONTEND_ERROR_CONTEXT__ = {
...(window.__FOUNDRY_FRONTEND_ERROR_CONTEXT__ ?? {}),
...nextContext,
};
window.__FOUNDRY_FRONTEND_ERROR_COLLECTOR__?.setContext(nextContext);
}
function sanitizeContext(input: FrontendErrorContext): FrontendErrorContext {
const output: FrontendErrorContext = {};
for (const [key, value] of Object.entries(input)) {
if (value === null || value === undefined || typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
output[key] = value;
}
}
return output;
}

View file

@ -1,3 +0,0 @@
export * from "./router.js";
export * from "./script.js";
export * from "./types.js";

View file

@ -1,267 +0,0 @@
import { existsSync } from "node:fs";
import { appendFile, mkdir } from "node:fs/promises";
import { dirname, join, resolve } from "node:path";
import { Hono } from "hono";
import type { FrontendErrorContext, FrontendErrorKind, FrontendErrorLogEvent } from "./types.js";
const DEFAULT_RELATIVE_LOG_PATH = ".foundry/logs/frontend-errors.ndjson";
const DEFAULT_REPORTER = "foundry-frontend";
const MAX_FIELD_LENGTH = 12_000;
export interface FrontendErrorCollectorRouterOptions {
logFilePath?: string;
reporter?: string;
}
export function findProjectRoot(startDirectory: string = process.cwd()): string {
let currentDirectory = resolve(startDirectory);
while (true) {
if (existsSync(join(currentDirectory, ".git"))) {
return currentDirectory;
}
const parentDirectory = dirname(currentDirectory);
if (parentDirectory === currentDirectory) {
return resolve(startDirectory);
}
currentDirectory = parentDirectory;
}
}
export function defaultFrontendErrorLogPath(startDirectory: string = process.cwd()): string {
const root = findProjectRoot(startDirectory);
return resolve(root, DEFAULT_RELATIVE_LOG_PATH);
}
export function createFrontendErrorCollectorRouter(options: FrontendErrorCollectorRouterOptions = {}): Hono {
const logFilePath = options.logFilePath ?? defaultFrontendErrorLogPath();
const reporter = trimText(options.reporter, 128) ?? DEFAULT_REPORTER;
let ensureLogPathPromise: Promise<void> | null = null;
const app = new Hono();
app.get("/healthz", (c) =>
c.json({
ok: true,
logFilePath,
reporter,
}),
);
app.post("/events", async (c) => {
let parsedBody: unknown;
try {
parsedBody = await c.req.json();
} catch {
return c.json({ ok: false, error: "Expected JSON body" }, 400);
}
const inputEvents = Array.isArray(parsedBody) ? parsedBody : [parsedBody];
if (inputEvents.length === 0) {
return c.json({ ok: false, error: "Expected at least one event" }, 400);
}
const receivedAt = Date.now();
const userAgent = trimText(c.req.header("user-agent"), 512);
const clientIp = readClientIp(c.req.header("x-forwarded-for"));
const normalizedEvents: FrontendErrorLogEvent[] = [];
for (const candidate of inputEvents) {
if (!isObject(candidate)) {
continue;
}
normalizedEvents.push(
normalizeEvent({
candidate,
reporter,
userAgent: userAgent ?? null,
clientIp: clientIp ?? null,
receivedAt,
}),
);
}
if (normalizedEvents.length === 0) {
return c.json({ ok: false, error: "No valid events found in request" }, 400);
}
await ensureLogPath();
const payload = `${normalizedEvents.map((event) => JSON.stringify(event)).join("\n")}\n`;
await appendFile(logFilePath, payload, "utf8");
return c.json(
{
ok: true,
accepted: normalizedEvents.length,
},
202,
);
});
return app;
async function ensureLogPath(): Promise<void> {
ensureLogPathPromise ??= mkdir(dirname(logFilePath), { recursive: true }).then(() => undefined);
await ensureLogPathPromise;
}
}
interface NormalizeEventInput {
candidate: Record<string, unknown>;
reporter: string;
userAgent: string | null;
clientIp: string | null;
receivedAt: number;
}
function normalizeEvent(input: NormalizeEventInput): FrontendErrorLogEvent {
const kind = normalizeKind(input.candidate.kind);
return {
id: createEventId(),
kind,
message: trimText(input.candidate.message, MAX_FIELD_LENGTH) ?? "(no message)",
stack: trimText(input.candidate.stack, MAX_FIELD_LENGTH) ?? null,
source: trimText(input.candidate.source, 1024) ?? null,
line: normalizeNumber(input.candidate.line),
column: normalizeNumber(input.candidate.column),
url: trimText(input.candidate.url, 2048) ?? null,
timestamp: normalizeTimestamp(input.candidate.timestamp),
receivedAt: input.receivedAt,
userAgent: input.userAgent,
clientIp: input.clientIp,
reporter: input.reporter,
context: normalizeContext(input.candidate.context),
extra: normalizeExtra(input.candidate.extra),
};
}
function normalizeKind(value: unknown): FrontendErrorKind {
switch (value) {
case "window-error":
case "resource-error":
case "unhandled-rejection":
case "console-error":
case "fetch-error":
case "fetch-response-error":
return value;
default:
return "window-error";
}
}
function normalizeTimestamp(value: unknown): number {
const parsed = normalizeNumber(value);
if (parsed === null) {
return Date.now();
}
return parsed;
}
function normalizeNumber(value: unknown): number | null {
if (typeof value !== "number" || !Number.isFinite(value)) {
return null;
}
return value;
}
function normalizeContext(value: unknown): FrontendErrorContext {
if (!isObject(value)) {
return {};
}
const context: FrontendErrorContext = {};
for (const [key, candidate] of Object.entries(value)) {
if (!isAllowedContextValue(candidate)) {
continue;
}
const safeKey = trimText(key, 128);
if (!safeKey) {
continue;
}
if (typeof candidate === "string") {
context[safeKey] = trimText(candidate, 1024);
continue;
}
context[safeKey] = candidate;
}
return context;
}
function normalizeExtra(value: unknown): Record<string, unknown> {
if (!isObject(value)) {
return {};
}
const normalized: Record<string, unknown> = {};
for (const [key, candidate] of Object.entries(value)) {
const safeKey = trimText(key, 128);
if (!safeKey) {
continue;
}
normalized[safeKey] = normalizeUnknown(candidate);
}
return normalized;
}
function normalizeUnknown(value: unknown): unknown {
if (typeof value === "string") {
return trimText(value, 1024) ?? "";
}
if (typeof value === "number" || typeof value === "boolean" || value === null) {
return value;
}
if (Array.isArray(value)) {
return value.slice(0, 25).map((item) => normalizeUnknown(item));
}
if (isObject(value)) {
const output: Record<string, unknown> = {};
const entries = Object.entries(value).slice(0, 25);
for (const [key, candidate] of entries) {
const safeKey = trimText(key, 128);
if (!safeKey) {
continue;
}
output[safeKey] = normalizeUnknown(candidate);
}
return output;
}
return String(value);
}
function trimText(value: unknown, maxLength: number): string | null {
if (typeof value !== "string") {
return null;
}
const trimmed = value.trim();
if (!trimmed) {
return null;
}
if (trimmed.length <= maxLength) {
return trimmed;
}
return `${trimmed.slice(0, maxLength)}...(truncated)`;
}
function createEventId(): string {
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
return crypto.randomUUID();
}
return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
}
function readClientIp(forwardedFor: string | undefined): string | null {
if (!forwardedFor) {
return null;
}
const [first] = forwardedFor.split(",");
return trimText(first, 64) ?? null;
}
function isAllowedContextValue(value: unknown): value is string | number | boolean | null | undefined {
return value === null || value === undefined || typeof value === "string" || typeof value === "number" || typeof value === "boolean";
}
function isObject(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}

View file

@ -1,246 +0,0 @@
import type { FrontendErrorCollectorScriptOptions } from "./types.js";
const DEFAULT_REPORTER = "foundry-frontend";
export function createFrontendErrorCollectorScript(options: FrontendErrorCollectorScriptOptions): string {
const config = {
endpoint: options.endpoint,
reporter: options.reporter ?? DEFAULT_REPORTER,
includeConsoleErrors: options.includeConsoleErrors ?? true,
includeFetchErrors: options.includeFetchErrors ?? true,
};
return `(function () {
if (typeof window === "undefined") {
return;
}
if (window.__FOUNDRY_FRONTEND_ERROR_COLLECTOR__) {
return;
}
var config = ${JSON.stringify(config)};
var sharedContext = window.__FOUNDRY_FRONTEND_ERROR_CONTEXT__ || {};
window.__FOUNDRY_FRONTEND_ERROR_CONTEXT__ = sharedContext;
function now() {
return Date.now();
}
function clampText(input, maxLength) {
if (typeof input !== "string") {
return null;
}
var value = input.trim();
if (!value) {
return null;
}
if (value.length <= maxLength) {
return value;
}
return value.slice(0, maxLength) + "...(truncated)";
}
function currentRoute() {
return location.pathname + location.search + location.hash;
}
function safeContext() {
var copy = {};
for (var key in sharedContext) {
if (!Object.prototype.hasOwnProperty.call(sharedContext, key)) {
continue;
}
var candidate = sharedContext[key];
if (
candidate === null ||
candidate === undefined ||
typeof candidate === "string" ||
typeof candidate === "number" ||
typeof candidate === "boolean"
) {
copy[key] = candidate;
}
}
copy.route = currentRoute();
return copy;
}
function stringifyUnknown(input) {
if (typeof input === "string") {
return input;
}
if (input instanceof Error) {
return input.stack || input.message || String(input);
}
try {
return JSON.stringify(input);
} catch {
return String(input);
}
}
var internalSendInFlight = false;
function send(eventPayload) {
var payload = {
kind: eventPayload.kind || "window-error",
message: clampText(eventPayload.message || "(no message)", 12000),
stack: clampText(eventPayload.stack, 12000),
source: clampText(eventPayload.source, 1024),
line: typeof eventPayload.line === "number" ? eventPayload.line : null,
column: typeof eventPayload.column === "number" ? eventPayload.column : null,
url: clampText(eventPayload.url || location.href, 2048),
timestamp: typeof eventPayload.timestamp === "number" ? eventPayload.timestamp : now(),
context: safeContext(),
extra: eventPayload.extra || {},
};
var body = JSON.stringify(payload);
if (navigator.sendBeacon && body.length < 60000) {
var blob = new Blob([body], { type: "application/json" });
navigator.sendBeacon(config.endpoint, blob);
return;
}
if (internalSendInFlight) {
return;
}
internalSendInFlight = true;
fetch(config.endpoint, {
method: "POST",
headers: { "content-type": "application/json" },
credentials: "same-origin",
keepalive: true,
body: body,
}).catch(function () {
return;
}).finally(function () {
internalSendInFlight = false;
});
}
window.__FOUNDRY_FRONTEND_ERROR_COLLECTOR__ = {
setContext: function (nextContext) {
if (!nextContext || typeof nextContext !== "object") {
return;
}
for (var key in nextContext) {
if (!Object.prototype.hasOwnProperty.call(nextContext, key)) {
continue;
}
sharedContext[key] = nextContext[key];
}
},
};
if (config.includeConsoleErrors) {
var originalConsoleError = console.error.bind(console);
console.error = function () {
var message = "";
var values = [];
for (var index = 0; index < arguments.length; index += 1) {
values.push(stringifyUnknown(arguments[index]));
}
message = values.join(" ");
send({
kind: "console-error",
message: message || "console.error called",
timestamp: now(),
extra: { args: values.slice(0, 10) },
});
return originalConsoleError.apply(console, arguments);
};
}
window.addEventListener("error", function (event) {
var target = event.target;
var hasResourceTarget = target && target !== window && typeof target === "object";
if (hasResourceTarget) {
var url = null;
if ("src" in target && typeof target.src === "string") {
url = target.src;
} else if ("href" in target && typeof target.href === "string") {
url = target.href;
}
send({
kind: "resource-error",
message: "Resource failed to load",
source: event.filename || null,
line: typeof event.lineno === "number" ? event.lineno : null,
column: typeof event.colno === "number" ? event.colno : null,
url: url || location.href,
stack: null,
timestamp: now(),
});
return;
}
var message = clampText(event.message, 12000) || "Unhandled window error";
var stack = event.error && event.error.stack ? String(event.error.stack) : null;
send({
kind: "window-error",
message: message,
stack: stack,
source: event.filename || null,
line: typeof event.lineno === "number" ? event.lineno : null,
column: typeof event.colno === "number" ? event.colno : null,
url: location.href,
timestamp: now(),
});
}, true);
window.addEventListener("unhandledrejection", function (event) {
var reason = event.reason;
var stack = reason && reason.stack ? String(reason.stack) : null;
send({
kind: "unhandled-rejection",
message: stringifyUnknown(reason),
stack: stack,
url: location.href,
timestamp: now(),
});
});
if (config.includeFetchErrors && typeof window.fetch === "function") {
var originalFetch = window.fetch.bind(window);
window.fetch = function () {
var args = arguments;
var requestUrl = null;
if (typeof args[0] === "string") {
requestUrl = args[0];
} else if (args[0] && typeof args[0].url === "string") {
requestUrl = args[0].url;
}
return originalFetch.apply(window, args).then(function (response) {
if (!response.ok && response.status >= 500) {
send({
kind: "fetch-response-error",
message: "Fetch returned HTTP " + response.status,
url: requestUrl || location.href,
timestamp: now(),
extra: {
status: response.status,
statusText: response.statusText,
},
});
}
return response;
}).catch(function (error) {
send({
kind: "fetch-error",
message: stringifyUnknown(error),
stack: error && error.stack ? String(error.stack) : null,
url: requestUrl || location.href,
timestamp: now(),
});
throw error;
});
};
}
})();`;
}

View file

@ -1,46 +0,0 @@
export type FrontendErrorKind = "window-error" | "resource-error" | "unhandled-rejection" | "console-error" | "fetch-error" | "fetch-response-error";
export interface FrontendErrorContext {
route?: string;
workspaceId?: string;
taskId?: string;
[key: string]: string | number | boolean | null | undefined;
}
export interface FrontendErrorEventInput {
kind?: string;
message?: string;
stack?: string | null;
source?: string | null;
line?: number | null;
column?: number | null;
url?: string | null;
timestamp?: number;
context?: FrontendErrorContext | null;
extra?: Record<string, unknown> | null;
}
export interface FrontendErrorLogEvent {
id: string;
kind: FrontendErrorKind;
message: string;
stack: string | null;
source: string | null;
line: number | null;
column: number | null;
url: string | null;
timestamp: number;
receivedAt: number;
userAgent: string | null;
clientIp: string | null;
reporter: string;
context: FrontendErrorContext;
extra: Record<string, unknown>;
}
export interface FrontendErrorCollectorScriptOptions {
endpoint: string;
reporter?: string;
includeConsoleErrors?: boolean;
includeFetchErrors?: boolean;
}

View file

@ -1,76 +0,0 @@
import { getRequestListener } from "@hono/node-server";
import { Hono } from "hono";
import type { Plugin } from "vite";
import { createFrontendErrorCollectorRouter, defaultFrontendErrorLogPath } from "./router.js";
import { createFrontendErrorCollectorScript } from "./script.js";
const DEFAULT_MOUNT_PATH = "/__foundry/frontend-errors";
const DEFAULT_EVENT_PATH = "/events";
export interface FrontendErrorCollectorVitePluginOptions {
mountPath?: string;
logFilePath?: string;
reporter?: string;
includeConsoleErrors?: boolean;
includeFetchErrors?: boolean;
}
export function frontendErrorCollectorVitePlugin(options: FrontendErrorCollectorVitePluginOptions = {}): Plugin {
const mountPath = normalizePath(options.mountPath ?? DEFAULT_MOUNT_PATH);
const logFilePath = options.logFilePath ?? defaultFrontendErrorLogPath(process.cwd());
const reporter = options.reporter ?? "foundry-vite";
const endpoint = `${mountPath}${DEFAULT_EVENT_PATH}`;
const router = createFrontendErrorCollectorRouter({
logFilePath,
reporter,
});
const mountApp = new Hono().route(mountPath, router);
const listener = getRequestListener(mountApp.fetch);
return {
name: "foundry:frontend-error-collector",
apply: "serve",
transformIndexHtml(html) {
return {
html,
tags: [
{
tag: "script",
attrs: { type: "module" },
children: createFrontendErrorCollectorScript({
endpoint,
reporter,
includeConsoleErrors: options.includeConsoleErrors,
includeFetchErrors: options.includeFetchErrors,
}),
injectTo: "head-prepend",
},
],
};
},
configureServer(server) {
server.middlewares.use((req, res, next) => {
if (!req.url?.startsWith(mountPath)) {
return next();
}
void listener(req, res).catch((error) => next(error));
});
},
configurePreviewServer(server) {
server.middlewares.use((req, res, next) => {
if (!req.url?.startsWith(mountPath)) {
return next();
}
void listener(req, res).catch((error) => next(error));
});
},
};
}
function normalizePath(path: string): string {
if (!path.startsWith("/")) {
return `/${path}`;
}
return path.replace(/\/+$/, "");
}

View file

@ -1,55 +0,0 @@
import { mkdtemp, readFile, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { describe, expect, test } from "vitest";
import { createFrontendErrorCollectorRouter } from "../src/router.js";
import { createFrontendErrorCollectorScript } from "../src/script.js";
describe("frontend error collector router", () => {
test("writes accepted event payloads to NDJSON", async () => {
const directory = await mkdtemp(join(tmpdir(), "hf-frontend-errors-"));
const logFilePath = join(directory, "events.ndjson");
const app = createFrontendErrorCollectorRouter({ logFilePath, reporter: "test-suite" });
try {
const response = await app.request("/events", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
kind: "window-error",
message: "Boom",
stack: "at app.tsx:1:1",
context: { route: "/workspaces/default" },
}),
});
expect(response.status).toBe(202);
const written = await readFile(logFilePath, "utf8");
const [firstLine] = written.trim().split("\n");
expect(firstLine).toBeTruthy();
const parsed = JSON.parse(firstLine ?? "{}") as {
kind?: string;
message?: string;
reporter?: string;
context?: { route?: string };
};
expect(parsed.kind).toBe("window-error");
expect(parsed.message).toBe("Boom");
expect(parsed.reporter).toBe("test-suite");
expect(parsed.context?.route).toBe("/workspaces/default");
} finally {
await rm(directory, { recursive: true, force: true });
}
});
});
describe("frontend error collector script", () => {
test("embeds configured endpoint", () => {
const script = createFrontendErrorCollectorScript({
endpoint: "/__foundry/frontend-errors/events",
});
expect(script).toContain("/__foundry/frontend-errors/events");
expect(script).toContain('window.addEventListener("error"');
});
});

View file

@ -1,7 +0,0 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "dist"
},
"include": ["src", "test", "vitest.config.ts"]
}

View file

@ -1,8 +0,0 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
environment: "node",
include: ["test/**/*.test.ts"],
},
});

View file

@ -12,7 +12,6 @@
"dependencies": { "dependencies": {
"@sandbox-agent/react": "workspace:*", "@sandbox-agent/react": "workspace:*",
"@sandbox-agent/foundry-client": "workspace:*", "@sandbox-agent/foundry-client": "workspace:*",
"@sandbox-agent/foundry-frontend-errors": "workspace:*",
"@sandbox-agent/foundry-shared": "workspace:*", "@sandbox-agent/foundry-shared": "workspace:*",
"@tanstack/react-query": "^5.85.5", "@tanstack/react-query": "^5.85.5",
"@tanstack/react-router": "^1.132.23", "@tanstack/react-router": "^1.132.23",

View file

@ -1,8 +1,7 @@
import { type ReactNode, useEffect } from "react"; import { type ReactNode, useEffect } from "react";
import { setFrontendErrorContext } from "@sandbox-agent/foundry-frontend-errors/client";
import type { FoundryBillingPlanId } from "@sandbox-agent/foundry-shared"; import type { FoundryBillingPlanId } from "@sandbox-agent/foundry-shared";
import { useInterest } from "@sandbox-agent/foundry-client"; import { useInterest } from "@sandbox-agent/foundry-client";
import { Navigate, Outlet, createRootRoute, createRoute, createRouter, useRouterState } from "@tanstack/react-router"; import { Navigate, Outlet, createRootRoute, createRoute, createRouter } from "@tanstack/react-router";
import { MockLayout } from "../components/mock-layout"; import { MockLayout } from "../components/mock-layout";
import { import {
MockAccountSettingsPage, MockAccountSettingsPage,
@ -257,13 +256,6 @@ function WorkspaceView({
selectedTaskId: string | null; selectedTaskId: string | null;
selectedSessionId: string | null; selectedSessionId: string | null;
}) { }) {
useEffect(() => {
setFrontendErrorContext({
workspaceId,
taskId: undefined,
});
}, [workspaceId]);
return <MockLayout workspaceId={workspaceId} selectedTaskId={selectedTaskId} selectedSessionId={selectedSessionId} />; return <MockLayout workspaceId={workspaceId} selectedTaskId={selectedTaskId} selectedSessionId={selectedSessionId} />;
} }
@ -278,14 +270,6 @@ function TaskRoute() {
} }
function TaskView({ workspaceId, taskId, sessionId }: { workspaceId: string; taskId: string; sessionId: string | null }) { function TaskView({ workspaceId, taskId, sessionId }: { workspaceId: string; taskId: string; sessionId: string | null }) {
useEffect(() => {
setFrontendErrorContext({
workspaceId,
taskId,
repoId: undefined,
});
}, [taskId, workspaceId]);
return <MockLayout workspaceId={workspaceId} selectedTaskId={taskId} selectedSessionId={sessionId} />; return <MockLayout workspaceId={workspaceId} selectedTaskId={taskId} selectedSessionId={sessionId} />;
} }
@ -326,13 +310,6 @@ function AppWorkspaceGate({ workspaceId, children }: { workspaceId: string; chil
function RepoRouteInner({ workspaceId, repoId }: { workspaceId: string; repoId: string }) { function RepoRouteInner({ workspaceId, repoId }: { workspaceId: string; repoId: string }) {
const workspaceState = useInterest(interestManager, "workspace", { workspaceId }); const workspaceState = useInterest(interestManager, "workspace", { workspaceId });
useEffect(() => {
setFrontendErrorContext({
workspaceId,
taskId: undefined,
repoId,
});
}, [repoId, workspaceId]);
const activeTaskId = workspaceState.data?.taskSummaries.find((task) => task.repoId === repoId)?.id; const activeTaskId = workspaceState.data?.taskSummaries.find((task) => task.repoId === repoId)?.id;
if (!activeTaskId) { if (!activeTaskId) {
return <Navigate to="/workspaces/$workspaceId" params={{ workspaceId }} replace />; return <Navigate to="/workspaces/$workspaceId" params={{ workspaceId }} replace />;
@ -343,22 +320,7 @@ function RepoRouteInner({ workspaceId, repoId }: { workspaceId: string; repoId:
function RootLayout() { function RootLayout() {
return ( return (
<> <>
<RouteContextSync />
<Outlet /> <Outlet />
</> </>
); );
} }
function RouteContextSync() {
const location = useRouterState({
select: (state) => state.location,
});
useEffect(() => {
setFrontendErrorContext({
route: `${location.pathname}${location.search}${location.hash}`,
});
}, [location.hash, location.pathname, location.search]);
return null;
}

View file

@ -1,7 +1,6 @@
import { defineConfig } from "vite"; import { defineConfig } from "vite";
import { resolve } from "node:path"; import { resolve } from "node:path";
import react from "@vitejs/plugin-react"; import react from "@vitejs/plugin-react";
import { frontendErrorCollectorVitePlugin } from "@sandbox-agent/foundry-frontend-errors/vite";
const backendProxyTarget = process.env.HF_BACKEND_HTTP?.trim() || "http://127.0.0.1:7741"; const backendProxyTarget = process.env.HF_BACKEND_HTTP?.trim() || "http://127.0.0.1:7741";
const cacheDir = process.env.HF_VITE_CACHE_DIR?.trim() || undefined; const cacheDir = process.env.HF_VITE_CACHE_DIR?.trim() || undefined;
@ -9,7 +8,7 @@ export default defineConfig({
define: { define: {
"import.meta.env.FOUNDRY_FRONTEND_CLIENT_MODE": JSON.stringify(process.env.FOUNDRY_FRONTEND_CLIENT_MODE?.trim() || "remote"), "import.meta.env.FOUNDRY_FRONTEND_CLIENT_MODE": JSON.stringify(process.env.FOUNDRY_FRONTEND_CLIENT_MODE?.trim() || "remote"),
}, },
plugins: [react(), frontendErrorCollectorVitePlugin()], plugins: [react()],
cacheDir, cacheDir,
resolve: { resolve: {
alias: { alias: {

View file

@ -15,9 +15,7 @@
"paths": { "paths": {
"@sandbox-agent/foundry-client": ["packages/client/src/index.ts"], "@sandbox-agent/foundry-client": ["packages/client/src/index.ts"],
"@sandbox-agent/foundry-shared": ["packages/shared/src/index.ts"], "@sandbox-agent/foundry-shared": ["packages/shared/src/index.ts"],
"@sandbox-agent/foundry-backend": ["packages/backend/src/index.ts"], "@sandbox-agent/foundry-backend": ["packages/backend/src/index.ts"]
"@sandbox-agent/foundry-frontend-errors": ["packages/frontend-errors/src/index.ts"],
"@sandbox-agent/foundry-frontend-errors/*": ["packages/frontend-errors/src/*"]
}, },
"noUncheckedIndexedAccess": true, "noUncheckedIndexedAccess": true,
"noImplicitOverride": true "noImplicitOverride": true

43
pnpm-lock.yaml generated
View file

@ -35,7 +35,7 @@ importers:
dependencies: dependencies:
'@boxlite-ai/boxlite': '@boxlite-ai/boxlite':
specifier: latest specifier: latest
version: 0.4.1 version: 0.4.2
'@sandbox-agent/example-shared': '@sandbox-agent/example-shared':
specifier: workspace:* specifier: workspace:*
version: link:../shared version: link:../shared
@ -554,9 +554,6 @@ importers:
'@sandbox-agent/foundry-client': '@sandbox-agent/foundry-client':
specifier: workspace:* specifier: workspace:*
version: link:../client version: link:../client
'@sandbox-agent/foundry-frontend-errors':
specifier: workspace:*
version: link:../frontend-errors
'@sandbox-agent/foundry-shared': '@sandbox-agent/foundry-shared':
specifier: workspace:* specifier: workspace:*
version: link:../shared version: link:../shared
@ -613,22 +610,6 @@ importers:
specifier: ^7.1.3 specifier: ^7.1.3
version: 7.3.1(@types/node@25.5.0)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.2) version: 7.3.1(@types/node@25.5.0)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.2)
foundry/packages/frontend-errors:
dependencies:
'@hono/node-server':
specifier: ^1.19.9
version: 1.19.9(hono@4.12.2)
hono:
specifier: ^4.11.9
version: 4.12.2
devDependencies:
tsup:
specifier: ^8.5.0
version: 8.5.1(jiti@1.21.7)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2)
vite:
specifier: ^7.1.3
version: 7.3.1(@types/node@25.5.0)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.2)
foundry/packages/shared: foundry/packages/shared:
dependencies: dependencies:
pino: pino:
@ -1547,20 +1528,20 @@ packages:
cpu: [x64] cpu: [x64]
os: [win32] os: [win32]
'@boxlite-ai/boxlite-darwin-arm64@0.4.1': '@boxlite-ai/boxlite-darwin-arm64@0.4.2':
resolution: {integrity: sha512-OcpQ9fGeQTBLZDOwireul0l6D+kTxs8Qmtalv/hobBUto8cOLX7NjiQ1Huo9kr0UOThu1T8nfDSNol18+iOjpw==} resolution: {integrity: sha512-FwTfA8AyDXwoDb7nE7vGo04uBt2VDAiEl5leNNzroGSUKoTuCxNy8JfEbwwHb54UrIp/q7GNq7hG0JtmyxuubQ==}
engines: {node: '>=18.0.0'} engines: {node: '>=18.0.0'}
cpu: [arm64] cpu: [arm64]
os: [darwin] os: [darwin]
'@boxlite-ai/boxlite-linux-x64-gnu@0.4.1': '@boxlite-ai/boxlite-linux-x64-gnu@0.4.2':
resolution: {integrity: sha512-hBoZxWRvkFS8sztOjtTtgIAEE0K2xzuBtte2hl0sLfg5twgCy2BE/Ds/RikC6hMk6Ug4oc8MeBfWIhSvF70Jjw==} resolution: {integrity: sha512-UIRiTKl1L0cx2igDiikEiBfpNbTZ0W3lft5ow7I2mkDnjtBVIQYSm+PmVXBupTYivAuPh38g9WhqJH44C1RJdQ==}
engines: {node: '>=18.0.0'} engines: {node: '>=18.0.0'}
cpu: [x64] cpu: [x64]
os: [linux] os: [linux]
'@boxlite-ai/boxlite@0.4.1': '@boxlite-ai/boxlite@0.4.2':
resolution: {integrity: sha512-iy302L3Yy4w8UTYQrthYzB0KGFh8y71olSUYnseUnnIQlCgHBlFHCdrdPrgUrttplBu/m4zwTRNCQq4jIzNWeg==} resolution: {integrity: sha512-LVxG0feP1sBGbYz/VOm11VsU8PyUv7rvXOQJqKrfBgI9oRVyqycpY39PCJ1oC+FFho7w7d61q8VCVDlDdj8i6Q==}
engines: {node: '>=18.0.0'} engines: {node: '>=18.0.0'}
peerDependencies: peerDependencies:
playwright-core: '>=1.58.0' playwright-core: '>=1.58.0'
@ -8441,16 +8422,16 @@ snapshots:
'@biomejs/cli-win32-x64@2.4.6': '@biomejs/cli-win32-x64@2.4.6':
optional: true optional: true
'@boxlite-ai/boxlite-darwin-arm64@0.4.1': '@boxlite-ai/boxlite-darwin-arm64@0.4.2':
optional: true optional: true
'@boxlite-ai/boxlite-linux-x64-gnu@0.4.1': '@boxlite-ai/boxlite-linux-x64-gnu@0.4.2':
optional: true optional: true
'@boxlite-ai/boxlite@0.4.1': '@boxlite-ai/boxlite@0.4.2':
optionalDependencies: optionalDependencies:
'@boxlite-ai/boxlite-darwin-arm64': 0.4.1 '@boxlite-ai/boxlite-darwin-arm64': 0.4.2
'@boxlite-ai/boxlite-linux-x64-gnu': 0.4.1 '@boxlite-ai/boxlite-linux-x64-gnu': 0.4.2
'@bufbuild/protobuf@2.11.0': {} '@bufbuild/protobuf@2.11.0': {}