Rename Foundry handoffs to tasks (#239)

* Restore foundry onboarding stack

* Consolidate foundry rename

* Create foundry tasks without prompts

* Rename Foundry handoffs to tasks
This commit is contained in:
Nathan Flurry 2026-03-11 13:23:54 -07:00 committed by GitHub
parent d30cc0bcc8
commit d75e8c31d1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
281 changed files with 9242 additions and 4356 deletions

View file

@ -0,0 +1,35 @@
{
"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

@ -0,0 +1,35 @@
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

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

View file

@ -0,0 +1,267 @@
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

@ -0,0 +1,246 @@
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

@ -0,0 +1,46 @@
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

@ -0,0 +1,76 @@
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

@ -0,0 +1,55 @@
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

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

View file

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